@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.
package/dist/protect.js CHANGED
@@ -1,6 +1,221 @@
1
1
  import {
2
- pulseAuthHeader
3
- } from "./chunk-LLKP5EJS.js";
2
+ pulseAuthHeader,
3
+ pulseFetch
4
+ } from "./chunk-3G2I6QL6.js";
5
+
6
+ // src/protect/client-ip.js
7
+ var ZONE = /^[A-Za-z0-9._-]{1,64}$/;
8
+ var POLICY_FIELDS = Object.freeze(["peers", "hops", "header", "isTrusted"]);
9
+ var IP_SOURCES = Object.freeze(["runtime", "trusted-proxy", "unavailable"]);
10
+ var DEFAULT_FORWARDED_HEADER = "x-forwarded-for";
11
+ function parseIpv4(value) {
12
+ const parts = value.split(".");
13
+ if (parts.length !== 4) return null;
14
+ const octets = [];
15
+ for (const part of parts) {
16
+ if (!/^\d{1,3}$/.test(part)) return null;
17
+ if (part.length > 1 && part.startsWith("0")) return null;
18
+ const n = Number(part);
19
+ if (n > 255) return null;
20
+ octets.push(n);
21
+ }
22
+ return octets;
23
+ }
24
+ function parseIpv6(value) {
25
+ if (value === "" || value.includes(":::")) return null;
26
+ let head = value;
27
+ let embedded = null;
28
+ const lastColon = head.lastIndexOf(":");
29
+ const tail = lastColon === -1 ? "" : head.slice(lastColon + 1);
30
+ if (tail.includes(".")) {
31
+ embedded = parseIpv4(tail);
32
+ if (embedded === null) return null;
33
+ head = head.slice(0, lastColon + 1) + "0:0";
34
+ }
35
+ const halves = head.split("::");
36
+ if (halves.length > 2) return null;
37
+ const readGroups = (text) => {
38
+ if (text === "") return [];
39
+ const groups2 = [];
40
+ for (const g of text.split(":")) {
41
+ if (!/^[0-9a-fA-F]{1,4}$/.test(g)) return null;
42
+ groups2.push(Number.parseInt(g, 16));
43
+ }
44
+ return groups2;
45
+ };
46
+ const left = readGroups(halves[0]);
47
+ const right = halves.length === 2 ? readGroups(halves[1]) : [];
48
+ if (left === null || right === null) return null;
49
+ let groups;
50
+ if (halves.length === 2) {
51
+ const fill = 8 - (left.length + right.length);
52
+ if (fill < 1) return null;
53
+ groups = [...left, ...new Array(fill).fill(0), ...right];
54
+ } else {
55
+ groups = left;
56
+ }
57
+ if (groups.length !== 8) return null;
58
+ if (embedded !== null) {
59
+ groups[6] = embedded[0] << 8 | embedded[1];
60
+ groups[7] = embedded[2] << 8 | embedded[3];
61
+ }
62
+ return groups;
63
+ }
64
+ function toNumeric(value) {
65
+ const text = String(value).trim();
66
+ if (text === "") return null;
67
+ const zoneParts = text.split("%");
68
+ if (zoneParts.length > 2) return null;
69
+ const addr = zoneParts[0];
70
+ const zone = zoneParts.length === 2 ? zoneParts[1] : null;
71
+ if (zone !== null && (!ZONE.test(zone) || !addr.includes(":"))) return null;
72
+ const v4 = parseIpv4(addr);
73
+ if (v4 !== null) {
74
+ return {
75
+ bits: 32,
76
+ value: v4.reduce((acc, octet) => acc << 8n | BigInt(octet), 0n),
77
+ canonical: v4.join(".")
78
+ };
79
+ }
80
+ if (!addr.includes(":")) return null;
81
+ const v6 = parseIpv6(addr);
82
+ if (v6 === null) return null;
83
+ const mapped = v6.slice(0, 5).every((g) => g === 0) && v6[5] === 65535;
84
+ if (mapped) {
85
+ if (zone !== null) return null;
86
+ const octets = [v6[6] >> 8, v6[6] & 255, v6[7] >> 8, v6[7] & 255];
87
+ return { bits: 32, value: BigInt(v6[6]) << 16n | BigInt(v6[7]), canonical: octets.join(".") };
88
+ }
89
+ return {
90
+ bits: 128,
91
+ value: v6.reduce((acc, group) => acc << 16n | BigInt(group), 0n),
92
+ // The zone survives: without it a link-local address does not identify an interface.
93
+ canonical: zone === null ? canonicalIpv6(v6) : `${canonicalIpv6(v6)}%${zone}`
94
+ };
95
+ }
96
+ function canonicalIpv6(groups) {
97
+ let bestStart = -1;
98
+ let bestLength = 0;
99
+ let runStart = -1;
100
+ for (let i = 0; i <= groups.length; i++) {
101
+ if (i < groups.length && groups[i] === 0) {
102
+ if (runStart === -1) runStart = i;
103
+ } else if (runStart !== -1) {
104
+ const length = i - runStart;
105
+ if (length > bestLength) {
106
+ bestLength = length;
107
+ bestStart = runStart;
108
+ }
109
+ runStart = -1;
110
+ }
111
+ }
112
+ const hex = groups.map((g) => g.toString(16));
113
+ if (bestLength < 2) return hex.join(":");
114
+ const head = hex.slice(0, bestStart).join(":");
115
+ const tail = hex.slice(bestStart + bestLength).join(":");
116
+ return `${head}::${tail}`;
117
+ }
118
+ function canonicalIp(value) {
119
+ return toNumeric(value)?.canonical ?? null;
120
+ }
121
+ function parseCidr(entry) {
122
+ if (typeof entry !== "string") return null;
123
+ if (entry.includes("%")) return null;
124
+ const parts = entry.trim().split("/");
125
+ if (parts.length > 2) return null;
126
+ const numeric = toNumeric(parts[0]);
127
+ if (numeric === null) return null;
128
+ let length = numeric.bits;
129
+ if (parts.length === 2) {
130
+ if (!/^\d{1,3}$/.test(parts[1])) return null;
131
+ length = Number(parts[1]);
132
+ if (length > numeric.bits) return null;
133
+ }
134
+ const shift = BigInt(numeric.bits - length);
135
+ return { bits: numeric.bits, network: numeric.value >> shift, shift };
136
+ }
137
+ function readTrustPolicy(trustedProxy) {
138
+ if (trustedProxy === null || typeof trustedProxy !== "object" || Array.isArray(trustedProxy)) return null;
139
+ const has = (field) => Object.hasOwn(trustedProxy, field);
140
+ for (const key of Object.keys(trustedProxy)) {
141
+ if (!POLICY_FIELDS.includes(key)) return null;
142
+ }
143
+ if (has("header") && (typeof trustedProxy.header !== "string" || trustedProxy.header.trim() === "")) {
144
+ return null;
145
+ }
146
+ if (has("hops") && (!Number.isInteger(trustedProxy.hops) || trustedProxy.hops < 1)) return null;
147
+ if (has("isTrusted") && typeof trustedProxy.isTrusted !== "function") return null;
148
+ const header = has("header") ? trustedProxy.header.trim().toLowerCase() : DEFAULT_FORWARDED_HEADER;
149
+ const cidrs = [];
150
+ if (has("peers")) {
151
+ if (!Array.isArray(trustedProxy.peers) || trustedProxy.peers.length === 0) return null;
152
+ for (const entry of trustedProxy.peers) {
153
+ const parsed = parseCidr(entry);
154
+ if (parsed === null) return null;
155
+ cidrs.push(parsed);
156
+ }
157
+ }
158
+ const hops = has("hops") ? trustedProxy.hops : null;
159
+ const predicate = has("isTrusted") ? trustedProxy.isTrusted : null;
160
+ if (cidrs.length === 0 && hops === null && predicate === null) return null;
161
+ return { header, cidrs, hops, predicate };
162
+ }
163
+ function isTrustedPeer(policy, value) {
164
+ const numeric = toNumeric(value);
165
+ if (numeric === null) return false;
166
+ for (const cidr of policy.cidrs) {
167
+ if (cidr.bits === numeric.bits && numeric.value >> cidr.shift === cidr.network) return true;
168
+ }
169
+ if (policy.predicate !== null) {
170
+ try {
171
+ if (policy.predicate(String(value)) === true) return true;
172
+ } catch {
173
+ return false;
174
+ }
175
+ }
176
+ return false;
177
+ }
178
+ var UNSPECIFIED = /* @__PURE__ */ new Set(["0.0.0.0", "::"]);
179
+ function identifyingIp(value) {
180
+ const canonical = canonicalIp(value);
181
+ return canonical === null || UNSPECIFIED.has(canonical) ? null : canonical;
182
+ }
183
+ function chainFrom(headers, header) {
184
+ if (headers === null || typeof headers !== "object" || !Object.hasOwn(headers, header)) return [];
185
+ const raw = headers[header];
186
+ const value = Array.isArray(raw) ? raw.join(",") : raw;
187
+ if (typeof value !== "string" || value.trim() === "") return [];
188
+ return value.split(",").map((entry) => entry.trim()).filter((entry) => entry !== "");
189
+ }
190
+ function resolveClientIp(input) {
191
+ const headers = input.headers ?? {};
192
+ const peer = identifyingIp(input.peer);
193
+ const policy = readTrustPolicy(input.trustedProxy);
194
+ if (peer === null) return { ip: null, source: "unavailable" };
195
+ if (policy === null) return { ip: peer, source: "runtime" };
196
+ const gatedByAddress = policy.cidrs.length > 0 || policy.predicate !== null;
197
+ const peerTrusted = gatedByAddress ? isTrustedPeer(policy, peer) : policy.hops !== null;
198
+ if (!peerTrusted) return { ip: peer, source: "runtime" };
199
+ const chain = chainFrom(headers, policy.header);
200
+ if (chain.length === 0) return { ip: peer, source: "runtime" };
201
+ if (policy.hops !== null) {
202
+ const index = chain.length - policy.hops;
203
+ const candidate = index >= 0 ? chain[index] : void 0;
204
+ const canonical = identifyingIp(candidate);
205
+ return canonical === null ? { ip: peer, source: "runtime" } : { ip: canonical, source: "trusted-proxy" };
206
+ }
207
+ for (let i = chain.length - 1; i >= 0; i--) {
208
+ const canonical = identifyingIp(chain[i]);
209
+ if (canonical === null) return { ip: peer, source: "runtime" };
210
+ if (!isTrustedPeer(policy, canonical)) return { ip: canonical, source: "trusted-proxy" };
211
+ }
212
+ return { ip: peer, source: "runtime" };
213
+ }
214
+ function clientIpFields(resolved) {
215
+ const established = resolved?.source === "runtime" || resolved?.source === "trusted-proxy";
216
+ const address = identifyingIp(resolved?.ip);
217
+ return established && address !== null ? { client_ip: address, client_ip_source: resolved.source } : { client_ip_source: "unavailable" };
218
+ }
4
219
 
5
220
  // src/protect/safe-origin.js
6
221
  function isSafeOrigin(value) {
@@ -358,7 +573,7 @@ var RequestResolver = class {
358
573
  return req.headers?.host ? [req.headers.host] : [];
359
574
  case "REMOTE_ADDR":
360
575
  case "ip":
361
- return [req.ip ?? req.socket?.remoteAddress ?? ""];
576
+ return [typeof req.ip === "string" ? req.ip : ""];
362
577
  case "CONTENT_TYPE":
363
578
  return req.headers?.["content-type"] ? [req.headers["content-type"]] : [];
364
579
  case "CONTENT_LENGTH":
@@ -699,14 +914,29 @@ function serializeForRawDetection(body, visited = /* @__PURE__ */ new Set(), isR
699
914
  });
700
915
  return "{" + parts.join(",") + "}";
701
916
  }
917
+ var INHERITED_ACCESSORS = /* @__PURE__ */ new Set(["headers", "query"]);
918
+ function requestField(req, key) {
919
+ if (req === null || typeof req !== "object") return void 0;
920
+ if (Object.hasOwn(req, key)) return req[key];
921
+ if (!INHERITED_ACCESSORS.has(key)) return void 0;
922
+ let holder = Object.getPrototypeOf(req);
923
+ while (holder !== null && !Object.hasOwn(holder, key)) holder = Object.getPrototypeOf(holder);
924
+ if (holder === null || holder === Object.prototype) return void 0;
925
+ const descriptor = Object.getOwnPropertyDescriptor(holder, key);
926
+ if (typeof descriptor?.get !== "function") return void 0;
927
+ return req[key];
928
+ }
702
929
  function normalizeRequest(req, options = {}) {
703
- const rawBody = typeof req._rawBody === "string" ? req._rawBody : serializeForRawDetection(req.body ?? null);
930
+ const ownRaw = Object.hasOwn(req ?? {}, "_rawBody") && typeof req._rawBody === "string";
931
+ const body = requestField(req, "body");
932
+ const url = requestField(req, "url");
933
+ const rawBody = ownRaw ? req._rawBody : serializeForRawDetection(body ?? null);
704
934
  return {
705
- query: normalizeObject(req.query || {}, options),
706
- body: normalizeObject(req.body || {}, options),
707
- headers: normalizeObject(req.headers || {}, options),
708
- url: normalize(req.url || "", options),
709
- originalUrl: normalize(req.originalUrl || req.url || "", options),
935
+ query: normalizeObject(requestField(req, "query") || {}, options),
936
+ body: normalizeObject(body || {}, options),
937
+ headers: normalizeObject(requestField(req, "headers") || {}, options),
938
+ url: normalize(url || "", options),
939
+ originalUrl: normalize(requestField(req, "originalUrl") || url || "", options),
710
940
  _rawBody: rawBody
711
941
  };
712
942
  }
@@ -961,6 +1191,30 @@ function expandIPv6(host) {
961
1191
  }
962
1192
  return out;
963
1193
  }
1194
+ 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;
1195
+ function base64UrlToBase64(segment) {
1196
+ const padded = segment.length % 4 === 0 ? segment : segment + "=".repeat(4 - segment.length % 4);
1197
+ return padded.replace(/-/g, "+").replace(/_/g, "/");
1198
+ }
1199
+ function jwtClaimSpans(text, claim, value) {
1200
+ const found = /* @__PURE__ */ new Set();
1201
+ if (typeof text !== "string" || typeof claim !== "string" || claim === "") return [];
1202
+ const wanted = String(value);
1203
+ for (const [token] of text.matchAll(JWT_CANDIDATE)) {
1204
+ const parts = token.split(".");
1205
+ let payload;
1206
+ try {
1207
+ payload = JSON.parse(base64DecodeUtf8(base64UrlToBase64(parts[1])));
1208
+ } catch {
1209
+ continue;
1210
+ }
1211
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) continue;
1212
+ if (!Object.prototype.hasOwnProperty.call(payload, claim)) continue;
1213
+ const actual = payload[claim];
1214
+ if (typeof actual === "string" && actual === wanted) found.add(token);
1215
+ }
1216
+ return [...found];
1217
+ }
964
1218
  var warnedScopes = /* @__PURE__ */ new Set();
965
1219
  function warnUnrecognisedScope(when) {
966
1220
  const key = Object.keys(when).sort().join(",");
@@ -989,16 +1243,30 @@ function ruleAppliesTo(when, resolver) {
989
1243
  return true;
990
1244
  }
991
1245
  }
1246
+ function foldPathForScope(value) {
1247
+ const out = String(value).toLowerCase();
1248
+ return out.endsWith("/") ? out.slice(0, -1) : out;
1249
+ }
1250
+ var REGEX_SYNTAX = /[\\^$*+?()[\]{}|]/;
1251
+ var REGEX_FORM = /^\/(.+?)\/([gimsuy]*)$/s;
1252
+ function isRegexForm(pattern) {
1253
+ const parts = REGEX_FORM.exec(pattern);
1254
+ return parts !== null && REGEX_SYNTAX.test(parts[1]);
1255
+ }
992
1256
  function pathMatches(pattern, path) {
993
- if (pattern.length > 2 && pattern.startsWith("/") && pattern.endsWith("/")) {
1257
+ if (isRegexForm(pattern)) {
994
1258
  const re = safeRegExp(pattern);
995
1259
  return re ? re.test(path) : false;
996
1260
  }
997
- if (pattern.includes("*")) {
998
- const re = safeRegExp("/^" + pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^?]*") + "$/");
999
- return re ? re.test(path) : false;
1261
+ const target = foldPathForScope(path);
1262
+ const folded = foldPathForScope(pattern);
1263
+ if (folded.includes("*")) {
1264
+ const trailing = folded.endsWith("/*");
1265
+ const body = (trailing ? folded.slice(0, -2) : folded).replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^?]*");
1266
+ const re = safeRegExp("/^" + body + (trailing ? "(?:/[^?]*)?" : "") + "$/");
1267
+ return re ? re.test(target) : false;
1000
1268
  }
1001
- return path === pattern;
1269
+ return target === folded;
1002
1270
  }
1003
1271
  function normalizeDefaultPort(host) {
1004
1272
  return String(host).toLowerCase().replace(/:(?:80|443)$/, "");
@@ -1151,6 +1419,8 @@ function matchValue(type, value, matchVal, matchObj) {
1151
1419
  const stripped = strValue.replace(/[ _\-,]/g, "");
1152
1420
  return ctypeResult(strValue, /^[\w$€-￿]*$/.test(stripped), matchVal);
1153
1421
  }
1422
+ case "jwt_claim_equals":
1423
+ return jwtClaimSpans(strValue, matchObj?.claim, matchVal).length > 0;
1154
1424
  case "array_key_value":
1155
1425
  return arrayKeyValue(value, matchObj);
1156
1426
  // file_contains (uploaded-file content scanning) is NOT WordPress-specific, but is
@@ -1228,7 +1498,12 @@ var RuleEngine = class {
1228
1498
  return {
1229
1499
  blocked: true,
1230
1500
  rule,
1231
- message: rule.message ?? `Blocked by Patchstack WAF rule: ${rule.title ?? rule.id}`
1501
+ message: rule.message ?? `Blocked by Patchstack WAF rule: ${rule.title ?? rule.id}`,
1502
+ // The resolver this match was decided by, for whatever needs to report on it. A consumer
1503
+ // that re-read the request instead would be reading it a second time: a getter, a stream or
1504
+ // anything else that answers once can give a different value, and evidence that disagrees
1505
+ // with the match it belongs to is worse than none.
1506
+ resolver
1232
1507
  };
1233
1508
  }
1234
1509
  } catch (err) {
@@ -1323,1324 +1598,1624 @@ var RuleEngine = class {
1323
1598
  }
1324
1599
  };
1325
1600
 
1326
- // src/protect/engine/pulse-client.js
1327
- var DEFAULT_BASE_URL2 = "https://api.patchstack.com/monitor/pulse";
1328
- var DEFAULT_CACHE_TTL2 = 3e5;
1329
- var JITTER_FRACTION2 = 0.1;
1330
- var PulseRuleClient = class {
1331
- #siteUuid;
1332
- #timeoutMs;
1333
- #baseUrl;
1334
- #cacheTtl;
1335
- #cache = null;
1336
- #cacheTime = null;
1337
- #ttlEffective = 0;
1338
- #etag;
1339
- #pulseAuth;
1340
- #reportsDetections;
1341
- constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, reportsDetections } = {}) {
1342
- this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
1343
- this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
1344
- this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL, DEFAULT_BASE_URL2, "rule endpoint");
1345
- this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL2;
1346
- this.#etag = etag ?? null;
1347
- this.#pulseAuth = pulseAuth ?? null;
1348
- this.#reportsDetections = reportsDetections === true;
1349
- if (!this.#siteUuid) {
1350
- throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
1601
+ // src/protect/rules/contract.js
1602
+ var SOURCES = Object.freeze({
1603
+ raw: Object.freeze({ keyed: false }),
1604
+ all: Object.freeze({ keyed: false }),
1605
+ false: Object.freeze({ keyed: false }),
1606
+ rules: Object.freeze({ keyed: false, group: true }),
1607
+ get: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
1608
+ post: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
1609
+ request: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
1610
+ cookie: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
1611
+ // A bare `files.<name>` resolves to the filename; an optional trailing attribute selects another part
1612
+ // of the upload. Optional, not required — filename-scoped rules use the bare form.
1613
+ files: Object.freeze({
1614
+ keyed: true,
1615
+ keys: "any",
1616
+ wildcard: true,
1617
+ optional_key_suffixes: Object.freeze(["content", "filename", "type"])
1618
+ }),
1619
+ server: Object.freeze({
1620
+ keyed: true,
1621
+ keys: Object.freeze([
1622
+ "REQUEST_URI",
1623
+ "REQUEST_METHOD",
1624
+ "HTTP_USER_AGENT",
1625
+ "HTTP_REFERER",
1626
+ "HTTP_HOST",
1627
+ "REMOTE_ADDR",
1628
+ "ip",
1629
+ "CONTENT_TYPE",
1630
+ "CONTENT_LENGTH"
1631
+ ]),
1632
+ key_prefixes: Object.freeze(["HTTP_"])
1633
+ }),
1634
+ response: Object.freeze({
1635
+ keyed: true,
1636
+ keys: Object.freeze(["status", "body", "headers"]),
1637
+ key_prefixes: Object.freeze(["header."])
1638
+ }),
1639
+ egress: Object.freeze({ keyed: true, keys: Object.freeze(["url", "host", "method"]) })
1640
+ });
1641
+ var MATCH_TYPES = Object.freeze([
1642
+ "array_in_array",
1643
+ "array_key_value",
1644
+ "contains",
1645
+ "cors_reflected",
1646
+ "cross_origin",
1647
+ "ctype_alnum",
1648
+ "ctype_digit",
1649
+ "ctype_special",
1650
+ "equals",
1651
+ "equals_strict",
1652
+ "hostname",
1653
+ "in_array",
1654
+ "inline_js_xss",
1655
+ "inline_xss",
1656
+ "internal_host",
1657
+ "is_numeric",
1658
+ "isset",
1659
+ "jwt_claim_equals",
1660
+ "less_than",
1661
+ "more_than",
1662
+ "not_contains",
1663
+ "not_in_array",
1664
+ "off_origin",
1665
+ "quotes",
1666
+ "regex",
1667
+ "stripos"
1668
+ ]);
1669
+ var PARAMETERLESS_MATCH_TYPES = Object.freeze(["cross_origin", "off_origin", "cors_reflected"]);
1670
+ var OPERANDLESS_MATCH_TYPES = Object.freeze([
1671
+ "isset",
1672
+ "quotes",
1673
+ "is_numeric",
1674
+ "ctype_alnum",
1675
+ "ctype_digit",
1676
+ "ctype_special",
1677
+ "inline_xss",
1678
+ "inline_js_xss",
1679
+ "internal_host",
1680
+ "cross_origin",
1681
+ "off_origin",
1682
+ "cors_reflected"
1683
+ ]);
1684
+ var OPERAND_SHAPES = Object.freeze([
1685
+ "positive-number",
1686
+ "boolean",
1687
+ "non-empty-string",
1688
+ "regex-literal",
1689
+ "string-or-string-list",
1690
+ "scalar",
1691
+ "string",
1692
+ "numeric",
1693
+ "scalar-or-non-empty-list",
1694
+ "non-empty-list",
1695
+ "object",
1696
+ "non-empty-object",
1697
+ "non-empty-string-list",
1698
+ "header-name-map",
1699
+ "header-name-list",
1700
+ "cookie-flags"
1701
+ ]);
1702
+ var COOKIE_FLAGS = Object.freeze(["httpOnly", "secure", "sameSite"]);
1703
+ var SAME_SITE_VALUES = Object.freeze(["Strict", "Lax", "None"]);
1704
+ var HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
1705
+ var REGEX_LITERAL = /^\/(.+?)\/([gimsuy]*)$/s;
1706
+ var MATCH_OPERANDS = Object.freeze({
1707
+ array_key_value: Object.freeze({
1708
+ required: Object.freeze(["key", "match"]),
1709
+ shapes: Object.freeze({ key: "string-or-string-list", match: "object" })
1710
+ }),
1711
+ // Decides on a JWT's DECODED payload rather than its shape, and yields the matching token spans so
1712
+ // `redact` masks only those tokens. `claim` names an own, top-level claim; `value` is compared with
1713
+ // exact string equality. Anything not positively identified — undecodable, not JSON, not an object,
1714
+ // oversized, claim missing, claim not a string, or a different value — is a no-match.
1715
+ jwt_claim_equals: Object.freeze({
1716
+ required: Object.freeze(["claim", "value"]),
1717
+ shapes: Object.freeze({ claim: "non-empty-string", value: "non-empty-string" })
1718
+ }),
1719
+ regex: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "regex-literal" }) }),
1720
+ contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
1721
+ stripos: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
1722
+ not_contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
1723
+ // A hostname is compared to `new URL(value).hostname`, so an empty one only ever equals the empty
1724
+ // hostname of an opaque-scheme URL (`file:`, `mailto:`, `data:`) — never a host, and a false positive
1725
+ // on those. The other three are the list-membership family: an empty list makes `in_array` and
1726
+ // `array_in_array` inert and `not_in_array` true for every value.
1727
+ hostname: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
1728
+ in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
1729
+ not_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
1730
+ array_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-list" }) }),
1731
+ // The comparison family. `equals` coerces, so any scalar can match. `equals_strict` does not, and its
1732
+ // subject is always a string by the time it is compared — so a number operand is unconditionally
1733
+ // inert, which is the trap the engine's own note about `'1' !== 1` describes.
1734
+ equals: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar" }) }),
1735
+ equals_strict: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "string" }) }),
1736
+ less_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) }),
1737
+ more_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) })
1738
+ });
1739
+ function operandShapeProblem(shape, value) {
1740
+ const isFilledString = (v) => typeof v === "string" && v.trim() !== "";
1741
+ const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
1742
+ const isHeaderName = (v) => typeof v === "string" && HEADER_NAME.test(v);
1743
+ switch (shape) {
1744
+ case "positive-number":
1745
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? null : "must be a finite positive number";
1746
+ case "boolean":
1747
+ return typeof value === "boolean" ? null : "must be true or false";
1748
+ case "non-empty-string":
1749
+ return isFilledString(value) ? null : "must be a non-empty string";
1750
+ case "regex-literal":
1751
+ if (!isFilledString(value)) return "must be a non-empty string";
1752
+ return REGEX_LITERAL.test(value) ? null : "must be a delimited pattern, as in /pattern/i";
1753
+ case "string-or-string-list":
1754
+ if (isFilledString(value)) return null;
1755
+ if (Array.isArray(value) && value.length > 0 && value.every(isFilledString)) return null;
1756
+ return "must be a non-empty string, or a non-empty list of them";
1757
+ case "scalar":
1758
+ return isScalar(value) ? null : "must be a string, number or boolean";
1759
+ case "string":
1760
+ return typeof value === "string" ? null : "must be a string (this comparison is type-strict)";
1761
+ case "numeric":
1762
+ return isScalar(value) && Number.isFinite(Number(value)) ? null : "must be a number";
1763
+ case "scalar-or-non-empty-list":
1764
+ if (isScalar(value)) return null;
1765
+ if (Array.isArray(value) && value.length > 0 && value.every(isScalar)) return null;
1766
+ return "must be a scalar, or a non-empty list of them";
1767
+ case "non-empty-list":
1768
+ return Array.isArray(value) && value.length > 0 && value.every(isScalar) ? null : "must be a non-empty list of scalars";
1769
+ case "header-name-map": {
1770
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
1771
+ const names = Object.keys(value);
1772
+ if (names.length === 0) return "must be an object with at least one entry";
1773
+ const bad = names.find((name) => !isHeaderName(name));
1774
+ return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
1351
1775
  }
1352
- }
1353
- async getRules() {
1354
- const now = Date.now();
1355
- if (this.#cache && this.#cacheTime !== null && now - this.#cacheTime < this.#ttlEffective) {
1356
- return this.#cache;
1776
+ case "header-name-list": {
1777
+ if (!Array.isArray(value) || value.length === 0) return "must be a non-empty list of HTTP header names";
1778
+ const bad = value.find((name) => !isHeaderName(name));
1779
+ return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
1357
1780
  }
1358
- const url = `${this.#baseUrl}/rules/${encodeURIComponent(this.#siteUuid)}`;
1359
- try {
1360
- const auth = await pulseAuthHeader(
1361
- { pulseAuth: this.#pulseAuth, endpoint: this.#baseUrl, timeoutMs: this.#timeoutMs },
1362
- fetch
1363
- );
1364
- const headers = { Accept: "application/json", ...auth };
1365
- if (this.#reportsDetections && typeof auth.Authorization === "string") {
1366
- headers["X-Patchstack-Detections"] = "enabled";
1781
+ case "cookie-flags": {
1782
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
1783
+ const unknown = Object.keys(value).filter((key) => !COOKIE_FLAGS.includes(key));
1784
+ if (unknown.length > 0) return `has no flag named ${unknown.map((k) => JSON.stringify(k)).join(", ")}`;
1785
+ for (const flag of ["httpOnly", "secure"]) {
1786
+ if (value[flag] !== void 0 && typeof value[flag] !== "boolean") {
1787
+ return `flag "${flag}" must be true or false`;
1788
+ }
1367
1789
  }
1368
- if (this.#etag) headers["If-None-Match"] = this.#etag;
1369
- const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
1370
- if (response.status === 304) {
1371
- this.#touch(now);
1372
- return this.#cache ?? { success: true, notModified: true, etag: this.#etag, firewall: [], whitelists: [], whitelist_keys: {} };
1790
+ const sameSite = value.sameSite;
1791
+ const sameSiteOff = sameSite === false;
1792
+ if (sameSite !== void 0 && !sameSiteOff) {
1793
+ if (typeof sameSite !== "string" || !SAME_SITE_VALUES.some((v) => v.toLowerCase() === sameSite.toLowerCase())) {
1794
+ return `flag "sameSite" must be false, or one of ${SAME_SITE_VALUES.join(", ")}`;
1795
+ }
1373
1796
  }
1374
- if (!response.ok) {
1375
- return { success: false, error: `API returned ${response.status}`, firewall: [], whitelists: [], whitelist_keys: {} };
1376
- }
1377
- const data = await response.json();
1378
- if (!data || !Array.isArray(data.firewall)) {
1379
- return { success: false, error: "unexpected response shape (no firewall array)", firewall: [], whitelists: [], whitelist_keys: {} };
1797
+ if (value.httpOnly === false && value.secure === false && sameSiteOff) {
1798
+ return "turns off every flag, so it would harden nothing";
1380
1799
  }
1381
- const result = {
1382
- success: true,
1383
- notModified: false,
1384
- etag: response.headers?.get?.("etag") ?? null,
1385
- firewall: data.firewall,
1386
- whitelists: Array.isArray(data.whitelists) ? data.whitelists : [],
1387
- whitelist_keys: data.whitelist_keys ?? {},
1388
- ...enforcementField(data)
1389
- };
1390
- this.#cache = result;
1391
- this.#etag = result.etag;
1392
- this.#touch(now);
1393
- return result;
1394
- } catch (err) {
1395
- return {
1396
- success: false,
1397
- error: err.name === "TimeoutError" ? "Request timed out" : err.message,
1398
- firewall: [],
1399
- whitelists: [],
1400
- whitelist_keys: {}
1401
- };
1800
+ return null;
1402
1801
  }
1802
+ case "object":
1803
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? null : "must be an object";
1804
+ case "non-empty-object":
1805
+ return value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0 ? null : "must be an object with at least one entry";
1806
+ case "non-empty-string-list":
1807
+ return Array.isArray(value) && value.length > 0 && value.every(isFilledString) ? null : "must be a non-empty list of non-empty strings";
1808
+ default:
1809
+ return null;
1403
1810
  }
1404
- #touch(now) {
1405
- this.#cacheTime = now;
1406
- this.#ttlEffective = this.#cacheTtl - Math.floor(Math.random() * this.#cacheTtl * JITTER_FRACTION2);
1407
- }
1408
- clearCache() {
1409
- this.#cache = null;
1410
- this.#cacheTime = null;
1411
- this.#etag = null;
1811
+ }
1812
+ var MUTATIONS = Object.freeze([
1813
+ "base64_decode",
1814
+ "getArrayValues",
1815
+ "htmlentitydecode",
1816
+ "intval",
1817
+ "json_decode",
1818
+ "json_encode",
1819
+ "urldecode"
1820
+ ]);
1821
+ var PHASES = Object.freeze(["request", "response", "egress"]);
1822
+ var ACTIONS = Object.freeze([
1823
+ "block",
1824
+ "redact",
1825
+ "encode",
1826
+ "set-header",
1827
+ "remove-header",
1828
+ "harden-cookie"
1829
+ ]);
1830
+ var RULE_PROPERTY_SHAPES = Object.freeze({
1831
+ max_bytes: "positive-number",
1832
+ bypass_limit: "boolean",
1833
+ ensure: "boolean",
1834
+ prefilter: "non-empty-string-list"
1835
+ });
1836
+ var ENFORCEMENT_VALUES = Object.freeze(["dry-run"]);
1837
+ var WHEN_KEYS = Object.freeze(["path", "method"]);
1838
+ var RULE_PROPERTIES = Object.freeze([
1839
+ "id",
1840
+ "rule_id",
1841
+ "title",
1842
+ "category",
1843
+ "phase",
1844
+ "action",
1845
+ "rule_v2",
1846
+ "when",
1847
+ "message",
1848
+ "enforcement",
1849
+ "source_revision",
1850
+ "prefilter",
1851
+ "max_bytes",
1852
+ "bypass_limit",
1853
+ "set_headers",
1854
+ "remove_headers",
1855
+ "cookie_flags",
1856
+ "ensure",
1857
+ "capture"
1858
+ ]);
1859
+ var CAPTURE_VERSION = 1;
1860
+ var CAPTURE_KEYS = Object.freeze(["version", "raw_chars"]);
1861
+ var CAPTURE_RAW_CHARS_MAX = 512;
1862
+ var NULL_EXEMPT_PROPERTIES = Object.freeze(["capture"]);
1863
+ function captureProblem(capture) {
1864
+ if (capture === void 0 || capture === null) return null;
1865
+ if (typeof capture !== "object" || Array.isArray(capture)) return "capture must be an object";
1866
+ for (const key of Object.keys(capture)) {
1867
+ if (!CAPTURE_KEYS.includes(key)) return `capture has no "${key}" \u2014 it takes ${CAPTURE_KEYS.join(", ")}`;
1868
+ }
1869
+ if (!Object.hasOwn(capture, "version")) return "capture must name the version it was written against";
1870
+ if (capture.version !== CAPTURE_VERSION) {
1871
+ return `capture version ${String(capture.version)} is not one this guard understands`;
1872
+ }
1873
+ if (!Object.hasOwn(capture, "raw_chars")) return "capture must say what it is opting into";
1874
+ const chars = capture.raw_chars;
1875
+ if (typeof chars !== "number" || !Number.isInteger(chars) || chars <= 0) {
1876
+ return "capture.raw_chars must be a positive whole number of characters";
1412
1877
  }
1413
- };
1414
- function enforcementField(data) {
1415
- if (!data || typeof data !== "object") return {};
1416
- const v = data.enforcement ?? data.mode;
1417
- return v === "block" || v === "dry-run" ? { enforcement: v } : {};
1878
+ return null;
1418
1879
  }
1419
-
1420
- // src/protect/engine/fetch.js
1421
- var MAX_BODY_BYTES = 1024 * 1024;
1422
- async function fromFetchRequest(request, options = {}) {
1423
- const maxBodyBytes = options.maxBodyBytes ?? MAX_BODY_BYTES;
1424
- const url = new URL(request.url);
1425
- const method = (request.method || "GET").toUpperCase();
1426
- const headers = {};
1427
- request.headers.forEach((value, key) => {
1428
- headers[key.toLowerCase()] = value;
1429
- });
1430
- const query = {};
1431
- for (const [key, value] of url.searchParams) {
1432
- if (key in query) {
1433
- query[key] = Array.isArray(query[key]) ? [...query[key], value] : [query[key], value];
1434
- } else {
1435
- query[key] = value;
1436
- }
1880
+ var ACTION_PROPERTIES = Object.freeze({
1881
+ block: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["request", "response", "egress"]) }),
1882
+ redact: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
1883
+ encode: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
1884
+ "set-header": Object.freeze({
1885
+ required: Object.freeze(["set_headers"]),
1886
+ defaulted: Object.freeze(["ensure"]),
1887
+ phases: Object.freeze(["response"]),
1888
+ shapes: Object.freeze({ set_headers: "header-name-map" })
1889
+ }),
1890
+ "remove-header": Object.freeze({
1891
+ required: Object.freeze(["remove_headers"]),
1892
+ defaulted: Object.freeze([]),
1893
+ phases: Object.freeze(["response"]),
1894
+ shapes: Object.freeze({ remove_headers: "header-name-list" })
1895
+ }),
1896
+ "harden-cookie": Object.freeze({
1897
+ required: Object.freeze([]),
1898
+ defaulted: Object.freeze(["cookie_flags"]),
1899
+ phases: Object.freeze(["response"]),
1900
+ shapes: Object.freeze({ cookie_flags: "cookie-flags" })
1901
+ })
1902
+ });
1903
+ var LIMITS = Object.freeze({
1904
+ maxRules: 5e3,
1905
+ maxWhitelists: 2e3,
1906
+ maxConditionsPerRule: 250,
1907
+ maxNestingDepth: 12,
1908
+ maxRegexLength: 1e3,
1909
+ maxValueLength: 8192
1910
+ });
1911
+ var GROUP_PARAMETER = "rules";
1912
+ function isGroup(condition) {
1913
+ return condition?.parameter === GROUP_PARAMETER && Array.isArray(condition?.rules);
1914
+ }
1915
+ function conditionShapeProblem(condition) {
1916
+ if (!condition || typeof condition !== "object") return "condition is not an object";
1917
+ const named = condition.parameter === GROUP_PARAMETER;
1918
+ const nested = Array.isArray(condition.rules);
1919
+ if (named && !nested) {
1920
+ return `parameter "${GROUP_PARAMETER}" is a group and needs a "rules" array`;
1437
1921
  }
1438
- let rawBody = "";
1439
- if (method !== "GET" && method !== "HEAD") {
1440
- rawBody = await readCappedText(request, maxBodyBytes);
1922
+ if (nested && !named) {
1923
+ return `condition carries nested rules but its parameter is ${JSON.stringify(condition.parameter)}; a group must be {"parameter":"${GROUP_PARAMETER}"}`;
1441
1924
  }
1442
- const contentType = headers["content-type"] || "";
1443
- let body = {};
1444
- let files;
1445
- if (rawBody) {
1446
- const parsed = parseBody(rawBody, contentType);
1447
- body = parsed.body;
1448
- files = parsed.files;
1925
+ if (named && nested) {
1926
+ if (condition.match !== void 0) return "a group carries no match of its own; the engine ignores it";
1927
+ if (condition.mutations !== void 0) return "a group carries no mutations of its own; the engine ignores them";
1928
+ if (condition.rules.length === 0) return "group has no conditions (would never match)";
1449
1929
  }
1450
- const uri = url.pathname + url.search;
1451
- const forwarded = headers["cf-connecting-ip"] || headers["x-forwarded-for"] || headers["x-real-ip"] || "";
1452
- return {
1453
- method,
1454
- url: uri,
1455
- originalUrl: uri,
1456
- query,
1457
- body,
1458
- files,
1459
- headers,
1460
- ip: forwarded.split(",")[0].trim(),
1461
- cookies: parseCookies(headers.cookie),
1462
- // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify
1463
- // drops, so prototype-pollution rules on `raw` are robust.
1464
- _rawBody: rawBody
1465
- };
1930
+ return null;
1466
1931
  }
1467
- async function readCappedText(request, max) {
1468
- let clone;
1469
- try {
1470
- clone = request.clone();
1471
- } catch {
1472
- return "";
1473
- }
1474
- const body = clone.body;
1475
- if (body && typeof body.getReader === "function") {
1476
- const reader = body.getReader();
1477
- const chunks = [];
1478
- let buffered = 0;
1479
- try {
1480
- for (; ; ) {
1481
- const { done, value } = await reader.read();
1482
- if (done) break;
1483
- if (!value || buffered >= max) continue;
1484
- const take = Math.min(value.byteLength, max - buffered);
1485
- chunks.push(take === value.byteLength ? value : value.subarray(0, take));
1486
- buffered += take;
1487
- }
1488
- } catch {
1489
- return "";
1490
- }
1491
- try {
1492
- return new TextDecoder().decode(concatChunks(chunks, buffered));
1493
- } catch {
1494
- return "";
1932
+ function parameterProblem(parameter) {
1933
+ if (parameter === void 0 || parameter === null) return null;
1934
+ if (Array.isArray(parameter)) {
1935
+ if (parameter.length === 0) return "parameter list is empty";
1936
+ for (const member of parameter) {
1937
+ if (Array.isArray(member)) return "a parameter list holds parameters, not more lists";
1938
+ const problem = parameterProblem(member);
1939
+ if (problem !== null) return problem;
1495
1940
  }
1941
+ return null;
1496
1942
  }
1497
- try {
1498
- const text = await clone.text();
1499
- return text.length > max ? text.slice(0, max) : text;
1500
- } catch {
1501
- return "";
1943
+ if (typeof parameter !== "string" || parameter === "") return "parameter must be a non-empty string";
1944
+ if (parameter === GROUP_PARAMETER) {
1945
+ return `"${GROUP_PARAMETER}" is the group source and is only valid as a whole group, not as a parameter`;
1502
1946
  }
1503
- }
1504
- function concatChunks(chunks, total) {
1505
- const out = new Uint8Array(total);
1506
- let offset = 0;
1507
- for (const c of chunks) {
1508
- out.set(c, offset);
1509
- offset += c.byteLength;
1947
+ const dot = parameter.indexOf(".");
1948
+ const name = dot === -1 ? parameter : parameter.slice(0, dot);
1949
+ const source = SOURCES[name];
1950
+ if (source === void 0) return `unknown parameter source "${name}"`;
1951
+ if (dot === -1) {
1952
+ return source.keyed === true ? `source "${name}" needs a key, e.g. "${name}.fieldname"` : null;
1510
1953
  }
1511
- return out;
1954
+ if (source.keyed !== true) return `source "${name}" takes no key`;
1955
+ const key = parameter.slice(dot + 1);
1956
+ if (key === "") return `source "${name}" needs a key after the dot`;
1957
+ return keyProblem(name, source, key);
1512
1958
  }
1513
- function parseBody(rawBody, contentType) {
1514
- const ct = String(contentType || "").toLowerCase();
1515
- const isJson = ct.includes("application/json") || /\+json\b/.test(ct);
1516
- const isForm = ct.includes("application/x-www-form-urlencoded");
1517
- const isMultipart = ct.includes("multipart/form-data");
1518
- const isAmbiguous = ct === "" || ct.startsWith("text/plain") || ct.includes("csp-report") || ct.includes("/json");
1519
- if (isMultipart) {
1520
- const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
1521
- if (boundary) return parseMultipart(rawBody, boundary);
1522
- return { body: {}, files: void 0 };
1959
+ function keyProblem(name, source, key) {
1960
+ if (key.endsWith("*") && source.wildcard !== true) {
1961
+ return `"${name}" does not fan out over a wildcard key`;
1523
1962
  }
1524
- if (isForm) {
1525
- const body = {};
1526
- for (const [k, v] of new URLSearchParams(rawBody)) body[k] = k in body ? [].concat(body[k], v) : v;
1527
- return { body, files: void 0 };
1963
+ const bare = key.endsWith("*") ? key.slice(0, -1) : key;
1964
+ if (source.keys === "any") return null;
1965
+ if (Array.isArray(source.keys) && source.keys.includes(bare)) return null;
1966
+ for (const prefix of source.key_prefixes ?? []) {
1967
+ if (bare.startsWith(prefix) && bare.length > prefix.length) return null;
1528
1968
  }
1529
- if (isJson || isAmbiguous) {
1530
- try {
1531
- const parsed = JSON.parse(rawBody);
1532
- if (parsed && typeof parsed === "object") return { body: parsed, files: void 0 };
1533
- } catch {
1969
+ const accepted = [
1970
+ ...Array.isArray(source.keys) ? source.keys : [],
1971
+ ...(source.key_prefixes ?? []).map((p) => `${p}\u2026`)
1972
+ ].join(", ");
1973
+ return `"${name}" has no key "${key}" (expected one of: ${accepted})`;
1974
+ }
1975
+ function matchProblem(match, parameter) {
1976
+ if (!match || typeof match !== "object") return "condition has no match object";
1977
+ if (typeof match.type !== "string" || match.type === "") return "match.type must be a non-empty string";
1978
+ if (!MATCH_TYPES.includes(match.type)) return `unknown match type "${match.type}"`;
1979
+ const parameterless = PARAMETERLESS_MATCH_TYPES.includes(match.type);
1980
+ if (!parameterless && (parameter === void 0 || parameter === null)) {
1981
+ return `match type "${match.type}" needs a parameter`;
1982
+ }
1983
+ if (parameterless && parameter !== void 0 && parameter !== null) {
1984
+ return `match type "${match.type}" reads the whole request and takes no parameter`;
1985
+ }
1986
+ const operands = MATCH_OPERANDS[match.type];
1987
+ if (operands) {
1988
+ for (const required of operands.required) {
1989
+ if (match[required] === void 0 || match[required] === null) {
1990
+ return `match type "${match.type}" needs "${required}"`;
1991
+ }
1992
+ }
1993
+ for (const [operand, shape] of Object.entries(operands.shapes ?? {})) {
1994
+ if (match[operand] === void 0) continue;
1995
+ const problem = operandShapeProblem(shape, match[operand]);
1996
+ if (problem) return `match type "${match.type}" operand "${operand}" ${problem}`;
1534
1997
  }
1998
+ } else if (!OPERANDLESS_MATCH_TYPES.includes(match.type) && match.value === void 0) {
1999
+ return `match type "${match.type}" needs a value`;
1535
2000
  }
1536
- return { body: {}, files: void 0 };
2001
+ return null;
1537
2002
  }
1538
- function parseMultipart(rawBody, boundary) {
1539
- const body = {};
1540
- const files = {};
1541
- for (const part of rawBody.split("--" + boundary)) {
1542
- const sep = /\r?\n\r?\n/.exec(part);
1543
- if (!sep) continue;
1544
- const rawHeaders = part.slice(0, sep.index);
1545
- const disposition = /content-disposition:[^\r\n]*/i.exec(rawHeaders)?.[0];
1546
- if (!disposition) continue;
1547
- const name = /name="([^"]*)"/i.exec(disposition)?.[1];
1548
- if (name == null) continue;
1549
- const content = part.slice(sep.index + sep[0].length).replace(/\r?\n$/, "");
1550
- const filename = /filename="([^"]*)"/i.exec(disposition)?.[1];
1551
- if (filename !== void 0) {
1552
- const partType = /content-type:\s*([^\r\n;]+)/i.exec(rawHeaders)?.[1]?.trim() || "";
1553
- const file = { filename, type: partType, content };
1554
- files[name] = name in files ? [].concat(files[name], file) : file;
1555
- } else {
1556
- body[name] = name in body ? [].concat(body[name], content) : content;
2003
+ function mutationsProblem(mutations) {
2004
+ if (mutations === void 0 || mutations === null) return null;
2005
+ if (!Array.isArray(mutations)) return "mutations must be an array";
2006
+ for (const mutation of mutations) {
2007
+ if (typeof mutation !== "string" || !MUTATIONS.includes(mutation)) {
2008
+ return `unknown mutation "${String(mutation)}" \u2014 applied as a no-op, so the condition would test the untransformed value`;
1557
2009
  }
1558
2010
  }
1559
- return { body, files };
2011
+ return null;
1560
2012
  }
1561
- function parseCookies(header) {
1562
- const cookies = {};
1563
- if (!header) {
1564
- return cookies;
2013
+ function whenProblem(when) {
2014
+ if (when === void 0 || when === null) return null;
2015
+ if (typeof when !== "object" || Array.isArray(when)) return "when must be an object";
2016
+ const keys = Object.keys(when);
2017
+ if (keys.length === 0) return "when is empty \u2014 remove it, or name a path or method";
2018
+ const unknown = keys.filter((key) => !WHEN_KEYS.includes(key));
2019
+ if (unknown.length > 0) {
2020
+ 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`;
1565
2021
  }
1566
- for (const pair of header.split(";")) {
1567
- const idx = pair.indexOf("=");
1568
- if (idx === -1) {
1569
- continue;
2022
+ if ("path" in when && !isNonEmptyString(when.path)) {
2023
+ return "when.path must be a non-empty string";
2024
+ }
2025
+ if ("method" in when) {
2026
+ const methods = Array.isArray(when.method) ? when.method : [when.method];
2027
+ if (methods.length === 0) return "when.method is an empty list, so the rule would match no method";
2028
+ if (!methods.every(isNonEmptyString)) return "when.method must be a non-empty string, or a list of them";
2029
+ }
2030
+ return null;
2031
+ }
2032
+ function isNonEmptyString(value) {
2033
+ return typeof value === "string" && value.trim() !== "";
2034
+ }
2035
+ function nullPropertyProblem(rule) {
2036
+ if (!rule || typeof rule !== "object") return null;
2037
+ for (const property of RULE_PROPERTIES) {
2038
+ if (NULL_EXEMPT_PROPERTIES.includes(property)) continue;
2039
+ if (property in rule && rule[property] === null) {
2040
+ 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`;
1570
2041
  }
1571
- cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
1572
2042
  }
1573
- return cookies;
2043
+ return null;
1574
2044
  }
1575
-
1576
- // src/protect/engine/node.js
1577
- function fromNodeRequest(req, rawBody = "") {
1578
- const method = (req.method || "GET").toUpperCase();
1579
- const headers = {};
1580
- for (const [key, value] of Object.entries(req.headers || {})) {
1581
- headers[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;
2045
+ function rulePropertyProblem(rule) {
2046
+ if (!rule || typeof rule !== "object") return null;
2047
+ for (const [property, shape] of Object.entries(RULE_PROPERTY_SHAPES)) {
2048
+ if (rule[property] === void 0 || rule[property] === null) continue;
2049
+ const problem = operandShapeProblem(shape, rule[property]);
2050
+ if (problem) return `"${property}" ${problem}`;
1582
2051
  }
1583
- const host = headers.host || "localhost";
1584
- let url;
1585
- try {
1586
- url = new URL(req.url || "/", `http://${host}`);
1587
- } catch {
1588
- try {
1589
- url = new URL(req.url || "/", "http://localhost");
1590
- } catch {
1591
- url = new URL("http://localhost/");
2052
+ return null;
2053
+ }
2054
+ function actionProblem(action, rule) {
2055
+ if (action === void 0 || action === null) return null;
2056
+ if (!ACTIONS.includes(action)) return `unknown action "${action}"`;
2057
+ const spec = ACTION_PROPERTIES[action];
2058
+ for (const required of spec?.required ?? []) {
2059
+ if (rule?.[required] === void 0 || rule?.[required] === null) {
2060
+ return `action "${action}" needs "${required}"`;
1592
2061
  }
1593
2062
  }
1594
- const query = {};
1595
- for (const [key, value] of url.searchParams) {
1596
- if (key in query) {
1597
- query[key] = Array.isArray(query[key]) ? [...query[key], value] : [query[key], value];
1598
- } else {
1599
- query[key] = value;
2063
+ for (const [property, shape] of Object.entries(spec?.shapes ?? {})) {
2064
+ if (rule?.[property] === void 0 || rule?.[property] === null) continue;
2065
+ const problem = operandShapeProblem(shape, rule[property]);
2066
+ if (problem) return `action "${action}" property "${property}" ${problem}`;
2067
+ }
2068
+ const phase = rule?.phase ?? "request";
2069
+ if (spec !== void 0 && !spec.phases.includes(phase)) {
2070
+ return `action "${action}" is carried out on the ${spec.phases.join("/")} phase; on "${phase}" the rule blocks instead`;
2071
+ }
2072
+ return null;
2073
+ }
2074
+
2075
+ // src/protect/rules/validate.js
2076
+ var LIMITS2 = LIMITS;
2077
+ var PHASES2 = new Set(PHASES);
2078
+ var ACTIONS2 = new Set(ACTIONS);
2079
+ function validateBundle(bundle, opts = {}) {
2080
+ const rejected = [];
2081
+ const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
2082
+ const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
2083
+ const firewall = [];
2084
+ for (const rule of inFirewall) {
2085
+ if (firewall.length >= LIMITS2.maxRules) {
2086
+ rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS2.maxRules})` });
2087
+ continue;
1600
2088
  }
2089
+ const reason = enforceableRuleProblem(rule);
2090
+ if (reason) rejected.push({ id: idOf(rule), reason });
2091
+ else firewall.push(rule);
1601
2092
  }
1602
- const contentType = headers["content-type"] || "";
1603
- let body = {};
1604
- let files;
1605
- if (rawBody) {
1606
- const parsed = parseBody(rawBody, contentType);
1607
- body = parsed.body;
1608
- files = parsed.files;
2093
+ const whitelists = [];
2094
+ for (const wl of inWhitelists) {
2095
+ if (whitelists.length >= LIMITS2.maxWhitelists) {
2096
+ rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS2.maxWhitelists})` });
2097
+ continue;
2098
+ }
2099
+ if (!opts.allowGlobalWhitelists && wl && Array.isArray(wl.rule_v2) && !wl.rule_id) {
2100
+ rejected.push({ id: idOf(wl), reason: "whitelist has no rule_id (would suppress every rule); set allowGlobalWhitelists to permit" });
2101
+ continue;
2102
+ }
2103
+ const reason = conditionsProblem(wl?.rule_v2);
2104
+ if (reason) rejected.push({ id: idOf(wl), reason: `whitelist: ${reason}` });
2105
+ else whitelists.push(wl);
1609
2106
  }
1610
- const uri = url.pathname + url.search;
1611
- const forwarded = headers["cf-connecting-ip"] || headers["x-forwarded-for"] || headers["x-real-ip"] || "";
1612
2107
  return {
1613
- method,
1614
- url: uri,
1615
- originalUrl: uri,
1616
- query,
1617
- body,
1618
- files,
1619
- headers,
1620
- ip: forwarded.split(",")[0].trim() || req.socket && req.socket.remoteAddress || "",
1621
- cookies: parseCookies2(headers.cookie),
1622
- // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify drops.
1623
- _rawBody: rawBody
2108
+ bundle: { ...bundle, firewall, whitelists },
2109
+ rejected
1624
2110
  };
1625
2111
  }
1626
- function parseCookies2(header) {
1627
- const cookies = {};
1628
- if (!header) {
1629
- return cookies;
2112
+ function idOf(rule) {
2113
+ const id = rule?.id ?? rule?.rule_id;
2114
+ return id === void 0 || id === null ? "(unidentified)" : String(id);
2115
+ }
2116
+ function enforceableRuleProblem(rule) {
2117
+ if (!rule || typeof rule !== "object") return "not an object";
2118
+ const nullReason = nullPropertyProblem(rule);
2119
+ if (nullReason) return nullReason;
2120
+ if (rule.phase !== void 0 && !PHASES2.has(rule.phase)) return `unknown phase "${rule.phase}"`;
2121
+ const actionReason = actionProblem(rule.action, rule);
2122
+ if (actionReason) return actionReason;
2123
+ const scopeReason = whenProblem(rule.when);
2124
+ if (scopeReason) return scopeReason;
2125
+ const propertyReason = rulePropertyProblem(rule);
2126
+ if (propertyReason) return propertyReason;
2127
+ return conditionsProblem(rule.rule_v2);
2128
+ }
2129
+ function conditionsProblem(conditions, depth = 0) {
2130
+ if (!Array.isArray(conditions)) return "rule_v2 must be an array of conditions";
2131
+ if (conditions.length === 0) return "rule_v2 is empty (would never match)";
2132
+ if (depth > LIMITS2.maxNestingDepth) return `nesting deeper than ${LIMITS2.maxNestingDepth}`;
2133
+ if (conditions.length > LIMITS2.maxConditionsPerRule) {
2134
+ return `more than ${LIMITS2.maxConditionsPerRule} conditions`;
1630
2135
  }
1631
- for (const pair of header.split(";")) {
1632
- const idx = pair.indexOf("=");
1633
- if (idx === -1) {
2136
+ for (const c of conditions) {
2137
+ const shapeReason = conditionShapeProblem(c);
2138
+ if (shapeReason) return shapeReason;
2139
+ if (isGroup(c)) {
2140
+ const nested = conditionsProblem(c.rules, depth + 1);
2141
+ if (nested) return nested;
1634
2142
  continue;
1635
2143
  }
1636
- cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
2144
+ const parameterReason = parameterProblem(c.parameter);
2145
+ if (parameterReason) return parameterReason;
2146
+ const mutationReason = mutationsProblem(c.mutations);
2147
+ if (mutationReason) return mutationReason;
2148
+ const matchReason = matchProblem(c.match, c.parameter);
2149
+ if (matchReason) return matchReason;
2150
+ const m = c.match;
2151
+ if (m.type === "regex") {
2152
+ if (typeof m.value !== "string") return "regex match.value must be a string";
2153
+ if (m.value.length > LIMITS2.maxRegexLength) return `regex longer than ${LIMITS2.maxRegexLength} chars`;
2154
+ } else if (typeof m.value === "string" && m.value.length > LIMITS2.maxValueLength) {
2155
+ return `match.value longer than ${LIMITS2.maxValueLength} chars`;
2156
+ }
2157
+ if (m.match) {
2158
+ const nested = conditionsProblem([{ parameter: c.parameter, match: m.match }], depth + 1);
2159
+ if (nested) return nested;
2160
+ }
1637
2161
  }
1638
- return cookies;
2162
+ return null;
1639
2163
  }
1640
2164
 
1641
- // src/protect/egress.js
1642
- async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = true, lookup, allowHosts } = {}) {
1643
- const restores = [];
1644
- if (typeof shouldBlock !== "function") return () => {
2165
+ // src/protect/capture-plan.js
2166
+ var NEVER_CAPTURABLE = /* @__PURE__ */ new Set(["response"]);
2167
+ var CAPTURE_LIMITS = Object.freeze({
2168
+ /** Named and prefix values in one event, together. Raw has its own allowance. */
2169
+ capturedValues: 10,
2170
+ /** Characters of any single captured value. */
2171
+ valueChars: 512,
2172
+ /** Resolved values one prefix permission may contribute. */
2173
+ prefixValues: 5
2174
+ });
2175
+ function parametersOf(rule) {
2176
+ const out = /* @__PURE__ */ new Set();
2177
+ const add = (parameter) => {
2178
+ if (typeof parameter === "string" && parameter !== "rules") out.add(parameter);
1645
2179
  };
1646
- const exempt = new Set((allowHosts ?? []).map((h) => String(h).toLowerCase()));
1647
- const block = (url, host, method) => {
1648
- if (!shouldBlock(url, host, method)) return false;
1649
- notify(onBlock, { url, host, method }, "onEgressBlock");
1650
- return true;
1651
- };
1652
- let screen = null;
1653
- if (dnsScreen) {
1654
- try {
1655
- const resolveLookup = lookup ?? (await import("dns")).lookup;
1656
- const { isIP } = await import("net");
1657
- if (typeof resolveLookup === "function" && typeof isIP === "function") {
1658
- screen = { lookup: resolveLookup, isIP, isExempt: (h) => exempt.has(String(h).toLowerCase()) };
1659
- }
1660
- } catch {
1661
- screen = null;
2180
+ const collect = (parameter) => {
2181
+ if (Array.isArray(parameter)) {
2182
+ for (const member of parameter) add(member);
2183
+ return;
1662
2184
  }
1663
- }
1664
- const skip = (reason, detail) => notify(onSkip, { phase: "egress", reason, detail }, "onSkip");
1665
- const resolvesToDisallowed = (url, host, method) => new Promise((resolve) => {
1666
- if (!screen) {
1667
- if (host && dnsScreen) skip("resolver-unavailable", { host });
1668
- return resolve(false);
2185
+ add(parameter);
2186
+ };
2187
+ const walk = (conditions, depth) => {
2188
+ if (!Array.isArray(conditions) || depth > 20) return;
2189
+ for (const condition of conditions) {
2190
+ if (!condition || typeof condition !== "object") continue;
2191
+ collect(condition.parameter);
2192
+ if (Array.isArray(condition.rules)) walk(condition.rules, depth + 1);
1669
2193
  }
1670
- if (!host || screen.isIP(host) !== 0 || screen.isExempt(host)) return resolve(false);
1671
- try {
1672
- screen.lookup(host, { all: true }, (err, addresses) => {
1673
- if (err || !Array.isArray(addresses)) {
1674
- skip("resolver-failed", { host });
1675
- return resolve(false);
1676
- }
1677
- for (const a of addresses) {
1678
- const ip = a && typeof a === "object" ? a.address : a;
1679
- if (ip && block(url, ip, method)) return resolve(true);
1680
- }
1681
- resolve(false);
1682
- });
1683
- } catch {
1684
- skip("resolver-failed", { host });
1685
- resolve(false);
2194
+ };
2195
+ walk(rule?.rule_v2, 0);
2196
+ return [...out];
2197
+ }
2198
+ function rawOptIn(rule) {
2199
+ if (!rule || typeof rule !== "object" || !Object.hasOwn(rule, "capture")) return null;
2200
+ const capture = rule.capture;
2201
+ if (capture === null || typeof capture !== "object") return null;
2202
+ if (captureProblem(capture) !== null) return null;
2203
+ return { chars: Math.min(capture.raw_chars, CAPTURE_RAW_CHARS_MAX) };
2204
+ }
2205
+ var NOTHING = Object.freeze({
2206
+ named: Object.freeze([]),
2207
+ prefixes: Object.freeze([]),
2208
+ raw: null,
2209
+ limits: CAPTURE_LIMITS
2210
+ });
2211
+ function derivePlan(rule) {
2212
+ const named = /* @__PURE__ */ new Set();
2213
+ const prefixes = /* @__PURE__ */ new Set();
2214
+ if (enforceableRuleProblem(rule) !== null) return NOTHING;
2215
+ const parameters = parametersOf(rule).filter((parameter) => parameterProblem(parameter) === null);
2216
+ for (const parameter of parameters) {
2217
+ const dot = parameter.indexOf(".");
2218
+ if (dot === -1) continue;
2219
+ const source = parameter.slice(0, dot);
2220
+ const key = parameter.slice(dot + 1);
2221
+ if (SOURCES[source]?.keyed !== true || NEVER_CAPTURABLE.has(source)) continue;
2222
+ if (key.endsWith("*")) {
2223
+ const prefix = key.slice(0, -1);
2224
+ if (prefix !== "") prefixes.add(`${source}.${prefix}`);
2225
+ continue;
1686
2226
  }
2227
+ named.add(`${source}.${key}`);
2228
+ }
2229
+ const raw = rawOptIn(rule);
2230
+ return Object.freeze({
2231
+ named: Object.freeze([...named].sort()),
2232
+ prefixes: Object.freeze([...prefixes].sort()),
2233
+ raw: raw === null ? null : Object.freeze(raw),
2234
+ limits: CAPTURE_LIMITS
1687
2235
  });
1688
- const originalFetch = globalThis.fetch;
1689
- if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
1690
- const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
1691
- const MAX_REDIRECTS = 20;
1692
- const screenUrl = async (u, method) => {
1693
- let host = null;
1694
- try {
1695
- host = new URL(u).hostname;
1696
- } catch {
1697
- host = null;
1698
- }
1699
- if (block(u, host, method) || await resolvesToDisallowed(u, host, method)) {
1700
- throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? u}`);
1701
- }
1702
- };
1703
- const guarded = async (input, init) => {
1704
- let cur;
1705
- try {
1706
- cur = new Request(input, { ...init || {}, redirect: "manual" });
1707
- } catch {
1708
- return originalFetch(input, init);
1709
- }
1710
- const callerRedirect = init && init.redirect || input && input.redirect || "follow";
1711
- let url = cur.url;
1712
- let method = cur.method;
1713
- await screenUrl(url, method);
1714
- if (callerRedirect !== "follow") return originalFetch(input, init);
1715
- const headers = new Headers(cur.headers);
1716
- const signal = cur.signal;
1717
- let body = method === "GET" || method === "HEAD" ? void 0 : await cur.clone().arrayBuffer();
1718
- for (let hop = 0; ; hop++) {
1719
- const resp = await originalFetch(
1720
- hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
1721
- );
1722
- const location = REDIRECT_STATUSES.has(resp.status) ? resp.headers.get("location") : null;
1723
- if (!location) return resp;
1724
- if (hop >= MAX_REDIRECTS) throw new Error("Patchstack blocked an outbound request: too many redirects");
1725
- const next = new URL(location, url).href;
1726
- if (resp.status === 303 || (resp.status === 301 || resp.status === 302) && method === "POST") {
1727
- method = "GET";
1728
- body = void 0;
1729
- headers.delete("content-type");
1730
- headers.delete("content-length");
1731
- }
1732
- if (new URL(next).origin !== new URL(url).origin) {
1733
- headers.delete("authorization");
1734
- headers.delete("cookie");
1735
- }
1736
- await screenUrl(next, method);
1737
- url = next;
2236
+ }
2237
+ function permitsAnything(plan) {
2238
+ return plan.named.length > 0 || plan.prefixes.length > 0 || plan.raw !== null;
2239
+ }
2240
+ var LANES = Object.freeze([2166136261, 16777619, 2654435769, 2246822507]);
2241
+ function planReference(plan) {
2242
+ const limits = plan?.limits ?? {};
2243
+ const canonical = JSON.stringify([
2244
+ [...plan?.named ?? []].sort(),
2245
+ [...plan?.prefixes ?? []].sort(),
2246
+ plan?.raw?.chars ?? 0,
2247
+ Object.entries(limits).map(([key, value]) => [key, value]).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
2248
+ ]);
2249
+ const digest = LANES.map((base) => {
2250
+ let hash = base >>> 0;
2251
+ for (let i = 0; i < canonical.length; i++) {
2252
+ hash ^= canonical.charCodeAt(i);
2253
+ hash = Math.imul(hash, 16777619) >>> 0;
2254
+ }
2255
+ return hash.toString(16).padStart(8, "0");
2256
+ }).join("");
2257
+ return `cp2-${digest}`;
2258
+ }
2259
+ function createPlanCache() {
2260
+ const byRule = /* @__PURE__ */ new WeakMap();
2261
+ let derivations = 0;
2262
+ return {
2263
+ for(rule) {
2264
+ if (rule === null || typeof rule !== "object") {
2265
+ const plan2 = derivePlan(rule);
2266
+ derivations += 1;
2267
+ return Object.freeze({ plan: plan2, reference: planReference(plan2) });
1738
2268
  }
1739
- };
1740
- guarded.__patchstackGuarded = true;
1741
- globalThis.fetch = guarded;
1742
- restores.push(() => {
1743
- if (globalThis.fetch === guarded) globalThis.fetch = originalFetch;
1744
- });
1745
- }
1746
- for (const moduleName of ["node:http", "node:https"]) {
2269
+ const hit = byRule.get(rule);
2270
+ if (hit) return hit;
2271
+ const plan = derivePlan(rule);
2272
+ derivations += 1;
2273
+ const entry = Object.freeze({ plan, reference: planReference(plan) });
2274
+ byRule.set(rule, entry);
2275
+ return entry;
2276
+ },
2277
+ /** How many plans have been derived — the observable difference between a hit and a miss. */
2278
+ get derivations() {
2279
+ return derivations;
2280
+ }
2281
+ };
2282
+ }
2283
+ function captureValues(plan, resolver) {
2284
+ const nothing = { values: [], omitted: 0, unsupported: 0, failed: 0, unavailable: false, raw: null };
2285
+ if (!plan || !permitsAnything(plan)) return nothing;
2286
+ if (!resolver || typeof resolver.resolve !== "function") return { ...nothing, unavailable: true };
2287
+ const limits = plan.limits ?? CAPTURE_LIMITS;
2288
+ const values = [];
2289
+ let omitted = 0;
2290
+ let unsupported = 0;
2291
+ let failed = 0;
2292
+ let attempted = 0;
2293
+ const read = (parameter) => {
2294
+ attempted += 1;
1747
2295
  try {
1748
- const mod = await import(moduleName);
1749
- const restore = patchHttpModule(mod.default ?? mod, block, screen);
1750
- if (restore) restores.push(restore);
2296
+ const resolved = resolver.resolve(parameter);
2297
+ return Array.isArray(resolved) ? resolved : [];
1751
2298
  } catch {
2299
+ failed += 1;
2300
+ return null;
1752
2301
  }
1753
- }
1754
- return () => {
1755
- for (const restore of restores) {
1756
- try {
1757
- restore();
1758
- } catch {
1759
- }
2302
+ };
2303
+ const classify = (value) => {
2304
+ const text = asText(value);
2305
+ if (text === null) {
2306
+ unsupported += 1;
2307
+ return null;
1760
2308
  }
2309
+ return text;
1761
2310
  };
1762
- }
1763
- function patchHttpModule(http, block, screen) {
1764
- if (!http || typeof http.request !== "function" || http.__patchstackGuarded) return null;
1765
- const originalRequest = http.request;
1766
- const originalGet = http.get;
1767
- const wrap = (original) => function(...args) {
1768
- const target = extractHttpTarget(args);
1769
- if (target && block(target.url, target.host, target.method)) {
1770
- throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host ?? target.url}`);
2311
+ const record = (parameter, text) => {
2312
+ if (values.length >= limits.capturedValues) {
2313
+ omitted += 1;
2314
+ return false;
1771
2315
  }
1772
- if (target && screen && target.host && screen.isIP(target.host) === 0 && !screen.isExempt(target.host)) {
1773
- try {
1774
- args = withScreeningLookup(args, target, block, screen.lookup);
1775
- } catch {
2316
+ const capped = text.length > limits.valueChars;
2317
+ values.push({
2318
+ parameter,
2319
+ value: capped ? text.slice(0, limits.valueChars) : text,
2320
+ ...capped ? { truncated: true } : {}
2321
+ });
2322
+ return true;
2323
+ };
2324
+ for (const parameter of plan.named) {
2325
+ for (const value of read(parameter) ?? []) {
2326
+ const text = classify(value);
2327
+ if (text !== null) record(parameter, text);
2328
+ }
2329
+ }
2330
+ for (const prefix of plan.prefixes) {
2331
+ const pattern = `${prefix}*`;
2332
+ let taken = 0;
2333
+ for (const value of read(pattern) ?? []) {
2334
+ const text = classify(value);
2335
+ if (text === null) continue;
2336
+ if (taken >= limits.prefixValues) {
2337
+ omitted += 1;
2338
+ continue;
1776
2339
  }
2340
+ if (record(pattern, text)) taken += 1;
1777
2341
  }
1778
- return original.apply(this, args);
1779
- };
1780
- const guardedRequest = wrap(originalRequest);
1781
- http.request = guardedRequest;
1782
- let guardedGet;
1783
- if (typeof originalGet === "function") {
1784
- guardedGet = wrap(originalGet);
1785
- http.get = guardedGet;
1786
2342
  }
1787
- http.__patchstackGuarded = true;
1788
- return () => {
1789
- if (http.request === guardedRequest) http.request = originalRequest;
1790
- if (guardedGet && http.get === guardedGet) http.get = originalGet;
1791
- delete http.__patchstackGuarded;
1792
- };
1793
- }
1794
- function extractHttpTarget(args) {
1795
- const first = args[0];
1796
- try {
1797
- if (typeof first === "string" || first instanceof URL) {
1798
- const url = new URL(String(first));
1799
- const opts = args.find((a) => a && typeof a === "object" && !(a instanceof URL));
1800
- return { url: url.href, host: url.hostname, method: opts && opts.method || "GET" };
1801
- }
1802
- if (first && typeof first === "object") {
1803
- const host = normalizeHost(first.hostname || first.host) || "localhost";
1804
- const protocol = first.protocol || "http:";
1805
- const port = first.port ? `:${first.port}` : "";
1806
- const path = first.path || "/";
1807
- return { url: `${protocol}//${host}${port}${path}`, host, method: first.method || "GET" };
2343
+ let raw = null;
2344
+ if (plan.raw !== null) {
2345
+ const text = asText((read("raw") ?? [])[0]);
2346
+ if (text !== null) {
2347
+ const capped = text.length > plan.raw.chars;
2348
+ raw = { value: capped ? text.slice(0, plan.raw.chars) : text, ...capped ? { truncated: true } : {} };
1808
2349
  }
1809
- } catch {
1810
2350
  }
1811
- return null;
1812
- }
1813
- function normalizeHost(raw) {
1814
- const host = String(raw || "").trim();
1815
- const bracketed = /^\[([^\]]+)\]/.exec(host);
1816
- if (bracketed) return bracketed[1];
1817
- if ((host.match(/:/g) || []).length > 1) return host;
1818
- return host.split(":")[0];
2351
+ const unavailable = attempted > 0 && failed === attempted;
2352
+ return { values, omitted, unsupported, failed, unavailable, raw };
1819
2353
  }
1820
- function screenResolved(addresses, target, block) {
1821
- for (const a of addresses || []) {
1822
- const ip = a && typeof a === "object" ? a.address : a;
1823
- if (ip && block(target.url, ip, target.method)) return ip;
1824
- }
2354
+ function asText(value) {
2355
+ if (typeof value === "string") return value;
2356
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
2357
+ if (typeof value === "boolean") return String(value);
1825
2358
  return null;
1826
2359
  }
1827
- function withScreeningLookup(args, target, block, lookup) {
1828
- const screeningLookup = (hostname, options, callback) => {
1829
- let opts = options;
1830
- let cb = callback;
1831
- if (typeof opts === "function") {
1832
- cb = opts;
1833
- opts = {};
2360
+
2361
+ // src/protect/engine/pulse-client.js
2362
+ var DEFAULT_BASE_URL2 = "https://api.patchstack.com/monitor/pulse";
2363
+ var DEFAULT_CACHE_TTL2 = 3e5;
2364
+ var JITTER_FRACTION2 = 0.1;
2365
+ var PulseRuleClient = class {
2366
+ #siteUuid;
2367
+ #timeoutMs;
2368
+ #baseUrl;
2369
+ #cacheTtl;
2370
+ #cache = null;
2371
+ #cacheTime = null;
2372
+ #ttlEffective = 0;
2373
+ #etag;
2374
+ #pulseAuth;
2375
+ #detectionState;
2376
+ constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, detectionState } = {}) {
2377
+ this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
2378
+ this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
2379
+ this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL, DEFAULT_BASE_URL2, "rule endpoint");
2380
+ this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL2;
2381
+ this.#etag = etag ?? null;
2382
+ this.#pulseAuth = pulseAuth ?? null;
2383
+ this.#detectionState = typeof detectionState === "string" ? detectionState : null;
2384
+ if (!this.#siteUuid) {
2385
+ throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
1834
2386
  }
1835
- if (!opts || typeof opts !== "object") opts = {};
2387
+ }
2388
+ async getRules() {
2389
+ const now = Date.now();
2390
+ if (this.#cache && this.#cacheTime !== null && now - this.#cacheTime < this.#ttlEffective) {
2391
+ return this.#cache;
2392
+ }
2393
+ const url = `${this.#baseUrl}/rules/${encodeURIComponent(this.#siteUuid)}`;
1836
2394
  try {
1837
- lookup(hostname, { ...opts, all: true }, (err, addresses) => {
1838
- if (err) return cb(err);
1839
- const list = Array.isArray(addresses) ? addresses : [];
1840
- const blocked = screenResolved(list, target, block);
1841
- if (blocked) {
1842
- return cb(new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host} resolved to ${blocked}`));
1843
- }
1844
- if (opts.all) return cb(null, list);
1845
- const first = list[0];
1846
- if (!first) return cb(new Error(`Patchstack: could not resolve ${hostname}`));
1847
- return cb(null, first.address, first.family);
1848
- });
1849
- } catch {
1850
- try {
1851
- lookup(hostname, opts, cb);
1852
- } catch {
1853
- cb(new Error(`Patchstack: lookup failed for ${hostname}`));
2395
+ const auth = await pulseAuthHeader(
2396
+ { pulseAuth: this.#pulseAuth, endpoint: this.#baseUrl, timeoutMs: this.#timeoutMs },
2397
+ fetch
2398
+ );
2399
+ const headers = { Accept: "application/json", ...auth };
2400
+ if (this.#detectionState !== null && typeof auth.Authorization === "string") {
2401
+ headers["X-Patchstack-Detections"] = this.#detectionState;
1854
2402
  }
1855
- }
1856
- };
1857
- return injectLookupOption(args, screeningLookup);
1858
- }
1859
- function injectLookupOption(args, lookup) {
1860
- const first = args[0];
1861
- if (first && typeof first === "object" && !(first instanceof URL)) {
1862
- return [{ ...first, lookup }, ...args.slice(1)];
1863
- }
1864
- const rest = args.slice(1);
1865
- const optIdx = rest.findIndex((a) => a && typeof a === "object" && !(a instanceof URL));
1866
- if (optIdx !== -1) {
1867
- const next = [...rest];
1868
- next[optIdx] = { ...rest[optIdx], lookup };
1869
- return [first, ...next];
2403
+ if (this.#etag) headers["If-None-Match"] = this.#etag;
2404
+ const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
2405
+ if (response.status === 304) {
2406
+ this.#touch(now);
2407
+ return this.#cache ?? { success: true, notModified: true, etag: this.#etag, firewall: [], whitelists: [], whitelist_keys: {} };
2408
+ }
2409
+ if (!response.ok) {
2410
+ return { success: false, error: `API returned ${response.status}`, firewall: [], whitelists: [], whitelist_keys: {} };
2411
+ }
2412
+ const data = await response.json();
2413
+ if (!data || !Array.isArray(data.firewall)) {
2414
+ return { success: false, error: "unexpected response shape (no firewall array)", firewall: [], whitelists: [], whitelist_keys: {} };
2415
+ }
2416
+ const result = {
2417
+ success: true,
2418
+ notModified: false,
2419
+ etag: response.headers?.get?.("etag") ?? null,
2420
+ firewall: data.firewall,
2421
+ whitelists: Array.isArray(data.whitelists) ? data.whitelists : [],
2422
+ whitelist_keys: data.whitelist_keys ?? {},
2423
+ ...enforcementField(data)
2424
+ };
2425
+ this.#cache = result;
2426
+ this.#etag = result.etag;
2427
+ this.#touch(now);
2428
+ return result;
2429
+ } catch (err) {
2430
+ return {
2431
+ success: false,
2432
+ error: err.name === "TimeoutError" ? "Request timed out" : err.message,
2433
+ firewall: [],
2434
+ whitelists: [],
2435
+ whitelist_keys: {}
2436
+ };
2437
+ }
1870
2438
  }
1871
- const cbIdx = rest.findIndex((a) => typeof a === "function");
1872
- if (cbIdx === -1) return [first, { lookup }, ...rest];
1873
- return [first, ...rest.slice(0, cbIdx), { lookup }, ...rest.slice(cbIdx)];
1874
- }
1875
-
1876
- // src/protect/defaults.js
1877
- var DEFAULT_RESPONSE_RULES = [
1878
- {
1879
- id: "resp-private-key",
1880
- title: "Private key in response body",
1881
- phase: "response",
1882
- category: "secret-exposure",
1883
- action: "redact",
1884
- prefilter: ["PRIVATE KEY"],
1885
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/" } }]
1886
- },
1887
- {
1888
- id: "resp-aws-access-key",
1889
- title: "AWS access key id in response body",
1890
- phase: "response",
1891
- category: "secret-exposure",
1892
- action: "redact",
1893
- prefilter: ["AKIA", "ASIA"],
1894
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b/" } }]
1895
- },
1896
- {
1897
- id: "resp-gcp-api-key",
1898
- title: "Google API key in response body",
1899
- phase: "response",
1900
- category: "secret-exposure",
1901
- action: "redact",
1902
- prefilter: ["AIza"],
1903
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\bAIza[0-9A-Za-z_-]{35}\\b/" } }]
1904
- },
1905
- {
1906
- id: "resp-vendor-api-key",
1907
- title: "Vendor API key / token in response body",
1908
- phase: "response",
1909
- category: "secret-exposure",
1910
- action: "redact",
1911
- // High-signal, prefix-anchored provider tokens that never legitimately appear in a response
1912
- // body: Stripe (sk_live_/rk_live_), GitHub (gh[opsu]_ / github_pat_), GitLab (glpat-),
1913
- // Slack (xox[baprs]-), Anthropic (sk-ant-), Google OAuth (ya29.), npm (npm_). Trailing
1914
- // (?![0-9A-Za-z]) instead of \\b since some tokens end in - / _ .
1915
- prefilter: ["sk_live_", "rk_live_", "ghp_", "gho_", "ghs_", "ghu_", "github_pat_", "glpat-", "xox", "sk-ant-", "ya29.", "npm_"],
1916
- 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])/" } }]
1917
- },
1918
- {
1919
- id: "resp-jwt",
1920
- title: "JWT in response body",
1921
- phase: "response",
1922
- category: "secret-exposure",
1923
- action: "redact",
1924
- prefilter: ["eyJ"],
1925
- 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/" } }]
1926
- },
1927
- {
1928
- id: "resp-db-connection-string",
1929
- title: "Database connection string with credentials in response body",
1930
- phase: "response",
1931
- category: "secret-exposure",
1932
- action: "redact",
1933
- prefilter: ["mongodb", "postgres", "mysql", "redis", "amqp"],
1934
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[^\\s:@\\/]+:[^\\s:@\\/]+@/i" } }]
1935
- },
1936
- {
1937
- id: "resp-stack-trace",
1938
- title: "Node stack trace leaking in response body",
1939
- phase: "response",
1940
- category: "info-exposure",
1941
- action: "redact",
1942
- // No prefilter: a Node stack frame has no single distinctive literal (` at ` is too common to
1943
- // gate on). The pattern is linearly bounded per line, so it runs on every screened body.
1944
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\n\\s+at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
1945
- },
1946
- {
1947
- id: "resp-sql-error",
1948
- title: "SQL / ORM error disclosure in response body",
1949
- phase: "response",
1950
- category: "info-exposure",
1951
- action: "redact",
1952
- prefilter: ["SQLSTATE", "Sequelize", "ER_", "ORA-", "PG::", "SQLITE_ERROR", "SQL syntax"],
1953
- 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" } }]
1954
- },
1955
- {
1956
- id: "resp-exception-trace",
1957
- title: "Backend exception / stack trace disclosure in response body",
1958
- phase: "response",
1959
- category: "info-exposure",
1960
- action: "redact",
1961
- // Multi-language exception/traceback signatures a normal API response never carries:
1962
- // Python traceback, Java "Exception in thread", .NET System.*Exception, JVM stack frames,
1963
- // Go goroutine dumps. (Node `at fn (file:line:col)` frames are handled by resp-stack-trace.)
1964
- prefilter: ["Traceback", "Exception in thread", "System.", "goroutine ", ".java:", ".kt:", ".scala:", ".rb:", ".py:", ".cs:"],
1965
- 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+ \\[)/' } }]
2439
+ #touch(now) {
2440
+ this.#cacheTime = now;
2441
+ this.#ttlEffective = this.#cacheTtl - Math.floor(Math.random() * this.#cacheTtl * JITTER_FRACTION2);
1966
2442
  }
1967
- ];
1968
- var DEFAULT_EGRESS_RULES = [
1969
- {
1970
- id: "egress-internal-address",
1971
- title: "Outbound request to an internal / metadata address (SSRF)",
1972
- phase: "egress",
1973
- category: "ssrf",
1974
- rule_v2: [{ parameter: "egress.host", match: { type: "internal_host" } }]
2443
+ clearCache() {
2444
+ this.#cache = null;
2445
+ this.#cacheTime = null;
2446
+ this.#etag = null;
1975
2447
  }
1976
- ];
1977
-
1978
- // src/protect/block-page.js
1979
- function escapeHtml(s) {
1980
- return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
2448
+ };
2449
+ function enforcementField(data) {
2450
+ if (!data || typeof data !== "object") return {};
2451
+ const v = data.enforcement ?? data.mode;
2452
+ return v === "block" || v === "dry-run" ? { enforcement: v } : {};
1981
2453
  }
1982
- function renderBlockPage({ url = "", code = "patchstack" } = {}) {
1983
- const safeUrl = escapeHtml(url);
1984
- const safeCode = escapeHtml(code || "patchstack");
1985
- return `<!DOCTYPE html>
1986
- <html lang="en">
1987
- <head>
1988
- <meta charset="UTF-8">
1989
- <meta http-equiv="X-UA-Compatible" content="IE=edge">
1990
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
1991
- <style type="text/css">
1992
- .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}}
1993
- </style>
1994
- <title>Access Denied</title>
1995
- </head>
1996
- <body>
1997
- <div class="main-container">
1998
- <div class="container">
1999
- <div class="content">
2000
- <span class="access-denied">Access Denied</span>
2001
- <div class="site-url">
2002
- <input id="url" class="site-url-text" type="text"
2003
- value="${safeUrl}" readonly>
2004
- <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')">
2005
- </div>
2006
- <div class="site-url">
2007
- <input id="response" class="reason-code-text" type="text" value="${safeCode}" readonly>
2008
- <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')">
2009
- </div>
2010
- <div class="descp">
2011
- <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>
2012
- </div>
2013
- </div>
2014
- </div>
2015
- <div class="bottom-container">
2016
- <div class="bottom-text-containers">
2017
- <img class="arrow-vector" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOCIgaGVpZ2h0PSIxMyIgdmlld0JveD0iMCAwIDggMTMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik03LjQxIDEuNDMzNDRMNiAwLjAyMzQzNzVMMCA2LjAyMzQ0TDYgMTIuMDIzNEw3LjQxIDEwLjYxMzRMMi44MyA2LjAyMzQ0TDcuNDEgMS40MzM0NFoiIGZpbGw9IiNBRkU2MTQiLz4KPC9zdmc+Cg==" alt="Arrow Left">
2018
- <a href="/" class="return-text">Return to homepage</a>
2019
- </div>
2020
- <div class="bottom-second-containers">
2021
- <span class="secured-text">This website is secured by</span>
2022
- <a href="https://patchstack.com" class="logoText">
2023
- <img class="patch-logo" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTg0IiBoZWlnaHQ9IjIyIiB2aWV3Qm94PSIwIDAgMTg0IDIyIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTUwLjQwMDEgMTEuNTAwOEM1MC40MDAxIDkuMTAwNzggNDkuMzAwMSA4LjEwMDc4IDQ2LjYwMDEgOC4xMDA3OEM0NC4wMDAxIDguMTAwNzggNDIuNzAwMSA5LjIwMDc4IDQyLjcwMDEgMTEuNTAwOEM0Mi43MDAxIDEzLjgwMDggNDQuMDAwMSAxNC45MDA4IDQ2LjYwMDEgMTQuOTAwOEM0OS4zMDAxIDE0LjkwMDggNTAuNDAwMSAxMy44MDA4IDUwLjQwMDEgMTEuNTAwOFpNNTMuNDAwMSAxMS41MDA4QzUzLjQwMDEgMTUuMzAwOCA1MS4zMDAxIDE3LjYwMDggNDcuNDAwMSAxNy42MDA4QzQ1LjQwMDEgMTcuNjAwOCA0My42MDAxIDE2LjUwMDggNDIuNzAwMSAxNC41MDA4VjIxLjUwMDhIMzkuNjAwMVY1LjYwMDc4SDQyLjcwMDFWOC41MDA3OEM0My42MDAxIDYuNTAwNzggNDUuNDAwMSA1LjMwMDc4IDQ3LjUwMDEgNS4zMDA3OEM1MS40MDAxIDUuMzAwNzggNTMuNDAwMSA3LjcwMDc4IDUzLjQwMDEgMTEuNTAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNjYuNDAwMSAxMS40OTg0QzY2LjQwMDEgOS4xOTg0NCA2NS4xMDAxIDguMDk4NDQgNjIuNTAwMSA4LjA5ODQ0QzU5LjgwMDEgOC4wOTg0NCA1OC43MDAxIDkuMDk4NDQgNTguNzAwMSAxMS40OTg0QzU4LjcwMDEgMTMuNzk4NCA1OS44MDAxIDE0Ljg5ODQgNjIuNTAwMSAxNC44OTg0QzY1LjEwMDEgMTQuODk4NCA2Ni40MDAxIDEzLjc5ODQgNjYuNDAwMSAxMS40OTg0Wk02Ni4zMDAxIDUuNTk4NDRINjkuNDAwMVYxNy4zOTg0SDY2LjMwMDFWMTQuNDk4NEM2NS40MDAxIDE2LjQ5ODQgNjMuNzAwMSAxNy42OTg0IDYxLjYwMDEgMTcuNjk4NEM1Ny43MDAxIDE3LjY5ODQgNTUuNjAwMSAxNS4yOTg0IDU1LjYwMDEgMTEuNTk4NEM1NS42MDAxIDcuNzk4NDQgNTcuNjAwMSA1LjM5ODQ0IDYxLjQwMDEgNS4zOTg0NEM2My41MDAxIDUuMzk4NDQgNjUuNDAwMSA2LjU5ODQ0IDY2LjIwMDEgOC41OTg0NFY1LjU5ODQ0SDY2LjMwMDFaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTgxLjM5OTkgOC4zSDc3LjM5OTlWMTQuN0g4MS4wOTk5VjE3LjRINzcuOTk5OUM3NS43OTk5IDE3LjQgNzQuMjk5OSAxNS45IDc0LjI5OTkgMTMuN1Y4LjNINzEuMzk5OVY1LjZINzQuMjk5OVYySDc3LjM5OTlWNS41SDgxLjM5OTlWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik05My42OTk5IDEyLjIwMDhMOTYuNjk5OSAxMi45MDA4Qzk1Ljk5OTkgMTYuMDAwOCA5My42OTk5IDE3LjYwMDggODkuOTk5OSAxNy42MDA4Qzg3Ljc5OTkgMTcuNjAwOCA4Ni4wOTk5IDE3LjAwMDggODQuNzk5OSAxNS45MDA4QzgzLjQ5OTkgMTQuODAwOCA4Mi44OTk5IDEzLjMwMDggODIuODk5OSAxMS41MDA4QzgyLjg5OTkgOS42MDA3OCA4My40OTk5IDguMTAwNzggODQuNzk5OSA3LjAwMDc4Qzg2LjA5OTkgNS45MDA3OCA4Ny43OTk5IDUuMzAwNzggODkuOTk5OSA1LjMwMDc4QzkzLjY5OTkgNS4zMDA3OCA5NS45OTk5IDYuOTAwNzggOTYuNjk5OSAxMC4wMDA4TDkzLjY5OTkgMTAuNzAwOEM5My4yOTk5IDguODAwNzggOTIuMjk5OSA4LjEwMDc4IDg5Ljg5OTkgOC4xMDA3OEM4Ni45OTk5IDguMTAwNzggODUuODk5OSA5LjIwMDc4IDg1Ljg5OTkgMTEuNTAwOEM4NS44OTk5IDEzLjgwMDggODYuOTk5OSAxNC45MDA4IDg5Ljg5OTkgMTQuOTAwOEM5Mi4yOTk5IDE0LjkwMDggOTMuMjk5OSAxNC4yMDA4IDkzLjY5OTkgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTExLjggMTAuNlYxNy40SDEwOC43VjExLjJDMTA4LjcgOSAxMDcuOCA4LjEgMTA1LjkgOC4xQzEwMy42IDguMSAxMDIuNCA5LjMgMTAyLjQgMTEuN1YxNy4zSDk5LjI5OThWMC41SDEwMi40VjguM0MxMDMuMSA2LjMgMTA0LjYgNS4zIDEwNyA1LjNDMTEwLjIgNS4zIDExMS44IDcuMSAxMTEuOCAxMC42WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMTQuMSAxNC4wOTg0TDExNy4xIDEzLjI5ODRDMTE3LjQgMTQuNzk4NCAxMTguMyAxNS4yOTg0IDEyMC4yIDE1LjI5ODRDMTIxLjggMTUuMjk4NCAxMjIuNyAxNC44OTg0IDEyMi43IDE0LjA5ODRDMTIyLjcgMTMuMjk4NCAxMjIuMiAxMi45OTg0IDEyMC42IDEyLjc5ODRMMTE4LjIgMTIuNDk4NEMxMTUuOSAxMi4xOTg0IDExNC41IDEwLjg5ODQgMTE0LjUgOC45OTg0NEMxMTQuNSA2Ljc5ODQ0IDExNi41IDUuMzk4NDQgMTE5LjggNS4zOTg0NEMxMjMuMSA1LjM5ODQ0IDEyNSA2LjY5ODQ0IDEyNS41IDguODk4NDRMMTIyLjUgOS41OTg0NEMxMjIuMyA4LjA5ODQ0IDEyMS41IDcuNjk4NDQgMTE5LjkgNy42OTg0NEMxMTguNCA3LjY5ODQ0IDExNy42IDguMDk4NDQgMTE3LjYgOC44OTg0NEMxMTcuNiA5LjU5ODQ0IDExOC4xIDkuOTk4NDQgMTE5LjMgMTAuMDk4NEwxMjIuMiAxMC40OTg0QzEyNC40IDEwLjc5ODQgMTI1LjggMTEuOTk4NCAxMjUuOCAxMy44OTg0QzEyNS44IDE2LjI5ODQgMTIzLjcgMTcuNzk4NCAxMjAuMSAxNy43OTg0QzExNi43IDE3LjU5ODQgMTE0LjUgMTYuMTk4NCAxMTQuMSAxNC4wOTg0WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMzYuNyA4LjNIMTMyLjdWMTQuN0gxMzYuNFYxNy40SDEzMy4zQzEzMS4xIDE3LjQgMTI5LjYgMTUuOSAxMjkuNiAxMy43VjguM0gxMjYuN1Y1LjZIMTI5LjZWMkgxMzIuN1Y1LjVIMTM2LjdWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xNDkuMiAxMS40OTg0QzE0OS4yIDkuMTk4NDQgMTQ3LjkgOC4wOTg0NCAxNDUuMyA4LjA5ODQ0QzE0Mi42IDguMDk4NDQgMTQxLjUgOS4wOTg0NCAxNDEuNSAxMS40OTg0QzE0MS41IDEzLjc5ODQgMTQyLjYgMTQuODk4NCAxNDUuMyAxNC44OTg0QzE0Ny45IDE0Ljg5ODQgMTQ5LjIgMTMuNzk4NCAxNDkuMiAxMS40OTg0Wk0xNDkuMSA1LjU5ODQ0SDE1Mi4yVjE3LjM5ODRIMTQ5LjFWMTQuNDk4NEMxNDguMiAxNi40OTg0IDE0Ni41IDE3LjY5ODQgMTQ0LjQgMTcuNjk4NEMxNDAuNSAxNy42OTg0IDEzOC40IDE1LjI5ODQgMTM4LjQgMTEuNTk4NEMxMzguNCA3Ljc5ODQ0IDE0MC40IDUuMzk4NDQgMTQ0LjIgNS4zOTg0NEMxNDYuMyA1LjM5ODQ0IDE0OC4yIDYuNTk4NDQgMTQ5IDguNTk4NDRWNS41OTg0NEgxNDkuMVoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTY1LjYgMTIuMjAwOEwxNjguNiAxMi45MDA4QzE2Ny45IDE2LjAwMDggMTY1LjYgMTcuNjAwOCAxNjEuOSAxNy42MDA4QzE1OS43IDE3LjYwMDggMTU4IDE3LjAwMDggMTU2LjcgMTUuOTAwOEMxNTUuNCAxNC44MDA4IDE1NC44IDEzLjMwMDggMTU0LjggMTEuNTAwOEMxNTQuOCA5LjYwMDc4IDE1NS40IDguMTAwNzggMTU2LjcgNy4wMDA3OEMxNTggNS45MDA3OCAxNTkuNyA1LjMwMDc4IDE2MS45IDUuMzAwNzhDMTY1LjYgNS4zMDA3OCAxNjcuOSA2LjkwMDc4IDE2OC42IDEwLjAwMDhMMTY1LjYgMTAuNzAwOEMxNjUuMiA4LjgwMDc4IDE2NC4yIDguMTAwNzggMTYxLjggOC4xMDA3OEMxNTguOSA4LjEwMDc4IDE1Ny44IDkuMjAwNzggMTU3LjggMTEuNTAwOEMxNTcuOCAxMy44MDA4IDE1OC45IDE0LjkwMDggMTYxLjggMTQuOTAwOEMxNjQuMSAxNC45MDA4IDE2NS4xIDE0LjIwMDggMTY1LjYgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTgwLjI1NyAxNy40TDE3Ni42NTcgMTIuM0wxNzQuMTU3IDE0LjZWMTcuNEgxNzEuMTU3VjAuNUgxNzQuMTU3VjExLjFMMTc5Ljg1NyA1LjZIMTgzLjc1N0wxNzguNTU3IDEwLjVMMTgzLjk1NyAxNy40SDE4MC4yNTdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTAgMjEuNTAwOEg3LjNWMTYuMzAwOEgwVjIxLjUwMDhaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNy4yOTk4IDIxLjUwMDhIMTQuNTk5OFYxNi4zMDA4SDcuMjk5OFYyMS41MDA4WiIgZmlsbD0iIzZFQkUwMCIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTE0LjYwMDEgMTYuM0gyOS4yMDAxVjAuNUgxNC42MDAxVjE2LjNaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMCAxMUgxNC42VjAuNUgwVjExWiIgZmlsbD0iIzZFQkUwMCIvPgo8L3N2Zz4K" alt="Patchstack Logo">
2024
- </a>
2025
- </div>
2026
- </div>
2027
-
2028
- <div class="mobile-bottom-container">
2029
- <div class="mobile-bottom-text-containers">
2030
- <img class="arrow-vector" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOCIgaGVpZ2h0PSIxMyIgdmlld0JveD0iMCAwIDggMTMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik03LjQxIDEuNDMzNDRMNiAwLjAyMzQzNzVMMCA2LjAyMzQ0TDYgMTIuMDIzNEw3LjQxIDEwLjYxMzRMMi44MyA2LjAyMzQ0TDcuNDEgMS40MzM0NFoiIGZpbGw9IiNBRkU2MTQiLz4KPC9zdmc+Cg==" alt="Arrow Left">
2031
- <a href="/" class="return-text">Return to homepage</a>
2032
- </div>
2033
- <div class="mobile-bottom-second-containers">
2034
- <span class="secured-text">This website is secured by</span>
2035
- <a href="https://patchstack.com" class="logoText">
2036
- <img class="patch-logo" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTg0IiBoZWlnaHQ9IjIyIiB2aWV3Qm94PSIwIDAgMTg0IDIyIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTUwLjQwMDEgMTEuNTAwOEM1MC40MDAxIDkuMTAwNzggNDkuMzAwMSA4LjEwMDc4IDQ2LjYwMDEgOC4xMDA3OEM0NC4wMDAxIDguMTAwNzggNDIuNzAwMSA5LjIwMDc4IDQyLjcwMDEgMTEuNTAwOEM0Mi43MDAxIDEzLjgwMDggNDQuMDAwMSAxNC45MDA4IDQ2LjYwMDEgMTQuOTAwOEM0OS4zMDAxIDE0LjkwMDggNTAuNDAwMSAxMy44MDA4IDUwLjQwMDEgMTEuNTAwOFpNNTMuNDAwMSAxMS41MDA4QzUzLjQwMDEgMTUuMzAwOCA1MS4zMDAxIDE3LjYwMDggNDcuNDAwMSAxNy42MDA4QzQ1LjQwMDEgMTcuNjAwOCA0My42MDAxIDE2LjUwMDggNDIuNzAwMSAxNC41MDA4VjIxLjUwMDhIMzkuNjAwMVY1LjYwMDc4SDQyLjcwMDFWOC41MDA3OEM0My42MDAxIDYuNTAwNzggNDUuNDAwMSA1LjMwMDc4IDQ3LjUwMDEgNS4zMDA3OEM1MS40MDAxIDUuMzAwNzggNTMuNDAwMSA3LjcwMDc4IDUzLjQwMDEgMTEuNTAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNjYuNDAwMSAxMS40OTg0QzY2LjQwMDEgOS4xOTg0NCA2NS4xMDAxIDguMDk4NDQgNjIuNTAwMSA4LjA5ODQ0QzU5LjgwMDEgOC4wOTg0NCA1OC43MDAxIDkuMDk4NDQgNTguNzAwMSAxMS40OTg0QzU4LjcwMDEgMTMuNzk4NCA1OS44MDAxIDE0Ljg5ODQgNjIuNTAwMSAxNC44OTg0QzY1LjEwMDEgMTQuODk4NCA2Ni40MDAxIDEzLjc5ODQgNjYuNDAwMSAxMS40OTg0Wk02Ni4zMDAxIDUuNTk4NDRINjkuNDAwMVYxNy4zOTg0SDY2LjMwMDFWMTQuNDk4NEM2NS40MDAxIDE2LjQ5ODQgNjMuNzAwMSAxNy42OTg0IDYxLjYwMDEgMTcuNjk4NEM1Ny43MDAxIDE3LjY5ODQgNTUuNjAwMSAxNS4yOTg0IDU1LjYwMDEgMTEuNTk4NEM1NS42MDAxIDcuNzk4NDQgNTcuNjAwMSA1LjM5ODQ0IDYxLjQwMDEgNS4zOTg0NEM2My41MDAxIDUuMzk4NDQgNjUuNDAwMSA2LjU5ODQ0IDY2LjIwMDEgOC41OTg0NFY1LjU5ODQ0SDY2LjMwMDFaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTgxLjM5OTkgOC4zSDc3LjM5OTlWMTQuN0g4MS4wOTk5VjE3LjRINzcuOTk5OUM3NS43OTk5IDE3LjQgNzQuMjk5OSAxNS45IDc0LjI5OTkgMTMuN1Y4LjNINzEuMzk5OVY1LjZINzQuMjk5OVYySDc3LjM5OTlWNS41SDgxLjM5OTlWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik05My42OTk5IDEyLjIwMDhMOTYuNjk5OSAxMi45MDA4Qzk1Ljk5OTkgMTYuMDAwOCA5My42OTk5IDE3LjYwMDggODkuOTk5OSAxNy42MDA4Qzg3Ljc5OTkgMTcuNjAwOCA4Ni4wOTk5IDE3LjAwMDggODQuNzk5OSAxNS45MDA4QzgzLjQ5OTkgMTQuODAwOCA4Mi44OTk5IDEzLjMwMDggODIuODk5OSAxMS41MDA4QzgyLjg5OTkgOS42MDA3OCA4My40OTk5IDguMTAwNzggODQuNzk5OSA3LjAwMDc4Qzg2LjA5OTkgNS45MDA3OCA4Ny43OTk5IDUuMzAwNzggODkuOTk5OSA1LjMwMDc4QzkzLjY5OTkgNS4zMDA3OCA5NS45OTk5IDYuOTAwNzggOTYuNjk5OSAxMC4wMDA4TDkzLjY5OTkgMTAuNzAwOEM5My4yOTk5IDguODAwNzggOTIuMjk5OSA4LjEwMDc4IDg5Ljg5OTkgOC4xMDA3OEM4Ni45OTk5IDguMTAwNzggODUuODk5OSA5LjIwMDc4IDg1Ljg5OTkgMTEuNTAwOEM4NS44OTk5IDEzLjgwMDggODYuOTk5OSAxNC45MDA4IDg5Ljg5OTkgMTQuOTAwOEM5Mi4yOTk5IDE0LjkwMDggOTMuMjk5OSAxNC4yMDA4IDkzLjY5OTkgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTExLjggMTAuNlYxNy40SDEwOC43VjExLjJDMTA4LjcgOSAxMDcuOCA4LjEgMTA1LjkgOC4xQzEwMy42IDguMSAxMDIuNCA5LjMgMTAyLjQgMTEuN1YxNy4zSDk5LjI5OThWMC41SDEwMi40VjguM0MxMDMuMSA2LjMgMTA0LjYgNS4zIDEwNyA1LjNDMTEwLjIgNS4zIDExMS44IDcuMSAxMTEuOCAxMC42WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMTQuMSAxNC4wOTg0TDExNy4xIDEzLjI5ODRDMTE3LjQgMTQuNzk4NCAxMTguMyAxNS4yOTg0IDEyMC4yIDE1LjI5ODRDMTIxLjggMTUuMjk4NCAxMjIuNyAxNC44OTg0IDEyMi43IDE0LjA5ODRDMTIyLjcgMTMuMjk4NCAxMjIuMiAxMi45OTg0IDEyMC42IDEyLjc5ODRMMTE4LjIgMTIuNDk4NEMxMTUuOSAxMi4xOTg0IDExNC41IDEwLjg5ODQgMTE0LjUgOC45OTg0NEMxMTQuNSA2Ljc5ODQ0IDExNi41IDUuMzk4NDQgMTE5LjggNS4zOTg0NEMxMjMuMSA1LjM5ODQ0IDEyNSA2LjY5ODQ0IDEyNS41IDguODk4NDRMMTIyLjUgOS41OTg0NEMxMjIuMyA4LjA5ODQ0IDEyMS41IDcuNjk4NDQgMTE5LjkgNy42OTg0NEMxMTguNCA3LjY5ODQ0IDExNy42IDguMDk4NDQgMTE3LjYgOC44OTg0NEMxMTcuNiA5LjU5ODQ0IDExOC4xIDkuOTk4NDQgMTE5LjMgMTAuMDk4NEwxMjIuMiAxMC40OTg0QzEyNC40IDEwLjc5ODQgMTI1LjggMTEuOTk4NCAxMjUuOCAxMy44OTg0QzEyNS44IDE2LjI5ODQgMTIzLjcgMTcuNzk4NCAxMjAuMSAxNy43OTg0QzExNi43IDE3LjU5ODQgMTE0LjUgMTYuMTk4NCAxMTQuMSAxNC4wOTg0WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMzYuNyA4LjNIMTMyLjdWMTQuN0gxMzYuNFYxNy40SDEzMy4zQzEzMS4xIDE3LjQgMTI5LjYgMTUuOSAxMjkuNiAxMy43VjguM0gxMjYuN1Y1LjZIMTI5LjZWMkgxMzIuN1Y1LjVIMTM2LjdWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xNDkuMiAxMS40OTg0QzE0OS4yIDkuMTk4NDQgMTQ3LjkgOC4wOTg0NCAxNDUuMyA4LjA5ODQ0QzE0Mi42IDguMDk4NDQgMTQxLjUgOS4wOTg0NCAxNDEuNSAxMS40OTg0QzE0MS41IDEzLjc5ODQgMTQyLjYgMTQuODk4NCAxNDUuMyAxNC44OTg0QzE0Ny45IDE0Ljg5ODQgMTQ5LjIgMTMuNzk4NCAxNDkuMiAxMS40OTg0Wk0xNDkuMSA1LjU5ODQ0SDE1Mi4yVjE3LjM5ODRIMTQ5LjFWMTQuNDk4NEMxNDguMiAxNi40OTg0IDE0Ni41IDE3LjY5ODQgMTQ0LjQgMTcuNjk4NEMxNDAuNSAxNy42OTg0IDEzOC40IDE1LjI5ODQgMTM4LjQgMTEuNTk4NEMxMzguNCA3Ljc5ODQ0IDE0MC40IDUuMzk4NDQgMTQ0LjIgNS4zOTg0NEMxNDYuMyA1LjM5ODQ0IDE0OC4yIDYuNTk4NDQgMTQ5IDguNTk4NDRWNS41OTg0NEgxNDkuMVoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTY1LjYgMTIuMjAwOEwxNjguNiAxMi45MDA4QzE2Ny45IDE2LjAwMDggMTY1LjYgMTcuNjAwOCAxNjEuOSAxNy42MDA4QzE1OS43IDE3LjYwMDggMTU4IDE3LjAwMDggMTU2LjcgMTUuOTAwOEMxNTUuNCAxNC44MDA4IDE1NC44IDEzLjMwMDggMTU0LjggMTEuNTAwOEMxNTQuOCA5LjYwMDc4IDE1NS40IDguMTAwNzggMTU2LjcgNy4wMDA3OEMxNTggNS45MDA3OCAxNTkuNyA1LjMwMDc4IDE2MS45IDUuMzAwNzhDMTY1LjYgNS4zMDA3OCAxNjcuOSA2LjkwMDc4IDE2OC42IDEwLjAwMDhMMTY1LjYgMTAuNzAwOEMxNjUuMiA4LjgwMDc4IDE2NC4yIDguMTAwNzggMTYxLjggOC4xMDA3OEMxNTguOSA4LjEwMDc4IDE1Ny44IDkuMjAwNzggMTU3LjggMTEuNTAwOEMxNTcuOCAxMy44MDA4IDE1OC45IDE0LjkwMDggMTYxLjggMTQuOTAwOEMxNjQuMSAxNC45MDA4IDE2NS4xIDE0LjIwMDggMTY1LjYgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTgwLjI1NyAxNy40TDE3Ni42NTcgMTIuM0wxNzQuMTU3IDE0LjZWMTcuNEgxNzEuMTU3VjAuNUgxNzQuMTU3VjExLjFMMTc5Ljg1NyA1LjZIMTgzLjc1N0wxNzguNTU3IDEwLjVMMTgzLjk1NyAxNy40SDE4MC4yNTdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTAgMjEuNTAwOEg3LjNWMTYuMzAwOEgwVjIxLjUwMDhaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNy4yOTk4IDIxLjUwMDhIMTQuNTk5OFYxNi4zMDA4SDcuMjk5OFYyMS41MDA4WiIgZmlsbD0iIzZFQkUwMCIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTE0LjYwMDEgMTYuM0gyOS4yMDAxVjAuNUgxNC42MDAxVjE2LjNaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMCAxMUgxNC42VjAuNUgwVjExWiIgZmlsbD0iIzZFQkUwMCIvPgo8L3N2Zz4K" alt="Patchstack Logo">
2037
- </a>
2038
- </div>
2039
- </div>
2040
- </div>
2041
- </body>
2042
- <script>
2043
- function copyText(id) {
2044
- var copyText = document.getElementById(id)
2045
- copyText.select();
2046
- copyText.setSelectionRange(0, 99999);
2047
- navigator.clipboard.writeText(copyText.value);
2048
- }
2049
-
2050
- function hover(element) {
2051
- element.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkiIGhlaWdodD0iMjMiIHZpZXdCb3g9IjAgMCAxOSAyMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0IDAuMDIzNDM3NUgwVjE2LjAyMzRIMlYyLjAyMzQ0SDE0VjAuMDIzNDM3NVpNMTkgNC4wMjM0NEg0VjIyLjAyMzRIMTlWNC4wMjM0NFpNMTcgMjAuMDIzNEg2VjYuMDIzNDRIMTdWMjAuMDIzNFoiIGZpbGw9IiNBRkU2MTQiLz4KPC9zdmc+Cg==');
2052
- }
2053
2454
 
2054
- function unhover(element) {
2055
- element.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkiIGhlaWdodD0iMjMiIHZpZXdCb3g9IjAgMCAxOSAyMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0IDAuMDIzNDM3NUgwVjE2LjAyMzRIMlYyLjAyMzQ0SDE0VjAuMDIzNDM3NVpNMTkgNC4wMjM0NEg0VjIyLjAyMzRIMTlWNC4wMjM0NFpNMTcgMjAuMDIzNEg2VjYuMDIzNDRIMTdWMjAuMDIzNFoiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=');
2455
+ // src/protect/engine/fetch.js
2456
+ var MAX_BODY_BYTES = 1024 * 1024;
2457
+ async function fromFetchRequest(request, options = {}) {
2458
+ const maxBodyBytes = options.maxBodyBytes ?? MAX_BODY_BYTES;
2459
+ const url = new URL(request.url);
2460
+ const method = (request.method || "GET").toUpperCase();
2461
+ const headers = {};
2462
+ request.headers.forEach((value, key) => {
2463
+ headers[key.toLowerCase()] = value;
2464
+ });
2465
+ const query = {};
2466
+ for (const [key, value] of url.searchParams) {
2467
+ if (key in query) {
2468
+ query[key] = Array.isArray(query[key]) ? [...query[key], value] : [query[key], value];
2469
+ } else {
2470
+ query[key] = value;
2056
2471
  }
2057
- </script>
2058
- </html>`;
2059
- }
2060
-
2061
- // src/protect/rules/store.js
2062
- var fsMod;
2063
- async function loadFs() {
2064
- if (fsMod !== void 0) return fsMod;
2065
- try {
2066
- const [fs, path] = await Promise.all([import("fs"), import("path")]);
2067
- fsMod = {
2068
- readFileSync: fs.readFileSync,
2069
- writeFileSync: fs.writeFileSync,
2070
- mkdirSync: fs.mkdirSync,
2071
- join: path.join
2072
- };
2073
- } catch {
2074
- fsMod = null;
2075
2472
  }
2076
- return fsMod;
2077
- }
2078
- function makeStore(options = {}) {
2079
- let mem = null;
2080
- const durable = durableTier(options);
2081
- return {
2082
- async read() {
2083
- if (mem) return mem;
2084
- const env = await durable.read();
2085
- if (env) mem = env;
2086
- return env;
2087
- },
2088
- async write(env) {
2089
- if (env) mem = env;
2090
- await durable.write(env);
2091
- }
2092
- };
2093
- }
2094
- function durableTier(options) {
2095
- const adapter = options.ruleCache;
2096
- if (adapter && typeof adapter.read === "function" && typeof adapter.write === "function") {
2097
- return {
2098
- read: async () => {
2099
- try {
2100
- return toEnvelope(await adapter.read());
2101
- } catch {
2102
- return null;
2103
- }
2104
- },
2105
- write: async (env) => {
2106
- try {
2107
- await adapter.write(env);
2108
- } catch {
2109
- }
2110
- }
2111
- };
2473
+ let rawBody = "";
2474
+ if (method !== "GET" && method !== "HEAD") {
2475
+ rawBody = await readCappedText(request, maxBodyBytes);
2112
2476
  }
2113
- const dir = options.cacheDir;
2477
+ const contentType = headers["content-type"] || "";
2478
+ let body = {};
2479
+ let files;
2480
+ if (rawBody) {
2481
+ const parsed = parseBody(rawBody, contentType);
2482
+ body = parsed.body;
2483
+ files = parsed.files;
2484
+ }
2485
+ const uri = url.pathname + url.search;
2486
+ const client = resolveClientIp({ peer: options.peer, headers, trustedProxy: options.trustedProxy });
2114
2487
  return {
2115
- read: async () => cacheRead(dir),
2116
- write: async (env) => cacheWrite(dir, env)
2488
+ method,
2489
+ url: uri,
2490
+ originalUrl: uri,
2491
+ query,
2492
+ body,
2493
+ files,
2494
+ headers,
2495
+ // The resolved address, as an own property. Nothing downstream re-derives or falls back: a consumer
2496
+ // that guessed differently would attribute one request to two addresses.
2497
+ ip: client.ip ?? "",
2498
+ _clientIp: client,
2499
+ cookies: parseCookies(headers.cookie),
2500
+ // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify
2501
+ // drops, so prototype-pollution rules on `raw` are robust.
2502
+ _rawBody: rawBody
2117
2503
  };
2118
2504
  }
2119
- async function cacheWrite(dir, env) {
2120
- if (!dir) return;
2121
- const fs = await loadFs();
2122
- if (!fs) return;
2505
+ async function readCappedText(request, max) {
2506
+ let clone;
2507
+ try {
2508
+ clone = request.clone();
2509
+ } catch {
2510
+ return "";
2511
+ }
2512
+ const body = clone.body;
2513
+ if (body && typeof body.getReader === "function") {
2514
+ const reader = body.getReader();
2515
+ const chunks = [];
2516
+ let buffered = 0;
2517
+ try {
2518
+ for (; ; ) {
2519
+ const { done, value } = await reader.read();
2520
+ if (done) break;
2521
+ if (!value || buffered >= max) continue;
2522
+ const take = Math.min(value.byteLength, max - buffered);
2523
+ chunks.push(take === value.byteLength ? value : value.subarray(0, take));
2524
+ buffered += take;
2525
+ }
2526
+ } catch {
2527
+ return "";
2528
+ }
2529
+ try {
2530
+ return new TextDecoder().decode(concatChunks(chunks, buffered));
2531
+ } catch {
2532
+ return "";
2533
+ }
2534
+ }
2123
2535
  try {
2124
- fs.mkdirSync(dir, { recursive: true });
2125
- fs.writeFileSync(fs.join(dir, "patchstack-rules.json"), JSON.stringify(env));
2536
+ const text = await clone.text();
2537
+ return text.length > max ? text.slice(0, max) : text;
2126
2538
  } catch {
2539
+ return "";
2127
2540
  }
2128
2541
  }
2129
- async function cacheRead(dir) {
2130
- if (!dir) return null;
2131
- const fs = await loadFs();
2132
- if (!fs) return null;
2133
- try {
2134
- return toEnvelope(JSON.parse(fs.readFileSync(fs.join(dir, "patchstack-rules.json"), "utf8")));
2135
- } catch {
2136
- return null;
2542
+ function concatChunks(chunks, total) {
2543
+ const out = new Uint8Array(total);
2544
+ let offset = 0;
2545
+ for (const c of chunks) {
2546
+ out.set(c, offset);
2547
+ offset += c.byteLength;
2137
2548
  }
2549
+ return out;
2138
2550
  }
2139
- function toEnvelope(value) {
2140
- if (!value || typeof value !== "object") return null;
2141
- if (value.bundle && typeof value.bundle === "object") {
2142
- return { bundle: value.bundle, etag: value.etag ?? null };
2551
+ function parseBody(rawBody, contentType) {
2552
+ const ct = String(contentType || "").toLowerCase();
2553
+ const isJson = ct.includes("application/json") || /\+json\b/.test(ct);
2554
+ const isForm = ct.includes("application/x-www-form-urlencoded");
2555
+ const isMultipart = ct.includes("multipart/form-data");
2556
+ const isAmbiguous = ct === "" || ct.startsWith("text/plain") || ct.includes("csp-report") || ct.includes("/json");
2557
+ if (isMultipart) {
2558
+ const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
2559
+ if (boundary) return parseMultipart(rawBody, boundary);
2560
+ return { body: {}, files: void 0 };
2143
2561
  }
2144
- if (Array.isArray(value.firewall) || Array.isArray(value.whitelists)) {
2145
- return { bundle: value, etag: null };
2562
+ if (isForm) {
2563
+ const body = {};
2564
+ for (const [k, v] of new URLSearchParams(rawBody)) body[k] = k in body ? [].concat(body[k], v) : v;
2565
+ return { body, files: void 0 };
2146
2566
  }
2147
- return null;
2567
+ if (isJson || isAmbiguous) {
2568
+ try {
2569
+ const parsed = JSON.parse(rawBody);
2570
+ if (parsed && typeof parsed === "object") return { body: parsed, files: void 0 };
2571
+ } catch {
2572
+ }
2573
+ }
2574
+ return { body: {}, files: void 0 };
2148
2575
  }
2149
-
2150
- // src/protect/rules/contract.js
2151
- var SOURCES = Object.freeze({
2152
- raw: Object.freeze({ keyed: false }),
2153
- all: Object.freeze({ keyed: false }),
2154
- false: Object.freeze({ keyed: false }),
2155
- rules: Object.freeze({ keyed: false, group: true }),
2156
- get: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
2157
- post: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
2158
- request: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
2159
- cookie: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
2160
- // A bare `files.<name>` resolves to the filename; an optional trailing attribute selects another part
2161
- // of the upload. Optional, not required — filename-scoped rules use the bare form.
2162
- files: Object.freeze({
2163
- keyed: true,
2164
- keys: "any",
2165
- wildcard: true,
2166
- optional_key_suffixes: Object.freeze(["content", "filename", "type"])
2167
- }),
2168
- server: Object.freeze({
2169
- keyed: true,
2170
- keys: Object.freeze([
2171
- "REQUEST_URI",
2172
- "REQUEST_METHOD",
2173
- "HTTP_USER_AGENT",
2174
- "HTTP_REFERER",
2175
- "HTTP_HOST",
2176
- "REMOTE_ADDR",
2177
- "ip",
2178
- "CONTENT_TYPE",
2179
- "CONTENT_LENGTH"
2180
- ]),
2181
- key_prefixes: Object.freeze(["HTTP_"])
2182
- }),
2183
- response: Object.freeze({
2184
- keyed: true,
2185
- keys: Object.freeze(["status", "body", "headers"]),
2186
- key_prefixes: Object.freeze(["header."])
2187
- }),
2188
- egress: Object.freeze({ keyed: true, keys: Object.freeze(["url", "host", "method"]) })
2189
- });
2190
- var MATCH_TYPES = Object.freeze([
2191
- "array_in_array",
2192
- "array_key_value",
2193
- "contains",
2194
- "cors_reflected",
2195
- "cross_origin",
2196
- "ctype_alnum",
2197
- "ctype_digit",
2198
- "ctype_special",
2199
- "equals",
2200
- "equals_strict",
2201
- "hostname",
2202
- "in_array",
2203
- "inline_js_xss",
2204
- "inline_xss",
2205
- "internal_host",
2206
- "is_numeric",
2207
- "isset",
2208
- "less_than",
2209
- "more_than",
2210
- "not_contains",
2211
- "not_in_array",
2212
- "off_origin",
2213
- "quotes",
2214
- "regex",
2215
- "stripos"
2216
- ]);
2217
- var PARAMETERLESS_MATCH_TYPES = Object.freeze(["cross_origin", "off_origin", "cors_reflected"]);
2218
- var OPERANDLESS_MATCH_TYPES = Object.freeze([
2219
- "isset",
2220
- "quotes",
2221
- "is_numeric",
2222
- "ctype_alnum",
2223
- "ctype_digit",
2224
- "ctype_special",
2225
- "inline_xss",
2226
- "inline_js_xss",
2227
- "internal_host",
2228
- "cross_origin",
2229
- "off_origin",
2230
- "cors_reflected"
2231
- ]);
2232
- var OPERAND_SHAPES = Object.freeze([
2233
- "non-empty-string",
2234
- "regex-literal",
2235
- "string-or-string-list",
2236
- "scalar",
2237
- "string",
2238
- "numeric",
2239
- "scalar-or-non-empty-list",
2240
- "non-empty-list",
2241
- "object",
2242
- "non-empty-object",
2243
- "non-empty-string-list",
2244
- "header-name-map",
2245
- "header-name-list",
2246
- "cookie-flags"
2247
- ]);
2248
- var COOKIE_FLAGS = Object.freeze(["httpOnly", "secure", "sameSite"]);
2249
- var SAME_SITE_VALUES = Object.freeze(["Strict", "Lax", "None"]);
2250
- var HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
2251
- var REGEX_LITERAL = /^\/(.+?)\/([gimsuy]*)$/s;
2252
- var MATCH_OPERANDS = Object.freeze({
2253
- array_key_value: Object.freeze({
2254
- required: Object.freeze(["key", "match"]),
2255
- shapes: Object.freeze({ key: "string-or-string-list", match: "object" })
2256
- }),
2257
- regex: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "regex-literal" }) }),
2258
- contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
2259
- stripos: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
2260
- not_contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
2261
- // A hostname is compared to `new URL(value).hostname`, so an empty one only ever equals the empty
2262
- // hostname of an opaque-scheme URL (`file:`, `mailto:`, `data:`) — never a host, and a false positive
2263
- // on those. The other three are the list-membership family: an empty list makes `in_array` and
2264
- // `array_in_array` inert and `not_in_array` true for every value.
2265
- hostname: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
2266
- in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
2267
- not_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
2268
- array_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-list" }) }),
2269
- // The comparison family. `equals` coerces, so any scalar can match. `equals_strict` does not, and its
2270
- // subject is always a string by the time it is compared — so a number operand is unconditionally
2271
- // inert, which is the trap the engine's own note about `'1' !== 1` describes.
2272
- equals: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar" }) }),
2273
- equals_strict: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "string" }) }),
2274
- less_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) }),
2275
- more_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) })
2276
- });
2277
- function operandShapeProblem(shape, value) {
2278
- const isFilledString = (v) => typeof v === "string" && v.trim() !== "";
2279
- const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
2280
- const isHeaderName = (v) => typeof v === "string" && HEADER_NAME.test(v);
2281
- switch (shape) {
2282
- case "non-empty-string":
2283
- return isFilledString(value) ? null : "must be a non-empty string";
2284
- case "regex-literal":
2285
- if (!isFilledString(value)) return "must be a non-empty string";
2286
- return REGEX_LITERAL.test(value) ? null : "must be a delimited pattern, as in /pattern/i";
2287
- case "string-or-string-list":
2288
- if (isFilledString(value)) return null;
2289
- if (Array.isArray(value) && value.length > 0 && value.every(isFilledString)) return null;
2290
- return "must be a non-empty string, or a non-empty list of them";
2291
- case "scalar":
2292
- return isScalar(value) ? null : "must be a string, number or boolean";
2293
- case "string":
2294
- return typeof value === "string" ? null : "must be a string (this comparison is type-strict)";
2295
- case "numeric":
2296
- return isScalar(value) && Number.isFinite(Number(value)) ? null : "must be a number";
2297
- case "scalar-or-non-empty-list":
2298
- if (isScalar(value)) return null;
2299
- if (Array.isArray(value) && value.length > 0 && value.every(isScalar)) return null;
2300
- return "must be a scalar, or a non-empty list of them";
2301
- case "non-empty-list":
2302
- return Array.isArray(value) && value.length > 0 && value.every(isScalar) ? null : "must be a non-empty list of scalars";
2303
- case "header-name-map": {
2304
- if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
2305
- const names = Object.keys(value);
2306
- if (names.length === 0) return "must be an object with at least one entry";
2307
- const bad = names.find((name) => !isHeaderName(name));
2308
- return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
2576
+ function parseMultipart(rawBody, boundary) {
2577
+ const body = {};
2578
+ const files = {};
2579
+ for (const part of rawBody.split("--" + boundary)) {
2580
+ const sep = /\r?\n\r?\n/.exec(part);
2581
+ if (!sep) continue;
2582
+ const rawHeaders = part.slice(0, sep.index);
2583
+ const disposition = /content-disposition:[^\r\n]*/i.exec(rawHeaders)?.[0];
2584
+ if (!disposition) continue;
2585
+ const name = /name="([^"]*)"/i.exec(disposition)?.[1];
2586
+ if (name == null) continue;
2587
+ const content = part.slice(sep.index + sep[0].length).replace(/\r?\n$/, "");
2588
+ const filename = /filename="([^"]*)"/i.exec(disposition)?.[1];
2589
+ if (filename !== void 0) {
2590
+ const partType = /content-type:\s*([^\r\n;]+)/i.exec(rawHeaders)?.[1]?.trim() || "";
2591
+ const file = { filename, type: partType, content };
2592
+ files[name] = name in files ? [].concat(files[name], file) : file;
2593
+ } else {
2594
+ body[name] = name in body ? [].concat(body[name], content) : content;
2309
2595
  }
2310
- case "header-name-list": {
2311
- if (!Array.isArray(value) || value.length === 0) return "must be a non-empty list of HTTP header names";
2312
- const bad = value.find((name) => !isHeaderName(name));
2313
- return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
2596
+ }
2597
+ return { body, files };
2598
+ }
2599
+ function parseCookies(header) {
2600
+ const cookies = {};
2601
+ if (!header) {
2602
+ return cookies;
2603
+ }
2604
+ for (const pair of header.split(";")) {
2605
+ const idx = pair.indexOf("=");
2606
+ if (idx === -1) {
2607
+ continue;
2608
+ }
2609
+ cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
2610
+ }
2611
+ return cookies;
2612
+ }
2613
+
2614
+ // src/protect/engine/node.js
2615
+ function fromNodeRequest(req, rawBody = "", options = {}) {
2616
+ const method = (req.method || "GET").toUpperCase();
2617
+ const headers = {};
2618
+ for (const [key, value] of Object.entries(req.headers || {})) {
2619
+ headers[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;
2620
+ }
2621
+ const host = headers.host || "localhost";
2622
+ let url;
2623
+ try {
2624
+ url = new URL(req.url || "/", `http://${host}`);
2625
+ } catch {
2626
+ try {
2627
+ url = new URL(req.url || "/", "http://localhost");
2628
+ } catch {
2629
+ url = new URL("http://localhost/");
2314
2630
  }
2315
- case "cookie-flags": {
2316
- if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
2317
- const unknown = Object.keys(value).filter((key) => !COOKIE_FLAGS.includes(key));
2318
- if (unknown.length > 0) return `has no flag named ${unknown.map((k) => JSON.stringify(k)).join(", ")}`;
2319
- for (const flag of ["httpOnly", "secure"]) {
2320
- if (value[flag] !== void 0 && typeof value[flag] !== "boolean") {
2321
- return `flag "${flag}" must be true or false`;
2322
- }
2323
- }
2324
- const sameSite = value.sameSite;
2325
- const sameSiteOff = sameSite === false;
2326
- if (sameSite !== void 0 && !sameSiteOff) {
2327
- if (typeof sameSite !== "string" || !SAME_SITE_VALUES.some((v) => v.toLowerCase() === sameSite.toLowerCase())) {
2328
- return `flag "sameSite" must be false, or one of ${SAME_SITE_VALUES.join(", ")}`;
2329
- }
2330
- }
2331
- if (value.httpOnly === false && value.secure === false && sameSiteOff) {
2332
- return "turns off every flag, so it would harden nothing";
2333
- }
2334
- return null;
2631
+ }
2632
+ const query = {};
2633
+ for (const [key, value] of url.searchParams) {
2634
+ if (key in query) {
2635
+ query[key] = Array.isArray(query[key]) ? [...query[key], value] : [query[key], value];
2636
+ } else {
2637
+ query[key] = value;
2335
2638
  }
2336
- case "object":
2337
- return value !== null && typeof value === "object" && !Array.isArray(value) ? null : "must be an object";
2338
- case "non-empty-object":
2339
- return value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0 ? null : "must be an object with at least one entry";
2340
- case "non-empty-string-list":
2341
- return Array.isArray(value) && value.length > 0 && value.every(isFilledString) ? null : "must be a non-empty list of non-empty strings";
2342
- default:
2343
- return null;
2344
2639
  }
2640
+ const contentType = headers["content-type"] || "";
2641
+ let body = {};
2642
+ let files;
2643
+ if (rawBody) {
2644
+ const parsed = parseBody(rawBody, contentType);
2645
+ body = parsed.body;
2646
+ files = parsed.files;
2647
+ }
2648
+ const uri = url.pathname + url.search;
2649
+ const client = resolveClientIp({
2650
+ peer: req.socket?.remoteAddress,
2651
+ headers,
2652
+ trustedProxy: options.trustedProxy
2653
+ });
2654
+ return {
2655
+ method,
2656
+ url: uri,
2657
+ originalUrl: uri,
2658
+ query,
2659
+ body,
2660
+ files,
2661
+ headers,
2662
+ ip: client.ip ?? "",
2663
+ _clientIp: client,
2664
+ cookies: parseCookies2(headers.cookie),
2665
+ // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify drops.
2666
+ _rawBody: rawBody
2667
+ };
2345
2668
  }
2346
- var MUTATIONS = Object.freeze([
2347
- "base64_decode",
2348
- "getArrayValues",
2349
- "htmlentitydecode",
2350
- "intval",
2351
- "json_decode",
2352
- "json_encode",
2353
- "urldecode"
2354
- ]);
2355
- var PHASES = Object.freeze(["request", "response", "egress"]);
2356
- var ACTIONS = Object.freeze([
2357
- "block",
2358
- "redact",
2359
- "encode",
2360
- "set-header",
2361
- "remove-header",
2362
- "harden-cookie"
2363
- ]);
2364
- var WHEN_KEYS = Object.freeze(["path", "method"]);
2365
- var RULE_PROPERTIES = Object.freeze([
2366
- "id",
2367
- "rule_id",
2368
- "title",
2369
- "category",
2370
- "phase",
2371
- "action",
2372
- "rule_v2",
2373
- "when",
2374
- "message",
2375
- "enforcement",
2376
- "source_revision",
2377
- "prefilter",
2378
- "max_bytes",
2379
- "bypass_limit",
2380
- "set_headers",
2381
- "remove_headers",
2382
- "cookie_flags",
2383
- "ensure"
2384
- ]);
2385
- var ACTION_PROPERTIES = Object.freeze({
2386
- block: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["request", "response", "egress"]) }),
2387
- redact: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
2388
- encode: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
2389
- "set-header": Object.freeze({
2390
- required: Object.freeze(["set_headers"]),
2391
- defaulted: Object.freeze(["ensure"]),
2392
- phases: Object.freeze(["response"]),
2393
- shapes: Object.freeze({ set_headers: "header-name-map" })
2394
- }),
2395
- "remove-header": Object.freeze({
2396
- required: Object.freeze(["remove_headers"]),
2397
- defaulted: Object.freeze([]),
2398
- phases: Object.freeze(["response"]),
2399
- shapes: Object.freeze({ remove_headers: "header-name-list" })
2400
- }),
2401
- "harden-cookie": Object.freeze({
2402
- required: Object.freeze([]),
2403
- defaulted: Object.freeze(["cookie_flags"]),
2404
- phases: Object.freeze(["response"]),
2405
- shapes: Object.freeze({ cookie_flags: "cookie-flags" })
2406
- })
2407
- });
2408
- var LIMITS = Object.freeze({
2409
- maxRules: 5e3,
2410
- maxWhitelists: 2e3,
2411
- maxConditionsPerRule: 250,
2412
- maxNestingDepth: 12,
2413
- maxRegexLength: 1e3,
2414
- maxValueLength: 8192
2415
- });
2416
- var GROUP_PARAMETER = "rules";
2417
- function isGroup(condition) {
2418
- return condition?.parameter === GROUP_PARAMETER && Array.isArray(condition?.rules);
2669
+ function parseCookies2(header) {
2670
+ const cookies = {};
2671
+ if (!header) {
2672
+ return cookies;
2673
+ }
2674
+ for (const pair of header.split(";")) {
2675
+ const idx = pair.indexOf("=");
2676
+ if (idx === -1) {
2677
+ continue;
2678
+ }
2679
+ cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
2680
+ }
2681
+ return cookies;
2419
2682
  }
2420
- function conditionShapeProblem(condition) {
2421
- if (!condition || typeof condition !== "object") return "condition is not an object";
2422
- const named = condition.parameter === GROUP_PARAMETER;
2423
- const nested = Array.isArray(condition.rules);
2424
- if (named && !nested) {
2425
- return `parameter "${GROUP_PARAMETER}" is a group and needs a "rules" array`;
2683
+
2684
+ // src/protect/egress.js
2685
+ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = true, lookup, allowHosts } = {}) {
2686
+ const restores = [];
2687
+ if (typeof shouldBlock !== "function") return () => {
2688
+ };
2689
+ const exempt = new Set((allowHosts ?? []).map((h) => String(h).toLowerCase()));
2690
+ const block = (url, host, method) => {
2691
+ if (!shouldBlock(url, host, method)) return false;
2692
+ notify(onBlock, { url, host, method }, "onEgressBlock");
2693
+ return true;
2694
+ };
2695
+ let screen = null;
2696
+ if (dnsScreen) {
2697
+ try {
2698
+ const resolveLookup = lookup ?? (await import("dns")).lookup;
2699
+ const { isIP } = await import("net");
2700
+ if (typeof resolveLookup === "function" && typeof isIP === "function") {
2701
+ screen = { lookup: resolveLookup, isIP, isExempt: (h) => exempt.has(String(h).toLowerCase()) };
2702
+ }
2703
+ } catch {
2704
+ screen = null;
2705
+ }
2426
2706
  }
2427
- if (nested && !named) {
2428
- return `condition carries nested rules but its parameter is ${JSON.stringify(condition.parameter)}; a group must be {"parameter":"${GROUP_PARAMETER}"}`;
2707
+ const skip = (reason, detail) => notify(onSkip, { phase: "egress", reason, detail }, "onSkip");
2708
+ const resolvesToDisallowed = (url, host, method) => new Promise((resolve) => {
2709
+ if (!screen) {
2710
+ if (host && dnsScreen) skip("resolver-unavailable", { host });
2711
+ return resolve(false);
2712
+ }
2713
+ if (!host || screen.isIP(host) !== 0 || screen.isExempt(host)) return resolve(false);
2714
+ try {
2715
+ screen.lookup(host, { all: true }, (err, addresses) => {
2716
+ if (err || !Array.isArray(addresses)) {
2717
+ skip("resolver-failed", { host });
2718
+ return resolve(false);
2719
+ }
2720
+ for (const a of addresses) {
2721
+ const ip = a && typeof a === "object" ? a.address : a;
2722
+ if (ip && block(url, ip, method)) return resolve(true);
2723
+ }
2724
+ resolve(false);
2725
+ });
2726
+ } catch {
2727
+ skip("resolver-failed", { host });
2728
+ resolve(false);
2729
+ }
2730
+ });
2731
+ const originalFetch = globalThis.fetch;
2732
+ if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
2733
+ const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
2734
+ const MAX_REDIRECTS = 20;
2735
+ const screenUrl = async (u, method) => {
2736
+ let host = null;
2737
+ try {
2738
+ host = new URL(u).hostname;
2739
+ } catch {
2740
+ host = null;
2741
+ }
2742
+ if (block(u, host, method) || await resolvesToDisallowed(u, host, method)) {
2743
+ throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? u}`);
2744
+ }
2745
+ };
2746
+ const guarded = async (input, init) => {
2747
+ let cur;
2748
+ try {
2749
+ cur = new Request(input, { ...init || {}, redirect: "manual" });
2750
+ } catch {
2751
+ return originalFetch(input, init);
2752
+ }
2753
+ const callerRedirect = init && init.redirect || input && input.redirect || "follow";
2754
+ let url = cur.url;
2755
+ let method = cur.method;
2756
+ await screenUrl(url, method);
2757
+ if (callerRedirect !== "follow") return originalFetch(input, init);
2758
+ const headers = new Headers(cur.headers);
2759
+ const signal = cur.signal;
2760
+ let body = method === "GET" || method === "HEAD" ? void 0 : await cur.clone().arrayBuffer();
2761
+ for (let hop = 0; ; hop++) {
2762
+ const resp = await originalFetch(
2763
+ hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
2764
+ );
2765
+ const location = REDIRECT_STATUSES.has(resp.status) ? resp.headers.get("location") : null;
2766
+ if (!location) return resp;
2767
+ if (hop >= MAX_REDIRECTS) throw new Error("Patchstack blocked an outbound request: too many redirects");
2768
+ const next = new URL(location, url).href;
2769
+ if (resp.status === 303 || (resp.status === 301 || resp.status === 302) && method === "POST") {
2770
+ method = "GET";
2771
+ body = void 0;
2772
+ headers.delete("content-type");
2773
+ headers.delete("content-length");
2774
+ }
2775
+ if (new URL(next).origin !== new URL(url).origin) {
2776
+ headers.delete("authorization");
2777
+ headers.delete("cookie");
2778
+ }
2779
+ await screenUrl(next, method);
2780
+ url = next;
2781
+ }
2782
+ };
2783
+ guarded.__patchstackGuarded = true;
2784
+ globalThis.fetch = guarded;
2785
+ restores.push(() => {
2786
+ if (globalThis.fetch === guarded) globalThis.fetch = originalFetch;
2787
+ });
2429
2788
  }
2430
- if (named && nested) {
2431
- if (condition.match !== void 0) return "a group carries no match of its own; the engine ignores it";
2432
- if (condition.mutations !== void 0) return "a group carries no mutations of its own; the engine ignores them";
2433
- if (condition.rules.length === 0) return "group has no conditions (would never match)";
2789
+ for (const moduleName of ["node:http", "node:https"]) {
2790
+ try {
2791
+ const mod = await import(moduleName);
2792
+ const restore = patchHttpModule(mod.default ?? mod, block, screen);
2793
+ if (restore) restores.push(restore);
2794
+ } catch {
2795
+ }
2434
2796
  }
2435
- return null;
2797
+ return () => {
2798
+ for (const restore of restores) {
2799
+ try {
2800
+ restore();
2801
+ } catch {
2802
+ }
2803
+ }
2804
+ };
2436
2805
  }
2437
- function parameterProblem(parameter) {
2438
- if (parameter === void 0 || parameter === null) return null;
2439
- if (Array.isArray(parameter)) {
2440
- if (parameter.length === 0) return "parameter list is empty";
2441
- for (const member of parameter) {
2442
- const problem = parameterProblem(member);
2443
- if (problem !== null) return problem;
2806
+ function patchHttpModule(http, block, screen) {
2807
+ if (!http || typeof http.request !== "function" || http.__patchstackGuarded) return null;
2808
+ const originalRequest = http.request;
2809
+ const originalGet = http.get;
2810
+ const wrap = (original) => function(...args) {
2811
+ const target = extractHttpTarget(args);
2812
+ if (target && block(target.url, target.host, target.method)) {
2813
+ throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host ?? target.url}`);
2444
2814
  }
2445
- return null;
2815
+ if (target && screen && target.host && screen.isIP(target.host) === 0 && !screen.isExempt(target.host)) {
2816
+ try {
2817
+ args = withScreeningLookup(args, target, block, screen.lookup);
2818
+ } catch {
2819
+ }
2820
+ }
2821
+ return original.apply(this, args);
2822
+ };
2823
+ const guardedRequest = wrap(originalRequest);
2824
+ http.request = guardedRequest;
2825
+ let guardedGet;
2826
+ if (typeof originalGet === "function") {
2827
+ guardedGet = wrap(originalGet);
2828
+ http.get = guardedGet;
2446
2829
  }
2447
- if (typeof parameter !== "string" || parameter === "") return "parameter must be a non-empty string";
2448
- if (parameter === GROUP_PARAMETER) {
2449
- return `"${GROUP_PARAMETER}" is the group source and is only valid as a whole group, not as a parameter`;
2830
+ http.__patchstackGuarded = true;
2831
+ return () => {
2832
+ if (http.request === guardedRequest) http.request = originalRequest;
2833
+ if (guardedGet && http.get === guardedGet) http.get = originalGet;
2834
+ delete http.__patchstackGuarded;
2835
+ };
2836
+ }
2837
+ function extractHttpTarget(args) {
2838
+ const first = args[0];
2839
+ try {
2840
+ if (typeof first === "string" || first instanceof URL) {
2841
+ const url = new URL(String(first));
2842
+ const opts = args.find((a) => a && typeof a === "object" && !(a instanceof URL));
2843
+ return { url: url.href, host: url.hostname, method: opts && opts.method || "GET" };
2844
+ }
2845
+ if (first && typeof first === "object") {
2846
+ const host = normalizeHost(first.hostname || first.host) || "localhost";
2847
+ const protocol = first.protocol || "http:";
2848
+ const port = first.port ? `:${first.port}` : "";
2849
+ const path = first.path || "/";
2850
+ return { url: `${protocol}//${host}${port}${path}`, host, method: first.method || "GET" };
2851
+ }
2852
+ } catch {
2450
2853
  }
2451
- const dot = parameter.indexOf(".");
2452
- const name = dot === -1 ? parameter : parameter.slice(0, dot);
2453
- const source = SOURCES[name];
2454
- if (source === void 0) return `unknown parameter source "${name}"`;
2455
- if (dot === -1) {
2456
- return source.keyed === true ? `source "${name}" needs a key, e.g. "${name}.fieldname"` : null;
2854
+ return null;
2855
+ }
2856
+ function normalizeHost(raw) {
2857
+ const host = String(raw || "").trim();
2858
+ const bracketed = /^\[([^\]]+)\]/.exec(host);
2859
+ if (bracketed) return bracketed[1];
2860
+ if ((host.match(/:/g) || []).length > 1) return host;
2861
+ return host.split(":")[0];
2862
+ }
2863
+ function screenResolved(addresses, target, block) {
2864
+ for (const a of addresses || []) {
2865
+ const ip = a && typeof a === "object" ? a.address : a;
2866
+ if (ip && block(target.url, ip, target.method)) return ip;
2457
2867
  }
2458
- if (source.keyed !== true) return `source "${name}" takes no key`;
2459
- const key = parameter.slice(dot + 1);
2460
- if (key === "") return `source "${name}" needs a key after the dot`;
2461
- return keyProblem(name, source, key);
2868
+ return null;
2462
2869
  }
2463
- function keyProblem(name, source, key) {
2464
- if (key.endsWith("*") && source.wildcard !== true) {
2465
- return `"${name}" does not fan out over a wildcard key`;
2870
+ function withScreeningLookup(args, target, block, lookup) {
2871
+ const screeningLookup = (hostname, options, callback) => {
2872
+ let opts = options;
2873
+ let cb = callback;
2874
+ if (typeof opts === "function") {
2875
+ cb = opts;
2876
+ opts = {};
2877
+ }
2878
+ if (!opts || typeof opts !== "object") opts = {};
2879
+ try {
2880
+ lookup(hostname, { ...opts, all: true }, (err, addresses) => {
2881
+ if (err) return cb(err);
2882
+ const list = Array.isArray(addresses) ? addresses : [];
2883
+ const blocked = screenResolved(list, target, block);
2884
+ if (blocked) {
2885
+ return cb(new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host} resolved to ${blocked}`));
2886
+ }
2887
+ if (opts.all) return cb(null, list);
2888
+ const first = list[0];
2889
+ if (!first) return cb(new Error(`Patchstack: could not resolve ${hostname}`));
2890
+ return cb(null, first.address, first.family);
2891
+ });
2892
+ } catch {
2893
+ try {
2894
+ lookup(hostname, opts, cb);
2895
+ } catch {
2896
+ cb(new Error(`Patchstack: lookup failed for ${hostname}`));
2897
+ }
2898
+ }
2899
+ };
2900
+ return injectLookupOption(args, screeningLookup);
2901
+ }
2902
+ function injectLookupOption(args, lookup) {
2903
+ const first = args[0];
2904
+ if (first && typeof first === "object" && !(first instanceof URL)) {
2905
+ return [{ ...first, lookup }, ...args.slice(1)];
2466
2906
  }
2467
- const bare = key.endsWith("*") ? key.slice(0, -1) : key;
2468
- if (source.keys === "any") return null;
2469
- if (Array.isArray(source.keys) && source.keys.includes(bare)) return null;
2470
- for (const prefix of source.key_prefixes ?? []) {
2471
- if (bare.startsWith(prefix) && bare.length > prefix.length) return null;
2907
+ const rest = args.slice(1);
2908
+ const optIdx = rest.findIndex((a) => a && typeof a === "object" && !(a instanceof URL));
2909
+ if (optIdx !== -1) {
2910
+ const next = [...rest];
2911
+ next[optIdx] = { ...rest[optIdx], lookup };
2912
+ return [first, ...next];
2472
2913
  }
2473
- const accepted = [
2474
- ...Array.isArray(source.keys) ? source.keys : [],
2475
- ...(source.key_prefixes ?? []).map((p) => `${p}\u2026`)
2476
- ].join(", ");
2477
- return `"${name}" has no key "${key}" (expected one of: ${accepted})`;
2914
+ const cbIdx = rest.findIndex((a) => typeof a === "function");
2915
+ if (cbIdx === -1) return [first, { lookup }, ...rest];
2916
+ return [first, ...rest.slice(0, cbIdx), { lookup }, ...rest.slice(cbIdx)];
2478
2917
  }
2479
- function matchProblem(match, parameter) {
2480
- if (!match || typeof match !== "object") return "condition has no match object";
2481
- if (typeof match.type !== "string" || match.type === "") return "match.type must be a non-empty string";
2482
- if (!MATCH_TYPES.includes(match.type)) return `unknown match type "${match.type}"`;
2483
- const parameterless = PARAMETERLESS_MATCH_TYPES.includes(match.type);
2484
- if (!parameterless && (parameter === void 0 || parameter === null)) {
2485
- return `match type "${match.type}" needs a parameter`;
2918
+
2919
+ // src/protect/defaults.js
2920
+ var DEFAULT_RESPONSE_RULES = [
2921
+ {
2922
+ id: "resp-private-key",
2923
+ title: "Private key in response body",
2924
+ phase: "response",
2925
+ category: "secret-exposure",
2926
+ action: "redact",
2927
+ prefilter: ["PRIVATE KEY"],
2928
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/" } }]
2929
+ },
2930
+ {
2931
+ id: "resp-aws-access-key",
2932
+ title: "AWS access key id in response body",
2933
+ phase: "response",
2934
+ category: "secret-exposure",
2935
+ action: "redact",
2936
+ prefilter: ["AKIA", "ASIA"],
2937
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b/" } }]
2938
+ },
2939
+ {
2940
+ id: "resp-gcp-api-key",
2941
+ title: "Google API key in response body",
2942
+ phase: "response",
2943
+ category: "secret-exposure",
2944
+ action: "redact",
2945
+ prefilter: ["AIza"],
2946
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\bAIza[0-9A-Za-z_-]{35}\\b/" } }]
2947
+ },
2948
+ {
2949
+ id: "resp-vendor-api-key",
2950
+ title: "Vendor API key / token in response body",
2951
+ phase: "response",
2952
+ category: "secret-exposure",
2953
+ action: "redact",
2954
+ // High-signal, prefix-anchored provider tokens that never legitimately appear in a response
2955
+ // body: Stripe (sk_live_/rk_live_), GitHub (gh[opsu]_ / github_pat_), GitLab (glpat-),
2956
+ // Slack (xox[baprs]-), Anthropic (sk-ant-), Google OAuth (ya29.), npm (npm_). Trailing
2957
+ // (?![0-9A-Za-z]) instead of \\b since some tokens end in - / _ .
2958
+ prefilter: ["sk_live_", "rk_live_", "ghp_", "gho_", "ghs_", "ghu_", "github_pat_", "glpat-", "xox", "sk-ant-", "ya29.", "npm_"],
2959
+ 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])/" } }]
2960
+ },
2961
+ {
2962
+ id: "resp-supabase-secret-key",
2963
+ title: "Supabase secret key in response body",
2964
+ phase: "response",
2965
+ category: "secret-exposure",
2966
+ action: "redact",
2967
+ // Supabase's opaque key format, which is not a JWT and so is not covered by the JWT rule below.
2968
+ // `sb_secret_` is the elevated one — documented as full access, bypassing Row Level Security,
2969
+ // backend-only — while `sb_publishable_` is meant for public clients and must pass untouched.
2970
+ // Both key systems are live at once: new keys sit alongside the legacy `anon` / `service_role`
2971
+ // JWTs rather than replacing them, so this rule and the JWT rule cover different halves.
2972
+ //
2973
+ // The documented grammar is the prefix, 22 base64url characters, `_`, then an 8-character
2974
+ // base64url checksum. Matching it exactly is what keeps the mask on the key and off the response
2975
+ // around it: a variable-length suffix would either run past the key into adjacent syntax or stop
2976
+ // short and leave the tail of a real key visible. The trailing guard rejects a longer run of key
2977
+ // characters, which is not this format.
2978
+ prefilter: ["sb_secret_"],
2979
+ 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_-])/" } }]
2980
+ },
2981
+ {
2982
+ id: "resp-supabase-service-role-key",
2983
+ title: "Supabase service_role key in response body",
2984
+ phase: "response",
2985
+ category: "secret-exposure",
2986
+ action: "redact",
2987
+ // Scoped to the one role that must never leave: `service_role` bypasses Row Level Security
2988
+ // entirely. A JWT in a response body is not inherently a leak — the Supabase `anon` key is public
2989
+ // by design and a login response carries the user's own access token — so this decides on the
2990
+ // decoded payload rather than on the shape of the token.
2991
+ //
2992
+ // `jwt_claim_equals` also yields the matching token spans, which is what lets `redact` mask those
2993
+ // tokens and serve the rest of the response.
2994
+ //
2995
+ // A broad any-JWT rule is a reasonable thing to want; it belongs in the managed hardening bundle as
2996
+ // an opt-in, observe-only rule rather than an on-by-default mask.
2997
+ prefilter: ["eyJ"],
2998
+ rule_v2: [{ parameter: "response.body", match: { type: "jwt_claim_equals", claim: "role", value: "service_role" } }]
2999
+ },
3000
+ {
3001
+ id: "resp-db-connection-string",
3002
+ title: "Database connection string with credentials in response body",
3003
+ phase: "response",
3004
+ category: "secret-exposure",
3005
+ action: "redact",
3006
+ prefilter: ["mongodb", "postgres", "mysql", "redis", "amqp"],
3007
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[^\\s:@\\/]+:[^\\s:@\\/]+@/i" } }]
3008
+ },
3009
+ {
3010
+ id: "resp-stack-trace",
3011
+ title: "Node stack trace leaking in response body",
3012
+ phase: "response",
3013
+ category: "info-exposure",
3014
+ action: "redact",
3015
+ // No prefilter: a Node stack frame has no single distinctive literal (` at ` is too common to
3016
+ // gate on). The pattern is linearly bounded per line, so it runs on every screened body.
3017
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\n\\s+at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
3018
+ },
3019
+ {
3020
+ id: "resp-sql-error",
3021
+ title: "SQL / ORM error disclosure in response body",
3022
+ phase: "response",
3023
+ category: "info-exposure",
3024
+ action: "redact",
3025
+ prefilter: ["SQLSTATE", "Sequelize", "ER_", "ORA-", "PG::", "SQLITE_ERROR", "SQL syntax"],
3026
+ 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" } }]
3027
+ },
3028
+ {
3029
+ id: "resp-exception-trace",
3030
+ title: "Backend exception / stack trace disclosure in response body",
3031
+ phase: "response",
3032
+ category: "info-exposure",
3033
+ action: "redact",
3034
+ // Multi-language exception/traceback signatures a normal API response never carries:
3035
+ // Python traceback, Java "Exception in thread", .NET System.*Exception, JVM stack frames,
3036
+ // Go goroutine dumps. (Node `at fn (file:line:col)` frames are handled by resp-stack-trace.)
3037
+ prefilter: ["Traceback", "Exception in thread", "System.", "goroutine ", ".java:", ".kt:", ".scala:", ".rb:", ".py:", ".cs:"],
3038
+ 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+ \\[)/' } }]
2486
3039
  }
2487
- if (parameterless && parameter !== void 0 && parameter !== null) {
2488
- return `match type "${match.type}" reads the whole request and takes no parameter`;
3040
+ ];
3041
+ var DEFAULT_EGRESS_RULES = [
3042
+ {
3043
+ id: "egress-internal-address",
3044
+ title: "Outbound request to an internal / metadata address (SSRF)",
3045
+ phase: "egress",
3046
+ category: "ssrf",
3047
+ rule_v2: [{ parameter: "egress.host", match: { type: "internal_host" } }]
2489
3048
  }
2490
- const operands = MATCH_OPERANDS[match.type];
2491
- if (operands) {
2492
- for (const required of operands.required) {
2493
- if (match[required] === void 0 || match[required] === null) {
2494
- return `match type "${match.type}" needs "${required}"`;
2495
- }
3049
+ ];
3050
+
3051
+ // src/protect/block-page.js
3052
+ function escapeHtml(s) {
3053
+ return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
3054
+ }
3055
+ function renderBlockPage({ url = "", code = "patchstack" } = {}) {
3056
+ const safeUrl = escapeHtml(url);
3057
+ const safeCode = escapeHtml(code || "patchstack");
3058
+ return `<!DOCTYPE html>
3059
+ <html lang="en">
3060
+ <head>
3061
+ <meta charset="UTF-8">
3062
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
3063
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
3064
+ <style type="text/css">
3065
+ .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}}
3066
+ </style>
3067
+ <title>Access Denied</title>
3068
+ </head>
3069
+ <body>
3070
+ <div class="main-container">
3071
+ <div class="container">
3072
+ <div class="content">
3073
+ <span class="access-denied">Access Denied</span>
3074
+ <div class="site-url">
3075
+ <input id="url" class="site-url-text" type="text"
3076
+ value="${safeUrl}" readonly>
3077
+ <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')">
3078
+ </div>
3079
+ <div class="site-url">
3080
+ <input id="response" class="reason-code-text" type="text" value="${safeCode}" readonly>
3081
+ <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')">
3082
+ </div>
3083
+ <div class="descp">
3084
+ <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>
3085
+ </div>
3086
+ </div>
3087
+ </div>
3088
+ <div class="bottom-container">
3089
+ <div class="bottom-text-containers">
3090
+ <img class="arrow-vector" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOCIgaGVpZ2h0PSIxMyIgdmlld0JveD0iMCAwIDggMTMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik03LjQxIDEuNDMzNDRMNiAwLjAyMzQzNzVMMCA2LjAyMzQ0TDYgMTIuMDIzNEw3LjQxIDEwLjYxMzRMMi44MyA2LjAyMzQ0TDcuNDEgMS40MzM0NFoiIGZpbGw9IiNBRkU2MTQiLz4KPC9zdmc+Cg==" alt="Arrow Left">
3091
+ <a href="/" class="return-text">Return to homepage</a>
3092
+ </div>
3093
+ <div class="bottom-second-containers">
3094
+ <span class="secured-text">This website is secured by</span>
3095
+ <a href="https://patchstack.com" class="logoText">
3096
+ <img class="patch-logo" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTg0IiBoZWlnaHQ9IjIyIiB2aWV3Qm94PSIwIDAgMTg0IDIyIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTUwLjQwMDEgMTEuNTAwOEM1MC40MDAxIDkuMTAwNzggNDkuMzAwMSA4LjEwMDc4IDQ2LjYwMDEgOC4xMDA3OEM0NC4wMDAxIDguMTAwNzggNDIuNzAwMSA5LjIwMDc4IDQyLjcwMDEgMTEuNTAwOEM0Mi43MDAxIDEzLjgwMDggNDQuMDAwMSAxNC45MDA4IDQ2LjYwMDEgMTQuOTAwOEM0OS4zMDAxIDE0LjkwMDggNTAuNDAwMSAxMy44MDA4IDUwLjQwMDEgMTEuNTAwOFpNNTMuNDAwMSAxMS41MDA4QzUzLjQwMDEgMTUuMzAwOCA1MS4zMDAxIDE3LjYwMDggNDcuNDAwMSAxNy42MDA4QzQ1LjQwMDEgMTcuNjAwOCA0My42MDAxIDE2LjUwMDggNDIuNzAwMSAxNC41MDA4VjIxLjUwMDhIMzkuNjAwMVY1LjYwMDc4SDQyLjcwMDFWOC41MDA3OEM0My42MDAxIDYuNTAwNzggNDUuNDAwMSA1LjMwMDc4IDQ3LjUwMDEgNS4zMDA3OEM1MS40MDAxIDUuMzAwNzggNTMuNDAwMSA3LjcwMDc4IDUzLjQwMDEgMTEuNTAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNjYuNDAwMSAxMS40OTg0QzY2LjQwMDEgOS4xOTg0NCA2NS4xMDAxIDguMDk4NDQgNjIuNTAwMSA4LjA5ODQ0QzU5LjgwMDEgOC4wOTg0NCA1OC43MDAxIDkuMDk4NDQgNTguNzAwMSAxMS40OTg0QzU4LjcwMDEgMTMuNzk4NCA1OS44MDAxIDE0Ljg5ODQgNjIuNTAwMSAxNC44OTg0QzY1LjEwMDEgMTQuODk4NCA2Ni40MDAxIDEzLjc5ODQgNjYuNDAwMSAxMS40OTg0Wk02Ni4zMDAxIDUuNTk4NDRINjkuNDAwMVYxNy4zOTg0SDY2LjMwMDFWMTQuNDk4NEM2NS40MDAxIDE2LjQ5ODQgNjMuNzAwMSAxNy42OTg0IDYxLjYwMDEgMTcuNjk4NEM1Ny43MDAxIDE3LjY5ODQgNTUuNjAwMSAxNS4yOTg0IDU1LjYwMDEgMTEuNTk4NEM1NS42MDAxIDcuNzk4NDQgNTcuNjAwMSA1LjM5ODQ0IDYxLjQwMDEgNS4zOTg0NEM2My41MDAxIDUuMzk4NDQgNjUuNDAwMSA2LjU5ODQ0IDY2LjIwMDEgOC41OTg0NFY1LjU5ODQ0SDY2LjMwMDFaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTgxLjM5OTkgOC4zSDc3LjM5OTlWMTQuN0g4MS4wOTk5VjE3LjRINzcuOTk5OUM3NS43OTk5IDE3LjQgNzQuMjk5OSAxNS45IDc0LjI5OTkgMTMuN1Y4LjNINzEuMzk5OVY1LjZINzQuMjk5OVYySDc3LjM5OTlWNS41SDgxLjM5OTlWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik05My42OTk5IDEyLjIwMDhMOTYuNjk5OSAxMi45MDA4Qzk1Ljk5OTkgMTYuMDAwOCA5My42OTk5IDE3LjYwMDggODkuOTk5OSAxNy42MDA4Qzg3Ljc5OTkgMTcuNjAwOCA4Ni4wOTk5IDE3LjAwMDggODQuNzk5OSAxNS45MDA4QzgzLjQ5OTkgMTQuODAwOCA4Mi44OTk5IDEzLjMwMDggODIuODk5OSAxMS41MDA4QzgyLjg5OTkgOS42MDA3OCA4My40OTk5IDguMTAwNzggODQuNzk5OSA3LjAwMDc4Qzg2LjA5OTkgNS45MDA3OCA4Ny43OTk5IDUuMzAwNzggODkuOTk5OSA1LjMwMDc4QzkzLjY5OTkgNS4zMDA3OCA5NS45OTk5IDYuOTAwNzggOTYuNjk5OSAxMC4wMDA4TDkzLjY5OTkgMTAuNzAwOEM5My4yOTk5IDguODAwNzggOTIuMjk5OSA4LjEwMDc4IDg5Ljg5OTkgOC4xMDA3OEM4Ni45OTk5IDguMTAwNzggODUuODk5OSA5LjIwMDc4IDg1Ljg5OTkgMTEuNTAwOEM4NS44OTk5IDEzLjgwMDggODYuOTk5OSAxNC45MDA4IDg5Ljg5OTkgMTQuOTAwOEM5Mi4yOTk5IDE0LjkwMDggOTMuMjk5OSAxNC4yMDA4IDkzLjY5OTkgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTExLjggMTAuNlYxNy40SDEwOC43VjExLjJDMTA4LjcgOSAxMDcuOCA4LjEgMTA1LjkgOC4xQzEwMy42IDguMSAxMDIuNCA5LjMgMTAyLjQgMTEuN1YxNy4zSDk5LjI5OThWMC41SDEwMi40VjguM0MxMDMuMSA2LjMgMTA0LjYgNS4zIDEwNyA1LjNDMTEwLjIgNS4zIDExMS44IDcuMSAxMTEuOCAxMC42WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMTQuMSAxNC4wOTg0TDExNy4xIDEzLjI5ODRDMTE3LjQgMTQuNzk4NCAxMTguMyAxNS4yOTg0IDEyMC4yIDE1LjI5ODRDMTIxLjggMTUuMjk4NCAxMjIuNyAxNC44OTg0IDEyMi43IDE0LjA5ODRDMTIyLjcgMTMuMjk4NCAxMjIuMiAxMi45OTg0IDEyMC42IDEyLjc5ODRMMTE4LjIgMTIuNDk4NEMxMTUuOSAxMi4xOTg0IDExNC41IDEwLjg5ODQgMTE0LjUgOC45OTg0NEMxMTQuNSA2Ljc5ODQ0IDExNi41IDUuMzk4NDQgMTE5LjggNS4zOTg0NEMxMjMuMSA1LjM5ODQ0IDEyNSA2LjY5ODQ0IDEyNS41IDguODk4NDRMMTIyLjUgOS41OTg0NEMxMjIuMyA4LjA5ODQ0IDEyMS41IDcuNjk4NDQgMTE5LjkgNy42OTg0NEMxMTguNCA3LjY5ODQ0IDExNy42IDguMDk4NDQgMTE3LjYgOC44OTg0NEMxMTcuNiA5LjU5ODQ0IDExOC4xIDkuOTk4NDQgMTE5LjMgMTAuMDk4NEwxMjIuMiAxMC40OTg0QzEyNC40IDEwLjc5ODQgMTI1LjggMTEuOTk4NCAxMjUuOCAxMy44OTg0QzEyNS44IDE2LjI5ODQgMTIzLjcgMTcuNzk4NCAxMjAuMSAxNy43OTg0QzExNi43IDE3LjU5ODQgMTE0LjUgMTYuMTk4NCAxMTQuMSAxNC4wOTg0WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMzYuNyA4LjNIMTMyLjdWMTQuN0gxMzYuNFYxNy40SDEzMy4zQzEzMS4xIDE3LjQgMTI5LjYgMTUuOSAxMjkuNiAxMy43VjguM0gxMjYuN1Y1LjZIMTI5LjZWMkgxMzIuN1Y1LjVIMTM2LjdWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xNDkuMiAxMS40OTg0QzE0OS4yIDkuMTk4NDQgMTQ3LjkgOC4wOTg0NCAxNDUuMyA4LjA5ODQ0QzE0Mi42IDguMDk4NDQgMTQxLjUgOS4wOTg0NCAxNDEuNSAxMS40OTg0QzE0MS41IDEzLjc5ODQgMTQyLjYgMTQuODk4NCAxNDUuMyAxNC44OTg0QzE0Ny45IDE0Ljg5ODQgMTQ5LjIgMTMuNzk4NCAxNDkuMiAxMS40OTg0Wk0xNDkuMSA1LjU5ODQ0SDE1Mi4yVjE3LjM5ODRIMTQ5LjFWMTQuNDk4NEMxNDguMiAxNi40OTg0IDE0Ni41IDE3LjY5ODQgMTQ0LjQgMTcuNjk4NEMxNDAuNSAxNy42OTg0IDEzOC40IDE1LjI5ODQgMTM4LjQgMTEuNTk4NEMxMzguNCA3Ljc5ODQ0IDE0MC40IDUuMzk4NDQgMTQ0LjIgNS4zOTg0NEMxNDYuMyA1LjM5ODQ0IDE0OC4yIDYuNTk4NDQgMTQ5IDguNTk4NDRWNS41OTg0NEgxNDkuMVoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTY1LjYgMTIuMjAwOEwxNjguNiAxMi45MDA4QzE2Ny45IDE2LjAwMDggMTY1LjYgMTcuNjAwOCAxNjEuOSAxNy42MDA4QzE1OS43IDE3LjYwMDggMTU4IDE3LjAwMDggMTU2LjcgMTUuOTAwOEMxNTUuNCAxNC44MDA4IDE1NC44IDEzLjMwMDggMTU0LjggMTEuNTAwOEMxNTQuOCA5LjYwMDc4IDE1NS40IDguMTAwNzggMTU2LjcgNy4wMDA3OEMxNTggNS45MDA3OCAxNTkuNyA1LjMwMDc4IDE2MS45IDUuMzAwNzhDMTY1LjYgNS4zMDA3OCAxNjcuOSA2LjkwMDc4IDE2OC42IDEwLjAwMDhMMTY1LjYgMTAuNzAwOEMxNjUuMiA4LjgwMDc4IDE2NC4yIDguMTAwNzggMTYxLjggOC4xMDA3OEMxNTguOSA4LjEwMDc4IDE1Ny44IDkuMjAwNzggMTU3LjggMTEuNTAwOEMxNTcuOCAxMy44MDA4IDE1OC45IDE0LjkwMDggMTYxLjggMTQuOTAwOEMxNjQuMSAxNC45MDA4IDE2NS4xIDE0LjIwMDggMTY1LjYgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTgwLjI1NyAxNy40TDE3Ni42NTcgMTIuM0wxNzQuMTU3IDE0LjZWMTcuNEgxNzEuMTU3VjAuNUgxNzQuMTU3VjExLjFMMTc5Ljg1NyA1LjZIMTgzLjc1N0wxNzguNTU3IDEwLjVMMTgzLjk1NyAxNy40SDE4MC4yNTdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTAgMjEuNTAwOEg3LjNWMTYuMzAwOEgwVjIxLjUwMDhaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNy4yOTk4IDIxLjUwMDhIMTQuNTk5OFYxNi4zMDA4SDcuMjk5OFYyMS41MDA4WiIgZmlsbD0iIzZFQkUwMCIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTE0LjYwMDEgMTYuM0gyOS4yMDAxVjAuNUgxNC42MDAxVjE2LjNaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMCAxMUgxNC42VjAuNUgwVjExWiIgZmlsbD0iIzZFQkUwMCIvPgo8L3N2Zz4K" alt="Patchstack Logo">
3097
+ </a>
3098
+ </div>
3099
+ </div>
3100
+
3101
+ <div class="mobile-bottom-container">
3102
+ <div class="mobile-bottom-text-containers">
3103
+ <img class="arrow-vector" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOCIgaGVpZ2h0PSIxMyIgdmlld0JveD0iMCAwIDggMTMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik03LjQxIDEuNDMzNDRMNiAwLjAyMzQzNzVMMCA2LjAyMzQ0TDYgMTIuMDIzNEw3LjQxIDEwLjYxMzRMMi44MyA2LjAyMzQ0TDcuNDEgMS40MzM0NFoiIGZpbGw9IiNBRkU2MTQiLz4KPC9zdmc+Cg==" alt="Arrow Left">
3104
+ <a href="/" class="return-text">Return to homepage</a>
3105
+ </div>
3106
+ <div class="mobile-bottom-second-containers">
3107
+ <span class="secured-text">This website is secured by</span>
3108
+ <a href="https://patchstack.com" class="logoText">
3109
+ <img class="patch-logo" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTg0IiBoZWlnaHQ9IjIyIiB2aWV3Qm94PSIwIDAgMTg0IDIyIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTUwLjQwMDEgMTEuNTAwOEM1MC40MDAxIDkuMTAwNzggNDkuMzAwMSA4LjEwMDc4IDQ2LjYwMDEgOC4xMDA3OEM0NC4wMDAxIDguMTAwNzggNDIuNzAwMSA5LjIwMDc4IDQyLjcwMDEgMTEuNTAwOEM0Mi43MDAxIDEzLjgwMDggNDQuMDAwMSAxNC45MDA4IDQ2LjYwMDEgMTQuOTAwOEM0OS4zMDAxIDE0LjkwMDggNTAuNDAwMSAxMy44MDA4IDUwLjQwMDEgMTEuNTAwOFpNNTMuNDAwMSAxMS41MDA4QzUzLjQwMDEgMTUuMzAwOCA1MS4zMDAxIDE3LjYwMDggNDcuNDAwMSAxNy42MDA4QzQ1LjQwMDEgMTcuNjAwOCA0My42MDAxIDE2LjUwMDggNDIuNzAwMSAxNC41MDA4VjIxLjUwMDhIMzkuNjAwMVY1LjYwMDc4SDQyLjcwMDFWOC41MDA3OEM0My42MDAxIDYuNTAwNzggNDUuNDAwMSA1LjMwMDc4IDQ3LjUwMDEgNS4zMDA3OEM1MS40MDAxIDUuMzAwNzggNTMuNDAwMSA3LjcwMDc4IDUzLjQwMDEgMTEuNTAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNjYuNDAwMSAxMS40OTg0QzY2LjQwMDEgOS4xOTg0NCA2NS4xMDAxIDguMDk4NDQgNjIuNTAwMSA4LjA5ODQ0QzU5LjgwMDEgOC4wOTg0NCA1OC43MDAxIDkuMDk4NDQgNTguNzAwMSAxMS40OTg0QzU4LjcwMDEgMTMuNzk4NCA1OS44MDAxIDE0Ljg5ODQgNjIuNTAwMSAxNC44OTg0QzY1LjEwMDEgMTQuODk4NCA2Ni40MDAxIDEzLjc5ODQgNjYuNDAwMSAxMS40OTg0Wk02Ni4zMDAxIDUuNTk4NDRINjkuNDAwMVYxNy4zOTg0SDY2LjMwMDFWMTQuNDk4NEM2NS40MDAxIDE2LjQ5ODQgNjMuNzAwMSAxNy42OTg0IDYxLjYwMDEgMTcuNjk4NEM1Ny43MDAxIDE3LjY5ODQgNTUuNjAwMSAxNS4yOTg0IDU1LjYwMDEgMTEuNTk4NEM1NS42MDAxIDcuNzk4NDQgNTcuNjAwMSA1LjM5ODQ0IDYxLjQwMDEgNS4zOTg0NEM2My41MDAxIDUuMzk4NDQgNjUuNDAwMSA2LjU5ODQ0IDY2LjIwMDEgOC41OTg0NFY1LjU5ODQ0SDY2LjMwMDFaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTgxLjM5OTkgOC4zSDc3LjM5OTlWMTQuN0g4MS4wOTk5VjE3LjRINzcuOTk5OUM3NS43OTk5IDE3LjQgNzQuMjk5OSAxNS45IDc0LjI5OTkgMTMuN1Y4LjNINzEuMzk5OVY1LjZINzQuMjk5OVYySDc3LjM5OTlWNS41SDgxLjM5OTlWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik05My42OTk5IDEyLjIwMDhMOTYuNjk5OSAxMi45MDA4Qzk1Ljk5OTkgMTYuMDAwOCA5My42OTk5IDE3LjYwMDggODkuOTk5OSAxNy42MDA4Qzg3Ljc5OTkgMTcuNjAwOCA4Ni4wOTk5IDE3LjAwMDggODQuNzk5OSAxNS45MDA4QzgzLjQ5OTkgMTQuODAwOCA4Mi44OTk5IDEzLjMwMDggODIuODk5OSAxMS41MDA4QzgyLjg5OTkgOS42MDA3OCA4My40OTk5IDguMTAwNzggODQuNzk5OSA3LjAwMDc4Qzg2LjA5OTkgNS45MDA3OCA4Ny43OTk5IDUuMzAwNzggODkuOTk5OSA1LjMwMDc4QzkzLjY5OTkgNS4zMDA3OCA5NS45OTk5IDYuOTAwNzggOTYuNjk5OSAxMC4wMDA4TDkzLjY5OTkgMTAuNzAwOEM5My4yOTk5IDguODAwNzggOTIuMjk5OSA4LjEwMDc4IDg5Ljg5OTkgOC4xMDA3OEM4Ni45OTk5IDguMTAwNzggODUuODk5OSA5LjIwMDc4IDg1Ljg5OTkgMTEuNTAwOEM4NS44OTk5IDEzLjgwMDggODYuOTk5OSAxNC45MDA4IDg5Ljg5OTkgMTQuOTAwOEM5Mi4yOTk5IDE0LjkwMDggOTMuMjk5OSAxNC4yMDA4IDkzLjY5OTkgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTExLjggMTAuNlYxNy40SDEwOC43VjExLjJDMTA4LjcgOSAxMDcuOCA4LjEgMTA1LjkgOC4xQzEwMy42IDguMSAxMDIuNCA5LjMgMTAyLjQgMTEuN1YxNy4zSDk5LjI5OThWMC41SDEwMi40VjguM0MxMDMuMSA2LjMgMTA0LjYgNS4zIDEwNyA1LjNDMTEwLjIgNS4zIDExMS44IDcuMSAxMTEuOCAxMC42WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMTQuMSAxNC4wOTg0TDExNy4xIDEzLjI5ODRDMTE3LjQgMTQuNzk4NCAxMTguMyAxNS4yOTg0IDEyMC4yIDE1LjI5ODRDMTIxLjggMTUuMjk4NCAxMjIuNyAxNC44OTg0IDEyMi43IDE0LjA5ODRDMTIyLjcgMTMuMjk4NCAxMjIuMiAxMi45OTg0IDEyMC42IDEyLjc5ODRMMTE4LjIgMTIuNDk4NEMxMTUuOSAxMi4xOTg0IDExNC41IDEwLjg5ODQgMTE0LjUgOC45OTg0NEMxMTQuNSA2Ljc5ODQ0IDExNi41IDUuMzk4NDQgMTE5LjggNS4zOTg0NEMxMjMuMSA1LjM5ODQ0IDEyNSA2LjY5ODQ0IDEyNS41IDguODk4NDRMMTIyLjUgOS41OTg0NEMxMjIuMyA4LjA5ODQ0IDEyMS41IDcuNjk4NDQgMTE5LjkgNy42OTg0NEMxMTguNCA3LjY5ODQ0IDExNy42IDguMDk4NDQgMTE3LjYgOC44OTg0NEMxMTcuNiA5LjU5ODQ0IDExOC4xIDkuOTk4NDQgMTE5LjMgMTAuMDk4NEwxMjIuMiAxMC40OTg0QzEyNC40IDEwLjc5ODQgMTI1LjggMTEuOTk4NCAxMjUuOCAxMy44OTg0QzEyNS44IDE2LjI5ODQgMTIzLjcgMTcuNzk4NCAxMjAuMSAxNy43OTg0QzExNi43IDE3LjU5ODQgMTE0LjUgMTYuMTk4NCAxMTQuMSAxNC4wOTg0WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMzYuNyA4LjNIMTMyLjdWMTQuN0gxMzYuNFYxNy40SDEzMy4zQzEzMS4xIDE3LjQgMTI5LjYgMTUuOSAxMjkuNiAxMy43VjguM0gxMjYuN1Y1LjZIMTI5LjZWMkgxMzIuN1Y1LjVIMTM2LjdWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xNDkuMiAxMS40OTg0QzE0OS4yIDkuMTk4NDQgMTQ3LjkgOC4wOTg0NCAxNDUuMyA4LjA5ODQ0QzE0Mi42IDguMDk4NDQgMTQxLjUgOS4wOTg0NCAxNDEuNSAxMS40OTg0QzE0MS41IDEzLjc5ODQgMTQyLjYgMTQuODk4NCAxNDUuMyAxNC44OTg0QzE0Ny45IDE0Ljg5ODQgMTQ5LjIgMTMuNzk4NCAxNDkuMiAxMS40OTg0Wk0xNDkuMSA1LjU5ODQ0SDE1Mi4yVjE3LjM5ODRIMTQ5LjFWMTQuNDk4NEMxNDguMiAxNi40OTg0IDE0Ni41IDE3LjY5ODQgMTQ0LjQgMTcuNjk4NEMxNDAuNSAxNy42OTg0IDEzOC40IDE1LjI5ODQgMTM4LjQgMTEuNTk4NEMxMzguNCA3Ljc5ODQ0IDE0MC40IDUuMzk4NDQgMTQ0LjIgNS4zOTg0NEMxNDYuMyA1LjM5ODQ0IDE0OC4yIDYuNTk4NDQgMTQ5IDguNTk4NDRWNS41OTg0NEgxNDkuMVoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTY1LjYgMTIuMjAwOEwxNjguNiAxMi45MDA4QzE2Ny45IDE2LjAwMDggMTY1LjYgMTcuNjAwOCAxNjEuOSAxNy42MDA4QzE1OS43IDE3LjYwMDggMTU4IDE3LjAwMDggMTU2LjcgMTUuOTAwOEMxNTUuNCAxNC44MDA4IDE1NC44IDEzLjMwMDggMTU0LjggMTEuNTAwOEMxNTQuOCA5LjYwMDc4IDE1NS40IDguMTAwNzggMTU2LjcgNy4wMDA3OEMxNTggNS45MDA3OCAxNTkuNyA1LjMwMDc4IDE2MS45IDUuMzAwNzhDMTY1LjYgNS4zMDA3OCAxNjcuOSA2LjkwMDc4IDE2OC42IDEwLjAwMDhMMTY1LjYgMTAuNzAwOEMxNjUuMiA4LjgwMDc4IDE2NC4yIDguMTAwNzggMTYxLjggOC4xMDA3OEMxNTguOSA4LjEwMDc4IDE1Ny44IDkuMjAwNzggMTU3LjggMTEuNTAwOEMxNTcuOCAxMy44MDA4IDE1OC45IDE0LjkwMDggMTYxLjggMTQuOTAwOEMxNjQuMSAxNC45MDA4IDE2NS4xIDE0LjIwMDggMTY1LjYgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTgwLjI1NyAxNy40TDE3Ni42NTcgMTIuM0wxNzQuMTU3IDE0LjZWMTcuNEgxNzEuMTU3VjAuNUgxNzQuMTU3VjExLjFMMTc5Ljg1NyA1LjZIMTgzLjc1N0wxNzguNTU3IDEwLjVMMTgzLjk1NyAxNy40SDE4MC4yNTdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTAgMjEuNTAwOEg3LjNWMTYuMzAwOEgwVjIxLjUwMDhaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNy4yOTk4IDIxLjUwMDhIMTQuNTk5OFYxNi4zMDA4SDcuMjk5OFYyMS41MDA4WiIgZmlsbD0iIzZFQkUwMCIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTE0LjYwMDEgMTYuM0gyOS4yMDAxVjAuNUgxNC42MDAxVjE2LjNaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMCAxMUgxNC42VjAuNUgwVjExWiIgZmlsbD0iIzZFQkUwMCIvPgo8L3N2Zz4K" alt="Patchstack Logo">
3110
+ </a>
3111
+ </div>
3112
+ </div>
3113
+ </div>
3114
+ </body>
3115
+ <script>
3116
+ function copyText(id) {
3117
+ var copyText = document.getElementById(id)
3118
+ copyText.select();
3119
+ copyText.setSelectionRange(0, 99999);
3120
+ navigator.clipboard.writeText(copyText.value);
2496
3121
  }
2497
- for (const [operand, shape] of Object.entries(operands.shapes ?? {})) {
2498
- if (match[operand] === void 0) continue;
2499
- const problem = operandShapeProblem(shape, match[operand]);
2500
- if (problem) return `match type "${match.type}" operand "${operand}" ${problem}`;
3122
+
3123
+ function hover(element) {
3124
+ element.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkiIGhlaWdodD0iMjMiIHZpZXdCb3g9IjAgMCAxOSAyMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0IDAuMDIzNDM3NUgwVjE2LjAyMzRIMlYyLjAyMzQ0SDE0VjAuMDIzNDM3NVpNMTkgNC4wMjM0NEg0VjIyLjAyMzRIMTlWNC4wMjM0NFpNMTcgMjAuMDIzNEg2VjYuMDIzNDRIMTdWMjAuMDIzNFoiIGZpbGw9IiNBRkU2MTQiLz4KPC9zdmc+Cg==');
2501
3125
  }
2502
- } else if (!OPERANDLESS_MATCH_TYPES.includes(match.type) && match.value === void 0) {
2503
- return `match type "${match.type}" needs a value`;
2504
- }
2505
- return null;
2506
- }
2507
- function mutationsProblem(mutations) {
2508
- if (mutations === void 0 || mutations === null) return null;
2509
- if (!Array.isArray(mutations)) return "mutations must be an array";
2510
- for (const mutation of mutations) {
2511
- if (typeof mutation !== "string" || !MUTATIONS.includes(mutation)) {
2512
- return `unknown mutation "${String(mutation)}" \u2014 applied as a no-op, so the condition would test the untransformed value`;
3126
+
3127
+ function unhover(element) {
3128
+ element.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkiIGhlaWdodD0iMjMiIHZpZXdCb3g9IjAgMCAxOSAyMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0IDAuMDIzNDM3NUgwVjE2LjAyMzRIMlYyLjAyMzQ0SDE0VjAuMDIzNDM3NVpNMTkgNC4wMjM0NEg0VjIyLjAyMzRIMTlWNC4wMjM0NFpNMTcgMjAuMDIzNEg2VjYuMDIzNDRIMTdWMjAuMDIzNFoiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=');
2513
3129
  }
2514
- }
2515
- return null;
3130
+ </script>
3131
+ </html>`;
2516
3132
  }
2517
- function whenProblem(when) {
2518
- if (when === void 0 || when === null) return null;
2519
- if (typeof when !== "object" || Array.isArray(when)) return "when must be an object";
2520
- const keys = Object.keys(when);
2521
- if (keys.length === 0) return "when is empty \u2014 remove it, or name a path or method";
2522
- const unknown = keys.filter((key) => !WHEN_KEYS.includes(key));
2523
- if (unknown.length > 0) {
2524
- 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`;
2525
- }
2526
- if ("path" in when && !isNonEmptyString(when.path)) {
2527
- return "when.path must be a non-empty string";
2528
- }
2529
- if ("method" in when) {
2530
- const methods = Array.isArray(when.method) ? when.method : [when.method];
2531
- if (methods.length === 0) return "when.method is an empty list, so the rule would match no method";
2532
- if (!methods.every(isNonEmptyString)) return "when.method must be a non-empty string, or a list of them";
3133
+
3134
+ // src/protect/rules/store.js
3135
+ var fsMod;
3136
+ async function loadFs() {
3137
+ if (fsMod !== void 0) return fsMod;
3138
+ try {
3139
+ const [fs, path] = await Promise.all([import("fs"), import("path")]);
3140
+ fsMod = {
3141
+ readFileSync: fs.readFileSync,
3142
+ writeFileSync: fs.writeFileSync,
3143
+ mkdirSync: fs.mkdirSync,
3144
+ join: path.join
3145
+ };
3146
+ } catch {
3147
+ fsMod = null;
2533
3148
  }
2534
- return null;
2535
- }
2536
- function isNonEmptyString(value) {
2537
- return typeof value === "string" && value.trim() !== "";
3149
+ return fsMod;
2538
3150
  }
2539
- function actionProblem(action, rule) {
2540
- if (action === void 0 || action === null) return null;
2541
- if (!ACTIONS.includes(action)) return `unknown action "${action}"`;
2542
- const spec = ACTION_PROPERTIES[action];
2543
- for (const required of spec?.required ?? []) {
2544
- if (rule?.[required] === void 0 || rule?.[required] === null) {
2545
- return `action "${action}" needs "${required}"`;
3151
+ function makeStore(options = {}) {
3152
+ let mem = null;
3153
+ const durable = durableTier(options);
3154
+ return {
3155
+ async read() {
3156
+ if (mem) return mem;
3157
+ const env = await durable.read();
3158
+ if (env) mem = env;
3159
+ return env;
3160
+ },
3161
+ async write(env) {
3162
+ if (env) mem = env;
3163
+ await durable.write(env);
2546
3164
  }
2547
- }
2548
- for (const [property, shape] of Object.entries(spec?.shapes ?? {})) {
2549
- if (rule?.[property] === void 0 || rule?.[property] === null) continue;
2550
- const problem = operandShapeProblem(shape, rule[property]);
2551
- if (problem) return `action "${action}" property "${property}" ${problem}`;
2552
- }
2553
- const phase = rule?.phase ?? "request";
2554
- if (spec !== void 0 && !spec.phases.includes(phase)) {
2555
- return `action "${action}" is carried out on the ${spec.phases.join("/")} phase; on "${phase}" the rule blocks instead`;
2556
- }
2557
- return null;
3165
+ };
2558
3166
  }
2559
-
2560
- // src/protect/rules/validate.js
2561
- var LIMITS2 = LIMITS;
2562
- var PHASES2 = new Set(PHASES);
2563
- var ACTIONS2 = new Set(ACTIONS);
2564
- function validateBundle(bundle, opts = {}) {
2565
- const rejected = [];
2566
- const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
2567
- const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
2568
- const firewall = [];
2569
- for (const rule of inFirewall) {
2570
- if (firewall.length >= LIMITS2.maxRules) {
2571
- rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS2.maxRules})` });
2572
- continue;
2573
- }
2574
- const reason = ruleProblem(rule);
2575
- if (reason) rejected.push({ id: idOf(rule), reason });
2576
- else firewall.push(rule);
2577
- }
2578
- const whitelists = [];
2579
- for (const wl of inWhitelists) {
2580
- if (whitelists.length >= LIMITS2.maxWhitelists) {
2581
- rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS2.maxWhitelists})` });
2582
- continue;
2583
- }
2584
- if (!opts.allowGlobalWhitelists && wl && Array.isArray(wl.rule_v2) && !wl.rule_id) {
2585
- rejected.push({ id: idOf(wl), reason: "whitelist has no rule_id (would suppress every rule); set allowGlobalWhitelists to permit" });
2586
- continue;
2587
- }
2588
- const reason = conditionsProblem(wl?.rule_v2);
2589
- if (reason) rejected.push({ id: idOf(wl), reason: `whitelist: ${reason}` });
2590
- else whitelists.push(wl);
3167
+ function durableTier(options) {
3168
+ const adapter = options.ruleCache;
3169
+ if (adapter && typeof adapter.read === "function" && typeof adapter.write === "function") {
3170
+ return {
3171
+ read: async () => {
3172
+ try {
3173
+ return toEnvelope(await adapter.read());
3174
+ } catch {
3175
+ return null;
3176
+ }
3177
+ },
3178
+ write: async (env) => {
3179
+ try {
3180
+ await adapter.write(env);
3181
+ } catch {
3182
+ }
3183
+ }
3184
+ };
2591
3185
  }
3186
+ const dir = options.cacheDir;
2592
3187
  return {
2593
- bundle: { ...bundle, firewall, whitelists },
2594
- rejected
3188
+ read: async () => cacheRead(dir),
3189
+ write: async (env) => cacheWrite(dir, env)
2595
3190
  };
2596
3191
  }
2597
- function idOf(rule) {
2598
- const id = rule?.id ?? rule?.rule_id;
2599
- return id === void 0 || id === null ? "(unidentified)" : String(id);
3192
+ async function cacheWrite(dir, env) {
3193
+ if (!dir) return;
3194
+ const fs = await loadFs();
3195
+ if (!fs) return;
3196
+ try {
3197
+ fs.mkdirSync(dir, { recursive: true });
3198
+ fs.writeFileSync(fs.join(dir, "patchstack-rules.json"), JSON.stringify(env));
3199
+ } catch {
3200
+ }
2600
3201
  }
2601
- function ruleProblem(rule) {
2602
- if (!rule || typeof rule !== "object") return "not an object";
2603
- if (rule.phase !== void 0 && !PHASES2.has(rule.phase)) return `unknown phase "${rule.phase}"`;
2604
- const actionReason = actionProblem(rule.action, rule);
2605
- if (actionReason) return actionReason;
2606
- const scopeReason = whenProblem(rule.when);
2607
- if (scopeReason) return scopeReason;
2608
- const capOverride = rule.max_bytes;
2609
- if (capOverride !== void 0 && !(Number(capOverride) > 0)) return "max_bytes must be a positive number";
2610
- return conditionsProblem(rule.rule_v2);
3202
+ async function cacheRead(dir) {
3203
+ if (!dir) return null;
3204
+ const fs = await loadFs();
3205
+ if (!fs) return null;
3206
+ try {
3207
+ return toEnvelope(JSON.parse(fs.readFileSync(fs.join(dir, "patchstack-rules.json"), "utf8")));
3208
+ } catch {
3209
+ return null;
3210
+ }
2611
3211
  }
2612
- function conditionsProblem(conditions, depth = 0) {
2613
- if (!Array.isArray(conditions)) return "rule_v2 must be an array of conditions";
2614
- if (conditions.length === 0) return "rule_v2 is empty (would never match)";
2615
- if (depth > LIMITS2.maxNestingDepth) return `nesting deeper than ${LIMITS2.maxNestingDepth}`;
2616
- if (conditions.length > LIMITS2.maxConditionsPerRule) {
2617
- return `more than ${LIMITS2.maxConditionsPerRule} conditions`;
3212
+ function toEnvelope(value) {
3213
+ if (!value || typeof value !== "object") return null;
3214
+ if (value.bundle && typeof value.bundle === "object") {
3215
+ return { bundle: value.bundle, etag: value.etag ?? null };
2618
3216
  }
2619
- for (const c of conditions) {
2620
- const shapeReason = conditionShapeProblem(c);
2621
- if (shapeReason) return shapeReason;
2622
- if (isGroup(c)) {
2623
- const nested = conditionsProblem(c.rules, depth + 1);
2624
- if (nested) return nested;
2625
- continue;
2626
- }
2627
- const parameterReason = parameterProblem(c.parameter);
2628
- if (parameterReason) return parameterReason;
2629
- const mutationReason = mutationsProblem(c.mutations);
2630
- if (mutationReason) return mutationReason;
2631
- const matchReason = matchProblem(c.match, c.parameter);
2632
- if (matchReason) return matchReason;
2633
- const m = c.match;
2634
- if (m.type === "regex") {
2635
- if (typeof m.value !== "string") return "regex match.value must be a string";
2636
- if (m.value.length > LIMITS2.maxRegexLength) return `regex longer than ${LIMITS2.maxRegexLength} chars`;
2637
- } else if (typeof m.value === "string" && m.value.length > LIMITS2.maxValueLength) {
2638
- return `match.value longer than ${LIMITS2.maxValueLength} chars`;
2639
- }
2640
- if (m.match) {
2641
- const nested = conditionsProblem([{ parameter: c.parameter, match: m.match }], depth + 1);
2642
- if (nested) return nested;
2643
- }
3217
+ if (Array.isArray(value.firewall) || Array.isArray(value.whitelists)) {
3218
+ return { bundle: value, etag: null };
2644
3219
  }
2645
3220
  return null;
2646
3221
  }
@@ -2666,67 +3241,70 @@ function reportRejections(rejected, options, label) {
2666
3241
  `${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" : ""}`
2667
3242
  ), "onError");
2668
3243
  }
2669
- function fromSource(bundle, reason) {
2670
- return reason === void 0 ? { ...bundle, source: { ok: true } } : { ...bundle, source: { ok: false, reason } };
3244
+ function fromSource(bundle, origin, reason) {
3245
+ return {
3246
+ ...bundle,
3247
+ source: reason === void 0 ? { ok: true, origin } : { ok: false, origin, reason }
3248
+ };
2671
3249
  }
2672
3250
  async function resolveRules(options, store, ctx = {}) {
2673
3251
  const timeoutMs = ctx.timeoutMs;
2674
3252
  if (options.siteUuid) {
2675
3253
  const prior = await store.read();
2676
- const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, reportsDetections: options.reportDetections === true });
3254
+ const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, detectionState: ctx.detectionState });
2677
3255
  const res = await client.getRules();
2678
- if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
3256
+ if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache");
2679
3257
  if (res.success && !res.notModified) {
2680
3258
  const rejected = liveUpdateRejections(res, options);
2681
3259
  if (rejected.length > 0) {
2682
3260
  reportRejections(rejected, options, "rule update rejected");
2683
- if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
2684
- if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
2685
- return fromSource(emptyBundle(), "update rejected");
3261
+ if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache", "update rejected");
3262
+ if (options.rules) return fromSource(normalizeBundle(options.rules, options), "bundled", "update rejected");
3263
+ return fromSource(emptyBundle(), "empty", "update rejected");
2686
3264
  }
2687
3265
  const bundle = normalizeBundle(res, options);
2688
3266
  await store.write({ bundle, etag: res.etag ?? null });
2689
- return fromSource(bundle);
3267
+ return fromSource(bundle, "api");
2690
3268
  }
2691
3269
  if (prior?.bundle) {
2692
3270
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
2693
- return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
3271
+ return fromSource(normalizeBundle(prior.bundle, options), "cache", res.error ?? "no usable response");
2694
3272
  }
2695
3273
  if (options.rules) {
2696
3274
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`), "onError");
2697
- return fromSource(normalizeBundle(options.rules, options), res.error ?? "no usable response");
3275
+ return fromSource(normalizeBundle(options.rules, options), "bundled", res.error ?? "no usable response");
2698
3276
  }
2699
3277
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
2700
- return fromSource(emptyBundle(), res.error ?? "no usable response");
3278
+ return fromSource(emptyBundle(), "empty", res.error ?? "no usable response");
2701
3279
  }
2702
3280
  if (options.token) {
2703
3281
  const prior = await store.read();
2704
3282
  const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs });
2705
3283
  const res = await client.getRules();
2706
- if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
3284
+ if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache");
2707
3285
  if (res.success && !res.notModified) {
2708
3286
  const rejected = liveUpdateRejections(res, options);
2709
3287
  if (rejected.length > 0) {
2710
3288
  reportRejections(rejected, options, "rule update rejected");
2711
- if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
2712
- if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
2713
- return fromSource(emptyBundle(), "update rejected");
3289
+ if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache", "update rejected");
3290
+ if (options.rules) return fromSource(normalizeBundle(options.rules, options), "bundled", "update rejected");
3291
+ return fromSource(emptyBundle(), "empty", "update rejected");
2714
3292
  }
2715
3293
  const bundle = normalizeBundle(res, options);
2716
3294
  await store.write({ bundle, etag: res.etag ?? null });
2717
- return fromSource(bundle);
3295
+ return fromSource(bundle, "api");
2718
3296
  }
2719
3297
  if (prior?.bundle) {
2720
3298
  notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
2721
- return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
3299
+ return fromSource(normalizeBundle(prior.bundle, options), "cache", res.error ?? "no usable response");
2722
3300
  }
2723
3301
  notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
2724
- return fromSource(emptyBundle(), res.error ?? "no usable response");
3302
+ return fromSource(emptyBundle(), "empty", res.error ?? "no usable response");
2725
3303
  }
2726
3304
  if (options.rules) {
2727
- return fromSource(normalizeBundle(options.rules, options));
3305
+ return fromSource(normalizeBundle(options.rules, options), "bundled");
2728
3306
  }
2729
- return fromSource(emptyBundle());
3307
+ return fromSource(emptyBundle(), "empty");
2730
3308
  }
2731
3309
  function normalizeBundle(b, options = {}) {
2732
3310
  const enforcement = b?.enforcement ?? b?.mode;
@@ -2822,6 +3400,164 @@ var DEFAULT_BASE_URL3 = "https://api.patchstack.com/monitor/pulse";
2822
3400
  var DEFAULT_FLUSH_MS = 5e3;
2823
3401
  var MAX_BATCH = 50;
2824
3402
  var MAX_QUEUE = 500;
3403
+ var MAX_ATTEMPTS = 4;
3404
+ var RETRY_BASE_MS = 1e3;
3405
+ var RETRY_CAP_MS = 3e4;
3406
+ var RETRYABLE_EXPLICIT = /* @__PURE__ */ new Set([408, 425, 429]);
3407
+ function worthRetrying(status) {
3408
+ if (status === null) return true;
3409
+ return RETRYABLE_EXPLICIT.has(status) || status >= 500 && status <= 599;
3410
+ }
3411
+ var ATTEMPT_TIMEOUT_MS = 1e4;
3412
+ var STOP_BUDGET_MS = 5e3;
3413
+ var MAX_ROUTE_CHARS = 256;
3414
+ var MAX_PARAMETERS = 25;
3415
+ var MAX_PARAMETER_CHARS = 64;
3416
+ var MAX_IDENTIFIER_CHARS = 256;
3417
+ var MAX_CAPTURED_VALUES = 10;
3418
+ var MAX_CAPTURED_VALUE_CHARS = 512;
3419
+ var MAX_QUERY_KEYS = 10;
3420
+ var MAX_METHOD_CHARS = 16;
3421
+ var MAX_BODY_BYTES2 = 64 * 1024;
3422
+ function queryKeysOf(path) {
3423
+ if (typeof path !== "string") return { keys: [], total: 0 };
3424
+ const start = path.indexOf("?");
3425
+ if (start === -1) return { keys: [], total: 0 };
3426
+ let names;
3427
+ try {
3428
+ names = [...new URLSearchParams(path.slice(start + 1)).keys()];
3429
+ } catch {
3430
+ return { keys: [], total: 0 };
3431
+ }
3432
+ const seen = /* @__PURE__ */ new Set();
3433
+ const keys = [];
3434
+ for (const name of names) {
3435
+ if (name === "") continue;
3436
+ if (seen.has(name)) continue;
3437
+ seen.add(name);
3438
+ if (keys.length < MAX_QUERY_KEYS) keys.push(name);
3439
+ }
3440
+ return { keys, total: seen.size };
3441
+ }
3442
+ function boundCapture(capture) {
3443
+ if (!capture || typeof capture !== "object" || Array.isArray(capture)) return null;
3444
+ const truncated = [];
3445
+ const own = (object, key) => Object.hasOwn(object, key) ? object[key] : void 0;
3446
+ const declaredPlan = own(capture, "plan");
3447
+ const plan = typeof declaredPlan === "string" ? capText(declaredPlan, MAX_IDENTIFIER_CHARS) : null;
3448
+ if (plan === null) return null;
3449
+ if (plan.truncated) truncated.push("plan");
3450
+ const out = { plan: plan.value };
3451
+ let overCap = 0;
3452
+ let rejected = 0;
3453
+ const declaredValues = own(capture, "values");
3454
+ if (Array.isArray(declaredValues) && declaredValues.length > 0) {
3455
+ const values = [];
3456
+ for (const entry of declaredValues) {
3457
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
3458
+ rejected += 1;
3459
+ continue;
3460
+ }
3461
+ const declaredParameter = own(entry, "parameter");
3462
+ const text = scalarText(own(entry, "value"));
3463
+ if (typeof declaredParameter !== "string" || text === null) {
3464
+ rejected += 1;
3465
+ continue;
3466
+ }
3467
+ if (values.length >= MAX_CAPTURED_VALUES) {
3468
+ overCap += 1;
3469
+ continue;
3470
+ }
3471
+ const label = capText(declaredParameter, MAX_PARAMETER_CHARS);
3472
+ const value = capText(text, MAX_CAPTURED_VALUE_CHARS);
3473
+ if (label.truncated && !truncated.includes("parameter")) truncated.push("parameter");
3474
+ if (value.truncated && !truncated.includes("value")) truncated.push("value");
3475
+ values.push({
3476
+ parameter: label.value,
3477
+ value: value.value,
3478
+ ...own(entry, "truncated") === true || value.truncated ? { truncated: true } : {}
3479
+ });
3480
+ }
3481
+ if (overCap > 0) truncated.push("values");
3482
+ if (values.length > 0) out.values = values;
3483
+ }
3484
+ const declaredRaw = own(capture, "raw");
3485
+ if (declaredRaw !== void 0 && declaredRaw !== null) {
3486
+ const rawValue = declaredRaw && typeof declaredRaw === "object" ? own(declaredRaw, "value") : void 0;
3487
+ if (typeof rawValue === "string") {
3488
+ const raw = capText(rawValue, MAX_CAPTURED_VALUE_CHARS);
3489
+ out.raw = {
3490
+ value: raw.value,
3491
+ ...own(declaredRaw, "truncated") === true || raw.truncated ? { truncated: true } : {}
3492
+ };
3493
+ } else {
3494
+ rejected += 1;
3495
+ }
3496
+ }
3497
+ const declaredCount = (key) => {
3498
+ const value = own(capture, key);
3499
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
3500
+ };
3501
+ const totals = {
3502
+ omitted: declaredCount("omitted") + overCap,
3503
+ unsupported: declaredCount("unsupported") + rejected,
3504
+ failed: declaredCount("failed")
3505
+ };
3506
+ for (const [key, total] of Object.entries(totals)) {
3507
+ if (total > 0) out[key] = total;
3508
+ }
3509
+ if (own(capture, "unavailable") === true) out.unavailable = true;
3510
+ if (truncated.length > 0) out.truncated = truncated;
3511
+ return out;
3512
+ }
3513
+ function scalarText(value) {
3514
+ if (typeof value === "string") return value;
3515
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
3516
+ if (typeof value === "boolean") return String(value);
3517
+ return null;
3518
+ }
3519
+ function makeInstanceId() {
3520
+ try {
3521
+ const uuid = globalThis.crypto?.randomUUID?.();
3522
+ if (typeof uuid === "string" && uuid !== "") return uuid;
3523
+ } catch {
3524
+ }
3525
+ return `${Date.now().toString(36)}-${Math.floor(Math.random() * 16777215).toString(36)}`;
3526
+ }
3527
+ function capText(value, limit) {
3528
+ const text = typeof value === "string" ? value : "";
3529
+ return text.length > limit ? { value: text.slice(0, limit), truncated: true } : { value: text, truncated: false };
3530
+ }
3531
+ function retryDelayMs(attempts, retryAfter, random = Math.random) {
3532
+ const advertised = parseRetryAfter(retryAfter);
3533
+ if (advertised !== null) return Math.min(advertised, RETRY_CAP_MS);
3534
+ const backoff = Math.min(RETRY_BASE_MS * 2 ** (attempts - 1), RETRY_CAP_MS);
3535
+ return Math.min(RETRY_CAP_MS, Math.round(backoff * (0.75 + random() * 0.5)));
3536
+ }
3537
+ function parseRetryAfter(value) {
3538
+ if (typeof value !== "string" || value.trim() === "") return null;
3539
+ const seconds = Number(value.trim());
3540
+ if (Number.isFinite(seconds)) return seconds >= 0 ? seconds * 1e3 : null;
3541
+ const at = Date.parse(value);
3542
+ if (Number.isNaN(at)) return null;
3543
+ return Math.max(0, at - Date.now());
3544
+ }
3545
+ var encoder = new TextEncoder();
3546
+ function byteLength(text) {
3547
+ return encoder.encode(text).length;
3548
+ }
3549
+ function splitToFit(events, maxBytes, wrap = (batch) => ({ detections: batch })) {
3550
+ const batch = events.slice();
3551
+ const rest = [];
3552
+ while (batch.length > 1 && byteLength(JSON.stringify(wrap(batch))) > maxBytes) {
3553
+ rest.unshift(batch.pop());
3554
+ }
3555
+ return [batch, rest];
3556
+ }
3557
+ function unattended(timer) {
3558
+ if (timer && typeof timer.unref === "function") timer.unref();
3559
+ return timer;
3560
+ }
2825
3561
  function ruleParameters(rule) {
2826
3562
  const out = /* @__PURE__ */ new Set();
2827
3563
  const walk = (conditions) => {
@@ -2856,12 +3592,22 @@ function createDetectionReporter(opts) {
2856
3592
  },
2857
3593
  flush() {
2858
3594
  },
2859
- stop() {
2860
- },
3595
+ stop: () => Promise.resolve(),
2861
3596
  setRulesEtag() {
2862
3597
  },
3598
+ announce() {
3599
+ },
2863
3600
  dropped: () => 0,
2864
- health: () => ({ sent: 0, delivered: 0, failed: 0, dropped: 0, lastDeliveredAt: null })
3601
+ health: () => ({
3602
+ sent: 0,
3603
+ delivered: 0,
3604
+ failed: 0,
3605
+ dropped: 0,
3606
+ retried: 0,
3607
+ reauthorized: 0,
3608
+ lastDeliveredAt: null,
3609
+ capability: { announced: 0, acknowledged: 0, failed: 0, retried: 0, lastAcknowledgedAt: null }
3610
+ })
2865
3611
  };
2866
3612
  }
2867
3613
  const configured = opts.baseUrl ?? process.env?.PATCHSTACK_PULSE_RULES_URL;
@@ -2875,47 +3621,233 @@ function createDetectionReporter(opts) {
2875
3621
  let failed = 0;
2876
3622
  let droppedTotal = 0;
2877
3623
  let lastDeliveredAt = null;
3624
+ let capabilityAnnounced = 0;
3625
+ let capabilityAcknowledged = 0;
3626
+ let capabilityFailed = 0;
3627
+ let lastCapabilityAckAt = null;
2878
3628
  let queue = [];
2879
3629
  let timer = null;
3630
+ let retryTimer = null;
2880
3631
  let stopped = false;
2881
3632
  let dropped = 0;
3633
+ let retried = 0;
3634
+ let capabilityRetried = 0;
3635
+ let flushRequested = false;
3636
+ let draining = false;
3637
+ let drainResolve = null;
3638
+ let drainPromise = null;
3639
+ let budgetTimer = null;
3640
+ let epoch = 0;
3641
+ let reauthorized = 0;
3642
+ let sending = false;
3643
+ let inFlight = null;
3644
+ let pendingState = null;
3645
+ const instanceId = makeInstanceId();
3646
+ let sequence = 0;
3647
+ const bodyFor = (events, droppedWith, state) => ({
3648
+ detections: events,
3649
+ // The count of what never made it, sent WITH the batch rather than inferred from a gap: a consumer
3650
+ // computing a false-positive rate needs to know its denominator is short, and silence about that
3651
+ // would make a truncated sample look like a complete one.
3652
+ dropped: droppedWith,
3653
+ ...state !== null ? { reporting_state: state } : {}
3654
+ });
3655
+ const takeBatch = (droppedWith, state) => {
3656
+ const [batch, rest] = splitToFit(
3657
+ queue.splice(0, MAX_BATCH),
3658
+ MAX_BODY_BYTES2,
3659
+ (events) => bodyFor(events, droppedWith, state)
3660
+ );
3661
+ if (rest.length > 0) queue.unshift(...rest);
3662
+ return batch;
3663
+ };
3664
+ const authConfig = { pulseAuth: opts.pulseAuth, endpoint: baseUrl, timeoutMs: ATTEMPT_TIMEOUT_MS };
3665
+ const detectionsUrl = `${baseUrl}/detections/${encodeURIComponent(siteUuid)}`;
3666
+ const post = async (body, key, signal, transport) => (
3667
+ // Through the shared Pulse path, which attaches the token and — on a 401 — discards it and retries
3668
+ // once with a fresh one. A cached token can stop being valid before it expires, and the server's
3669
+ // refusal is authoritative over our own clock; the batch's own headers, this key included, are
3670
+ // carried through both sends, so the redelivery is still recognisable as the same batch.
3671
+ pulseFetch(
3672
+ authConfig,
3673
+ detectionsUrl,
3674
+ {
3675
+ method: "POST",
3676
+ ...signal ? { signal } : {},
3677
+ headers: {
3678
+ "Content-Type": "application/json",
3679
+ Accept: "application/json",
3680
+ "User-Agent": "@patchstack/connect",
3681
+ // The same key on every attempt of one batch: an acknowledgement can be lost after the server
3682
+ // has already taken the batch, and a redelivery has to be identifiable as the same one.
3683
+ "Idempotency-Key": key
3684
+ },
3685
+ body: JSON.stringify(body)
3686
+ },
3687
+ transport
3688
+ )
3689
+ );
3690
+ const abandon = () => {
3691
+ if (!inFlight) return;
3692
+ failed += inFlight.events.length;
3693
+ if (inFlight.state !== null) capabilityFailed += 1;
3694
+ inFlight = null;
3695
+ };
3696
+ const settle = () => {
3697
+ if (!inFlight) return;
3698
+ if (inFlight.events.length > 0) {
3699
+ delivered += inFlight.events.length;
3700
+ lastDeliveredAt = (/* @__PURE__ */ new Date()).toISOString();
3701
+ }
3702
+ if (inFlight.state !== null) {
3703
+ capabilityAcknowledged += 1;
3704
+ lastCapabilityAckAt = (/* @__PURE__ */ new Date()).toISOString();
3705
+ }
3706
+ inFlight = null;
3707
+ };
3708
+ const attempt = async () => {
3709
+ if (!inFlight) return;
3710
+ const mine = epoch;
3711
+ sending = true;
3712
+ inFlight.attempts += 1;
3713
+ if (inFlight.attempts > 1) {
3714
+ if (inFlight.events.length > 0) retried += 1;
3715
+ if (inFlight.state !== null) capabilityRetried += 1;
3716
+ }
3717
+ const body = bodyFor(inFlight.events, inFlight.dropped, inFlight.state);
3718
+ let status = null;
3719
+ let retryAfter = null;
3720
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
3721
+ inFlight.controller = controller;
3722
+ const timeout = controller ? unattended(setTimeout(() => controller.abort(), ATTEMPT_TIMEOUT_MS)) : null;
3723
+ inFlight.timeout = timeout;
3724
+ let sends = 0;
3725
+ const counted = (url, init) => {
3726
+ if (String(url) === detectionsUrl) {
3727
+ sends += 1;
3728
+ if (sends > 1 && mine === epoch) reauthorized += 1;
3729
+ }
3730
+ return fetchImpl(url, init);
3731
+ };
3732
+ try {
3733
+ const res = await post(body, inFlight.key, controller?.signal, counted);
3734
+ if (mine !== epoch) return;
3735
+ if (res && res.ok) {
3736
+ settle();
3737
+ if (timeout) clearTimeout(timeout);
3738
+ finish();
3739
+ return;
3740
+ }
3741
+ status = typeof res?.status === "number" ? res.status : 0;
3742
+ retryAfter = res?.headers?.get?.("retry-after") ?? null;
3743
+ } catch {
3744
+ status = null;
3745
+ } finally {
3746
+ if (timeout) clearTimeout(timeout);
3747
+ if (inFlight && inFlight.controller === controller) {
3748
+ inFlight.controller = null;
3749
+ inFlight.timeout = null;
3750
+ }
3751
+ }
3752
+ if (mine !== epoch) return;
3753
+ const retryable = worthRetrying(status);
3754
+ if (retryable && inFlight.attempts < MAX_ATTEMPTS && !stopped) {
3755
+ const delay = retryDelayMs(inFlight.attempts, retryAfter);
3756
+ sending = false;
3757
+ retryTimer = unattended(
3758
+ setTimeout(() => {
3759
+ retryTimer = null;
3760
+ void attempt();
3761
+ }, delay)
3762
+ );
3763
+ return;
3764
+ }
3765
+ abandon();
3766
+ finish();
3767
+ };
3768
+ const finish = () => {
3769
+ sending = false;
3770
+ kick();
3771
+ };
3772
+ const arm = () => {
3773
+ if (!timer) {
3774
+ timer = unattended(
3775
+ setTimeout(() => {
3776
+ timer = null;
3777
+ flushRequested = true;
3778
+ kick();
3779
+ }, flushMs)
3780
+ );
3781
+ }
3782
+ };
3783
+ const due = () => draining || pendingState !== null || queue.length >= MAX_BATCH || flushRequested;
3784
+ const kick = () => {
3785
+ if (sending || inFlight) return;
3786
+ if (typeof fetchImpl !== "function") {
3787
+ if (draining) drained();
3788
+ return;
3789
+ }
3790
+ if (stopped && !draining) return;
3791
+ if (queue.length === 0 && pendingState === null) {
3792
+ flushRequested = false;
3793
+ if (draining) drained();
3794
+ return;
3795
+ }
3796
+ if (!due()) {
3797
+ arm();
3798
+ return;
3799
+ }
3800
+ flushRequested = false;
3801
+ const droppedWith = dropped;
3802
+ dropped = 0;
3803
+ droppedTotal += droppedWith;
3804
+ const state = pendingState;
3805
+ pendingState = null;
3806
+ const events = takeBatch(droppedWith, state);
3807
+ sent += events.length;
3808
+ if (state !== null) capabilityAnnounced += 1;
3809
+ sequence += 1;
3810
+ inFlight = { key: `${instanceId}-${sequence}`, events, dropped: droppedWith, state, attempts: 0 };
3811
+ void attempt();
3812
+ };
3813
+ const terminate = () => {
3814
+ epoch += 1;
3815
+ if (inFlight) {
3816
+ inFlight.controller?.abort();
3817
+ if (inFlight.timeout) clearTimeout(inFlight.timeout);
3818
+ failed += inFlight.events.length;
3819
+ if (inFlight.state !== null) capabilityFailed += 1;
3820
+ inFlight = null;
3821
+ }
3822
+ if (pendingState !== null) {
3823
+ pendingState = null;
3824
+ capabilityAnnounced += 1;
3825
+ capabilityFailed += 1;
3826
+ }
3827
+ sending = false;
3828
+ drained();
3829
+ };
3830
+ const drained = () => {
3831
+ draining = false;
3832
+ if (queue.length > 0) {
3833
+ droppedTotal += queue.length;
3834
+ queue = [];
3835
+ }
3836
+ if (budgetTimer) {
3837
+ clearTimeout(budgetTimer);
3838
+ budgetTimer = null;
3839
+ }
3840
+ const resolve = drainResolve;
3841
+ drainResolve = null;
3842
+ if (resolve) resolve();
3843
+ };
2882
3844
  const flush = () => {
2883
3845
  if (timer) {
2884
3846
  clearTimeout(timer);
2885
3847
  timer = null;
2886
3848
  }
2887
- if (queue.length === 0 || typeof fetchImpl !== "function") return;
2888
- const batch = queue.splice(0, MAX_BATCH);
2889
- const droppedWith = dropped;
2890
- dropped = 0;
2891
- droppedTotal += droppedWith;
2892
- sent += batch.length;
2893
- void (async () => {
2894
- try {
2895
- const res = await fetchImpl(`${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, {
2896
- method: "POST",
2897
- headers: {
2898
- "Content-Type": "application/json",
2899
- Accept: "application/json",
2900
- "User-Agent": "@patchstack/connect",
2901
- // Same credential path as the rules fetch. The detections endpoint is site-addressed and
2902
- // requires a verified, site-bound token, so a batch sent without one is refused — which is
2903
- // why the runtime does not build a reporter when no credential resolves, rather than
2904
- // posting into a 401.
2905
- ...await pulseAuthHeader({ pulseAuth: opts.pulseAuth, endpoint: baseUrl }, fetchImpl)
2906
- },
2907
- body: JSON.stringify({ detections: batch, dropped: droppedWith })
2908
- });
2909
- if (res && res.ok) {
2910
- delivered += batch.length;
2911
- lastDeliveredAt = (/* @__PURE__ */ new Date()).toISOString();
2912
- } else {
2913
- failed += batch.length;
2914
- }
2915
- } catch {
2916
- failed += batch.length;
2917
- }
2918
- })();
3849
+ flushRequested = true;
3850
+ kick();
2919
3851
  };
2920
3852
  return {
2921
3853
  /**
@@ -2934,32 +3866,144 @@ function createDetectionReporter(opts) {
2934
3866
  queue.shift();
2935
3867
  dropped++;
2936
3868
  }
3869
+ const query = queryKeysOf(detection.path);
3870
+ const method = typeof detection.method === "string" ? capText(detection.method, MAX_METHOD_CHARS) : null;
3871
+ const userAgent = typeof detection.userAgent === "string" && detection.userAgent !== "" ? capText(detection.userAgent, MAX_IDENTIFIER_CHARS) : null;
3872
+ const queryKeys = query.keys.map((name) => capText(name, MAX_PARAMETER_CHARS));
3873
+ const capture = boundCapture(detection.capture);
3874
+ const rawRoute = routeOf(detection.path);
3875
+ const route = typeof rawRoute === "string" ? capText(rawRoute, MAX_ROUTE_CHARS) : { value: rawRoute, truncated: false };
3876
+ const allParameters = ruleParameters(detection.rule);
3877
+ const parameters = allParameters.slice(0, MAX_PARAMETERS).map((name) => capText(name, MAX_PARAMETER_CHARS));
3878
+ const truncated = [];
3879
+ if (route.truncated) truncated.push("route");
3880
+ if (method?.truncated) truncated.push("method");
3881
+ if (userAgent?.truncated) truncated.push("user_agent");
3882
+ if (query.total > queryKeys.length || queryKeys.some((entry) => entry.truncated)) {
3883
+ truncated.push("query_keys");
3884
+ }
3885
+ if (allParameters.length > MAX_PARAMETERS || parameters.some((entry) => entry.truncated)) {
3886
+ truncated.push("parameters");
3887
+ }
3888
+ const id = capText(String(ruleId), MAX_IDENTIFIER_CHARS);
3889
+ const revision = capText(revisionOf(detection.rule) ?? "", MAX_IDENTIFIER_CHARS);
3890
+ const etag = capText(rulesEtag ?? "", MAX_IDENTIFIER_CHARS);
3891
+ for (const [name, field] of [
3892
+ ["rule_id", id],
3893
+ ["rule_revision", revision],
3894
+ ["rules_etag", etag]
3895
+ ]) {
3896
+ if (field.truncated) truncated.push(name);
3897
+ }
2937
3898
  queue.push({
2938
- rule_id: ruleId,
2939
- route: routeOf(detection.path),
2940
- parameters: ruleParameters(detection.rule),
3899
+ rule_id: id.value,
3900
+ route: route.value,
3901
+ parameters: parameters.map((entry) => entry.value),
3902
+ // Present only when something was shortened, so its absence is not a claim of its own.
3903
+ ...truncated.length > 0 ? { truncated } : {},
3904
+ // Only when parameters were actually left out. Reporting a total because some OTHER field was
3905
+ // shortened states that parameters were omitted when none were.
3906
+ ...truncated.includes("parameters") ? { parameters_total: allParameters.length } : {},
3907
+ method: method === null ? null : method.value,
3908
+ // The rest of the URL, as names only. `route` is the path; together they say what was requested
3909
+ // without saying what was in it.
3910
+ query_keys: queryKeys.map((entry) => entry.value),
3911
+ // Present only when the list is short, so its absence is not a claim of its own.
3912
+ ...query.total > queryKeys.length ? { query_keys_total: query.total } : {},
3913
+ // Who asked. Capped, since it is client-supplied text and this is an event with a size bound.
3914
+ user_agent: userAgent === null ? null : userAgent.value,
2941
3915
  phase: detection.phase ?? null,
2942
3916
  // The state this detection was handled under, which is the whole point: `false` is a rule that
2943
3917
  // saw traffic it would have stopped.
2944
3918
  enforced: detection.mode === "block",
2945
- rules_etag: rulesEtag,
3919
+ rules_etag: rulesEtag === null ? null : etag.value,
2946
3920
  // The revision of THIS rule, as the bundle delivered it. The bundle identity above answers "which
2947
3921
  // bundle", which changes whenever anything in it changes — so it cannot say whether the counts for
2948
3922
  // one rule describe the document that rule has now. Passed through untouched, and null when the
2949
3923
  // bundle carried none.
2950
- rule_revision: revisionOf(detection.rule),
3924
+ rule_revision: revisionOf(detection.rule) === null ? null : revision.value,
3925
+ // What the rule was permitted to show, and what it showed. Present only when a plan was derived,
3926
+ // which is to say only for a phase that has a reading to derive from.
3927
+ ...capture ? { capture } : {},
3928
+ // The client address and where it came from. `client_ip` is omitted entirely when there is none,
3929
+ // so a present-but-empty field cannot read as a failed lookup of a real address; the provenance is
3930
+ // always present, because "this could not be established" is the part a reader needs.
3931
+ ...clientIpFields({ ip: detection.ip ?? null, source: detection.clientIpSource ?? "unavailable" }),
2951
3932
  detected_at: (/* @__PURE__ */ new Date()).toISOString()
2952
3933
  });
2953
- if (queue.length >= MAX_BATCH) {
2954
- flush();
2955
- return;
2956
- }
2957
- if (!timer) timer = setTimeout(flush, flushMs);
3934
+ kick();
2958
3935
  },
2959
3936
  flush,
3937
+ /**
3938
+ * Tell the platform which reporting state this guard settled on.
3939
+ *
3940
+ * The state also travels on the rules fetch, but that request is made BEFORE the fetch decides
3941
+ * whether the rules are the platform's — so a site booting with an empty cache declares that it holds
3942
+ * no managed rules, then receives them. Without this, the corrected state would wait for the next
3943
+ * refresh, and a guard with refreshing switched off has none.
3944
+ *
3945
+ * Goes through the same transport as events, so it inherits the retry, the idempotency key and the
3946
+ * single-flight bound. Only the newest state is kept: calling this twice before a send declares the
3947
+ * second, because what the platform needs is the state now, not how it got there. A declaration that
3948
+ * exhausts its retries is dropped rather than held — the state travels on every rules fetch too, so
3949
+ * the next one corrects it.
3950
+ *
3951
+ * @param {string} state
3952
+ */
3953
+ announce(state) {
3954
+ if (stopped || typeof fetchImpl !== "function" || typeof state !== "string") return;
3955
+ pendingState = state;
3956
+ queueMicrotask(() => {
3957
+ if (!stopped) kick();
3958
+ });
3959
+ },
3960
+ /**
3961
+ * Stop reporting, leaving nothing stranded and nothing scheduled.
3962
+ *
3963
+ * Three things can be outstanding, and each needs an answer:
3964
+ *
3965
+ * - a batch waiting on a retry timer — it holds the only send slot, so clearing the timer alone would
3966
+ * leave it neither delivered nor counted. It gets one final attempt, with no retry behind it.
3967
+ * - a request in flight — it is aborted, and its completion drains what is left rather than starting
3968
+ * open-ended work.
3969
+ * - events still queued — drained a batch at a time, each attempted once.
3970
+ *
3971
+ * Whatever remains unsent when the drain runs out is counted as dropped, so every recorded event ends
3972
+ * up delivered, refused or dropped, and none simply disappears.
3973
+ *
3974
+ * The drain is asynchronous, so this returns a promise that settles when nothing is outstanding —
3975
+ * which a host shutting down can await instead of racing the last batch against process exit. It is
3976
+ * best-effort and bounded: a runtime that terminates the process regardless, or a drain slower than
3977
+ * `STOP_BUDGET_MS`, still ends the wait. Ignoring the promise behaves exactly as before.
3978
+ */
2960
3979
  stop() {
3980
+ if (stopped) return drainPromise ?? Promise.resolve();
2961
3981
  stopped = true;
2962
- flush();
3982
+ drainPromise = new Promise((resolve) => {
3983
+ drainResolve = resolve;
3984
+ });
3985
+ budgetTimer = unattended(setTimeout(() => {
3986
+ budgetTimer = null;
3987
+ terminate();
3988
+ }, STOP_BUDGET_MS));
3989
+ if (timer) {
3990
+ clearTimeout(timer);
3991
+ timer = null;
3992
+ }
3993
+ draining = true;
3994
+ if (retryTimer) {
3995
+ clearTimeout(retryTimer);
3996
+ retryTimer = null;
3997
+ void attempt();
3998
+ return drainPromise;
3999
+ }
4000
+ if (sending) {
4001
+ inFlight?.controller?.abort();
4002
+ return drainPromise;
4003
+ }
4004
+ flushRequested = true;
4005
+ kick();
4006
+ return drainPromise;
2963
4007
  },
2964
4008
  /**
2965
4009
  * Point later events at the bundle now running. Called after an ACCEPTED swap only — a rejected
@@ -2978,12 +4022,70 @@ function createDetectionReporter(opts) {
2978
4022
  * Delivery health, counted in events: attempted, acknowledged, refused or unreachable, and dropped
2979
4023
  * for queue pressure — plus when a batch was last acknowledged. No request data of any kind.
2980
4024
  */
2981
- health: () => ({ sent, delivered, failed, dropped: droppedTotal + dropped, lastDeliveredAt })
4025
+ health: () => ({
4026
+ sent,
4027
+ delivered,
4028
+ failed,
4029
+ dropped: droppedTotal + dropped,
4030
+ // Attempts beyond the first, counted in ATTEMPTS rather than events: a path that only ever
4031
+ // succeeds on a second try is working, and is worth telling apart from one that never retries.
4032
+ retried,
4033
+ // Redeliveries the credential path made after a refused token, which are not backoff retries and
4034
+ // would otherwise appear nowhere: a rotated or revoked credential is worth seeing as itself.
4035
+ reauthorized,
4036
+ lastDeliveredAt,
4037
+ // Separate, because a capability announcement delivers no events. Reading zero here alongside a
4038
+ // non-zero `delivered` is a normal state, and so is the reverse.
4039
+ capability: {
4040
+ announced: capabilityAnnounced,
4041
+ acknowledged: capabilityAcknowledged,
4042
+ failed: capabilityFailed,
4043
+ retried: capabilityRetried,
4044
+ lastAcknowledgedAt: lastCapabilityAckAt
4045
+ }
4046
+ })
2982
4047
  };
2983
4048
  }
2984
4049
 
4050
+ // src/protect/reporting-state.js
4051
+ var REPORTING_STATES = Object.freeze([
4052
+ /** Enrolled, managed rules, credential present, not opted out. Events are sent. */
4053
+ "on",
4054
+ /** `PATCHSTACK_REPORT_DETECTIONS=0`. An explicit, per-deployment opt-out. */
4055
+ "disabled-by-config",
4056
+ /** `PATCHSTACK_TELEMETRY=0`. The broader switch, which covers this along with everything else. */
4057
+ "disabled-by-telemetry-opt-out",
4058
+ /** No site identity: a local or unenrolled install. Nothing to report against. */
4059
+ "not-enrolled",
4060
+ /**
4061
+ * A site identity, but the rules running are not the platform's — the caller's own bundle, or none.
4062
+ * There is no managed rule document to attribute a detection to, so a report would name a rule id the
4063
+ * platform never issued.
4064
+ */
4065
+ "no-managed-rules",
4066
+ /** Enrolled with managed rules, but no credential resolved, so a report would be refused. */
4067
+ "unavailable-no-credential"
4068
+ ]);
4069
+ function optedOut(value) {
4070
+ if (value === void 0 || value === null || value === "") return false;
4071
+ return /^(0|false|off|no)$/i.test(String(value));
4072
+ }
4073
+ function reportingState(input) {
4074
+ const env = input.env ?? (typeof process !== "undefined" ? process.env : void 0) ?? {};
4075
+ if (input.configOptOut === true) return { state: "disabled-by-config", reports: false };
4076
+ if (optedOut(env.PATCHSTACK_REPORT_DETECTIONS)) return { state: "disabled-by-config", reports: false };
4077
+ if (optedOut(env.PATCHSTACK_TELEMETRY)) return { state: "disabled-by-telemetry-opt-out", reports: false };
4078
+ const siteUuid = input.siteUuid;
4079
+ if (typeof siteUuid !== "string" || siteUuid === "") return { state: "not-enrolled", reports: false };
4080
+ if (input.hasCredential !== true) return { state: "unavailable-no-credential", reports: false };
4081
+ const managed = input.ruleOrigin === "api" || input.ruleOrigin === "cache";
4082
+ if (!managed) return { state: "no-managed-rules", reports: false };
4083
+ return { state: "on", reports: true };
4084
+ }
4085
+
2985
4086
  // src/protect/firewall-log.js
2986
4087
  var DEFAULT_API_BASE = "https://api.patchstack.com";
4088
+ var STOP_BUDGET_MS2 = 5e3;
2987
4089
  var DEFAULT_FLUSH_MS2 = 1e3;
2988
4090
  var MAX_BATCH2 = 50;
2989
4091
  var TOKEN_SKEW_MS = 6e4;
@@ -3024,9 +4126,7 @@ function createFirewallLogReporter(opts) {
3024
4126
  const creds = parseApiKey(opts.apiKey);
3025
4127
  if (!creds) {
3026
4128
  return { record() {
3027
- }, flush() {
3028
- }, stop() {
3029
- } };
4129
+ }, flush: () => Promise.resolve(), stop: () => Promise.resolve() };
3030
4130
  }
3031
4131
  const apiBase = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
3032
4132
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
@@ -3035,9 +4135,13 @@ function createFirewallLogReporter(opts) {
3035
4135
  let queue = [];
3036
4136
  let timer = null;
3037
4137
  let stopped = false;
4138
+ const outstanding = /* @__PURE__ */ new Set();
4139
+ let drainPromise = null;
4140
+ const lifetime = typeof AbortController === "function" ? new AbortController() : null;
4141
+ let ended = false;
3038
4142
  let cachedToken = null;
3039
4143
  let tokenInflight = null;
3040
- const fetchAccessToken = async () => {
4144
+ const fetchAccessToken = async (signal) => {
3041
4145
  if (cachedToken && Date.now() < cachedToken.expiresAt - TOKEN_SKEW_MS) {
3042
4146
  return cachedToken.token;
3043
4147
  }
@@ -3046,6 +4150,7 @@ function createFirewallLogReporter(opts) {
3046
4150
  try {
3047
4151
  const res = await fetchImpl(`${apiBase}/oauth/token`, {
3048
4152
  method: "POST",
4153
+ ...signal ? { signal } : {},
3049
4154
  headers: {
3050
4155
  "Content-Type": "application/json",
3051
4156
  Accept: "application/json",
@@ -3078,15 +4183,16 @@ function createFirewallLogReporter(opts) {
3078
4183
  clearTimeout(timer);
3079
4184
  timer = null;
3080
4185
  }
3081
- if (queue.length === 0 || typeof fetchImpl !== "function") return;
4186
+ if (ended || queue.length === 0 || typeof fetchImpl !== "function") return Promise.resolve();
3082
4187
  const batch = queue.splice(0, MAX_BATCH2);
3083
- void (async () => {
3084
- const token = await fetchAccessToken();
3085
- if (!token) return;
3086
- const body = new URLSearchParams();
3087
- body.set("type", "firewall");
3088
- body.set("logs", JSON.stringify(batch));
4188
+ let entry;
4189
+ const send = (async () => {
3089
4190
  try {
4191
+ const token = await fetchAccessToken(lifetime?.signal);
4192
+ if (!token || ended) return;
4193
+ const body = new URLSearchParams();
4194
+ body.set("type", "firewall");
4195
+ body.set("logs", JSON.stringify(batch));
3090
4196
  const p = fetchImpl(`${apiBase}/api/logs/log`, {
3091
4197
  method: "POST",
3092
4198
  headers: {
@@ -3096,13 +4202,20 @@ function createFirewallLogReporter(opts) {
3096
4202
  "User-Agent": "@patchstack/connect",
3097
4203
  ...sourceHost ? { "Source-Host": sourceHost } : {}
3098
4204
  },
3099
- body
4205
+ body,
4206
+ // Both phases carry it, so a shutdown that runs out of time can end either one.
4207
+ ...lifetime ? { signal: lifetime.signal } : {}
3100
4208
  });
3101
- if (p && typeof p.then === "function") p.catch(() => {
4209
+ if (p && typeof p.then === "function") await p.catch(() => {
3102
4210
  });
3103
4211
  } catch {
4212
+ } finally {
4213
+ outstanding.delete(entry);
3104
4214
  }
3105
4215
  })();
4216
+ entry = send;
4217
+ outstanding.add(send);
4218
+ return send;
3106
4219
  };
3107
4220
  return {
3108
4221
  /**
@@ -3133,9 +4246,50 @@ function createFirewallLogReporter(opts) {
3133
4246
  if (!timer) timer = setTimeout(flush, flushMs);
3134
4247
  },
3135
4248
  flush,
4249
+ /**
4250
+ * Stop, and hand back a wait for what was outstanding.
4251
+ *
4252
+ * One flush sends at most a batch, so the queue is drained a batch at a time. The loop stops as soon
4253
+ * as a pass cannot shrink the queue — with no usable transport there is nothing to wait for, and
4254
+ * spinning would be worse than leaving the records where they are.
4255
+ */
3136
4256
  stop() {
4257
+ if (drainPromise) return drainPromise;
3137
4258
  stopped = true;
3138
- flush();
4259
+ if (timer) {
4260
+ clearTimeout(timer);
4261
+ timer = null;
4262
+ }
4263
+ const terminate = () => {
4264
+ ended = true;
4265
+ lifetime?.abort();
4266
+ queue = [];
4267
+ outstanding.clear();
4268
+ };
4269
+ let budget = null;
4270
+ const spent = new Promise((resolve) => {
4271
+ budget = setTimeout(() => {
4272
+ terminate();
4273
+ resolve();
4274
+ }, STOP_BUDGET_MS2);
4275
+ if (typeof budget.unref === "function") budget.unref();
4276
+ });
4277
+ const work = (async () => {
4278
+ while (!ended) {
4279
+ if (outstanding.size > 0) {
4280
+ await Promise.all([...outstanding]);
4281
+ continue;
4282
+ }
4283
+ if (queue.length === 0) break;
4284
+ const before = queue.length;
4285
+ await flush();
4286
+ if (queue.length >= before) break;
4287
+ }
4288
+ })();
4289
+ drainPromise = Promise.race([work, spent]).then(() => {
4290
+ if (budget) clearTimeout(budget);
4291
+ });
4292
+ return drainPromise;
3139
4293
  }
3140
4294
  };
3141
4295
  }
@@ -3226,8 +4380,8 @@ async function createProtection(options = {}) {
3226
4380
  fetchImpl: options.fetchImpl
3227
4381
  }) : null;
3228
4382
  let detections = null;
4383
+ const ruleIdentity = (rule) => ({ id: rule?.id, category: rule?.category });
3229
4384
  const onDetect = (detection) => {
3230
- notify(userOnDetect, detection, "onDetect");
3231
4385
  if (detections) detections.record(detection);
3232
4386
  if (firewallLog && detection?.mode === "block") {
3233
4387
  firewallLog.record({
@@ -3238,6 +4392,12 @@ async function createProtection(options = {}) {
3238
4392
  userAgent: detection.userAgent
3239
4393
  });
3240
4394
  }
4395
+ const { capture: _evidence, ...forCallback } = detection ?? {};
4396
+ notify(
4397
+ userOnDetect,
4398
+ { ...forCallback, ...detection?.rule ? { rule: ruleIdentity(detection.rule) } : {} },
4399
+ "onDetect"
4400
+ );
3241
4401
  };
3242
4402
  const store = makeStore(options);
3243
4403
  const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
@@ -3247,29 +4407,58 @@ async function createProtection(options = {}) {
3247
4407
  notify(onError, new Error(message), "onError");
3248
4408
  console.warn(message);
3249
4409
  }
3250
- const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth });
3251
- let detectionReporting = "off";
3252
- if (options.reportDetections === true && options.siteUuid && telemetryEnabled()) {
3253
- if (!pulseAuth) {
3254
- detectionReporting = "unavailable-no-credential";
3255
- 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.";
3256
- notify(onError, new Error(message), "onError");
3257
- console.warn(message);
3258
- } else {
3259
- detectionReporting = "on";
4410
+ const cachedOrigin = async () => {
4411
+ const prior = await store.read();
4412
+ if (prior?.bundle) return "cache";
4413
+ return options.rules ? "bundled" : "empty";
4414
+ };
4415
+ const stateFor = (origin) => reportingState({
4416
+ siteUuid: options.siteUuid,
4417
+ ruleOrigin: origin,
4418
+ hasCredential: Boolean(pulseAuth),
4419
+ configOptOut: options.reportDetections === false
4420
+ });
4421
+ const preFetchState = stateFor(await cachedOrigin()).state;
4422
+ const bundle = await resolveRules(options, store, {
4423
+ timeoutMs: bootTimeoutMs,
4424
+ pulseAuth,
4425
+ detectionState: preFetchState
4426
+ });
4427
+ let detectionReporting = "not-enrolled";
4428
+ let currentOrigin;
4429
+ const applyReportingState = async (origin) => {
4430
+ currentOrigin = origin;
4431
+ const next = stateFor(origin);
4432
+ const changed = next.state !== detectionReporting;
4433
+ detectionReporting = next.state;
4434
+ if (next.reports && !detections) {
3260
4435
  detections = createDetectionReporter({
3261
4436
  siteUuid: options.siteUuid,
3262
4437
  baseUrl: options.pulseRulesUrl,
3263
4438
  pulseAuth,
3264
- // The bundle the guard is actually running, so a hit can be attributed to the rules that produced
3265
- // it rather than to whatever is current when the report is read. Kept current across refreshes —
3266
- // see the refresh tick below.
4439
+ // The bundle the guard is actually running, so a hit can be attributed to the rules that
4440
+ // produced it rather than to whatever is current when the report is read.
3267
4441
  rulesEtag: (await store.read())?.etag ?? null,
3268
4442
  fetchImpl: options.fetchImpl,
3269
4443
  flushMs: options.detectionFlushMs
3270
4444
  });
4445
+ } else if (!next.reports && detections) {
4446
+ detections.stop();
4447
+ detections = void 0;
3271
4448
  }
3272
- }
4449
+ if (changed && next.state === "unavailable-no-credential") {
4450
+ 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.";
4451
+ notify(onError, new Error(message), "onError");
4452
+ console.warn(message);
4453
+ }
4454
+ return next;
4455
+ };
4456
+ const applyAndAcknowledge = async (origin, declared) => {
4457
+ const settled = await applyReportingState(origin);
4458
+ if (settled.state !== declared && detections) detections.announce(settled.state);
4459
+ return settled;
4460
+ };
4461
+ await applyAndAcknowledge(bundle.source?.origin, preFetchState);
3273
4462
  let mode = resolveMode(options, bundle);
3274
4463
  let requestRules;
3275
4464
  let responseRules;
@@ -3316,6 +4505,19 @@ async function createProtection(options = {}) {
3316
4505
  };
3317
4506
  const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
3318
4507
  const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
4508
+ const planCache = createPlanCache();
4509
+ const evidenceFrom = (result) => {
4510
+ if (!detections || !result?.rule) return void 0;
4511
+ let entry;
4512
+ try {
4513
+ entry = planCache.for(result.rule);
4514
+ } catch (err) {
4515
+ notify(onError, err, "onError");
4516
+ return void 0;
4517
+ }
4518
+ if (!permitsAnything(entry.plan)) return { plan: entry.reference };
4519
+ return { plan: entry.reference, ...captureValues(entry.plan, result.resolver) };
4520
+ };
3319
4521
  const decide = (phase, result, block, allow2, ctx = {}) => {
3320
4522
  if (!result || !result.blocked) return allow2();
3321
4523
  const effectiveMode = ruleMode(result.rule);
@@ -3330,7 +4532,12 @@ async function createProtection(options = {}) {
3330
4532
  method: ctx.method,
3331
4533
  path: ctx.path,
3332
4534
  ip: ctx.ip,
3333
- userAgent: ctx.userAgent
4535
+ // Provenance travels with the address. Without it a consumer cannot tell an observed peer from a
4536
+ // value read out of a forwarded header, and `null` from "there was no address to establish".
4537
+ clientIpSource: ctx.clientIpSource,
4538
+ userAgent: ctx.userAgent,
4539
+ // Derived from the reading this decision was made on, before that reading goes out of scope.
4540
+ capture: evidenceFrom(result)
3334
4541
  });
3335
4542
  return effectiveMode === "block" ? block() : allow2();
3336
4543
  };
@@ -3353,7 +4560,18 @@ async function createProtection(options = {}) {
3353
4560
  }
3354
4561
  if (!result.blocked) continue;
3355
4562
  const responseMode = ruleMode(rule);
3356
- onDetect({ phase: "response", mode: responseMode, category: rule.category, rule, message: result.message });
4563
+ onDetect({
4564
+ phase: "response",
4565
+ mode: responseMode,
4566
+ category: rule.category,
4567
+ rule,
4568
+ message: result.message,
4569
+ ...requestMetaFromContext(reqCtx),
4570
+ // A response detection names its capture policy like any other. Response sources are never
4571
+ // capturable, so a response-only rule reports a plan that permitted nothing — which is the
4572
+ // policy, and a different fact from a rule that found nothing.
4573
+ capture: evidenceFrom(result)
4574
+ });
3357
4575
  if (responseMode !== "block") continue;
3358
4576
  if (redactors && redactors.length) {
3359
4577
  const spanRedactors = redactors.filter((r) => !r.jsonPath);
@@ -3390,17 +4608,88 @@ async function createProtection(options = {}) {
3390
4608
  for (const rule of headerMutations) applyHeaderMutation(headers, rule);
3391
4609
  return { verdict: "redact", body, headers };
3392
4610
  };
3393
- const reqContextFromFetch = (request) => {
4611
+ const shapeExpressRequest = (req) => {
4612
+ const client = resolveClientIp({
4613
+ peer: req?.socket?.remoteAddress,
4614
+ headers: req?.headers ?? {},
4615
+ trustedProxy: options.trustedProxy
4616
+ });
4617
+ return {
4618
+ client,
4619
+ shaped: {
4620
+ // Own copies of everything a rule can address. Listed rather than spread, so a field the engine
4621
+ // gains has to be added here deliberately instead of appearing to work by accident.
4622
+ // Through the same gate as the engine's own normalisation: this projection turns whatever it reads
4623
+ // into an OWN property, so reading a polluted prototype here would launder it into evidence that
4624
+ // no later own-property check could tell from the real thing.
4625
+ method: requestField(req, "method"),
4626
+ url: requestField(req, "url"),
4627
+ originalUrl: requestField(req, "originalUrl"),
4628
+ headers: requestField(req, "headers"),
4629
+ query: requestField(req, "query"),
4630
+ body: requestField(req, "body"),
4631
+ files: requestField(req, "files"),
4632
+ cookies: requestField(req, "cookies"),
4633
+ socket: requestField(req, "socket"),
4634
+ // The verbatim body, when a caller kept one. A `raw` rule reads it directly, and the engine
4635
+ // otherwise reconstructs raw by re-serialising the parsed body — which cannot carry what parsing
4636
+ // did not keep: a body that failed to parse at all, a duplicate key where only the last value
4637
+ // survives, or the exact bytes a signature was written against.
4638
+ //
4639
+ // Own property only. Evidence is what this request actually carried: a value reachable through a
4640
+ // polluted prototype is not, and materialising it here would turn it into evidence indistinguish-
4641
+ // able from real bytes — firing every raw rule that matches it. The adapters that capture real
4642
+ // bytes define `_rawBody` on the request object itself.
4643
+ ...Object.hasOwn(req ?? {}, "_rawBody") ? { _rawBody: req._rawBody } : {},
4644
+ // The resolved address, and the resolution itself for the consumers downstream.
4645
+ ip: client.ip ?? "",
4646
+ _clientIp: client
4647
+ }
4648
+ };
4649
+ };
4650
+ const screenFetchRequest = async (request) => {
4651
+ let result;
4652
+ let shaped;
4653
+ try {
4654
+ shaped = await fromFetchRequest(request, { trustedProxy: options.trustedProxy });
4655
+ result = engine.evaluate(shaped);
4656
+ } catch (err) {
4657
+ notify(onError, err, "onError");
4658
+ return { blocked: null, client: void 0 };
4659
+ }
4660
+ const blocked = decide(
4661
+ "request",
4662
+ result,
4663
+ () => blockResponse(result, request),
4664
+ () => null,
4665
+ requestMeta(shaped, request)
4666
+ );
4667
+ return { blocked, client: shaped?._clientIp };
4668
+ };
4669
+ const reqContextFromFetch = (request, client) => {
3394
4670
  try {
3395
4671
  const u = new URL(request.url);
3396
4672
  const headers = headerObject(request.headers);
3397
4673
  if (!headers.host) headers.host = u.host;
3398
- return { method: request.method, originalUrl: u.pathname + u.search, headers };
4674
+ const resolved = client ?? { ip: null, source: "unavailable" };
4675
+ return {
4676
+ method: request.method,
4677
+ originalUrl: u.pathname + u.search,
4678
+ headers,
4679
+ ip: resolved.ip ?? "",
4680
+ _clientIp: resolved
4681
+ };
3399
4682
  } catch {
3400
4683
  return void 0;
3401
4684
  }
3402
4685
  };
3403
- const reqContextFromNode = (req) => req ? { method: req.method, originalUrl: req.url, headers: req.headers || {} } : void 0;
4686
+ const reqContextFromNode = (req, client) => req ? {
4687
+ method: req.method,
4688
+ originalUrl: req.url,
4689
+ headers: req.headers || {},
4690
+ ip: client?.ip ?? "",
4691
+ _clientIp: client ?? { ip: null, source: "unavailable" }
4692
+ } : void 0;
3404
4693
  const screenResp = async (response, reqCtx) => {
3405
4694
  const read = await readTextResponse(response, screenCap);
3406
4695
  if (read.skip) {
@@ -3527,7 +4816,23 @@ async function createProtection(options = {}) {
3527
4816
  }
3528
4817
  if (!result.blocked) return false;
3529
4818
  const egressMode = ruleMode(result.rule);
3530
- onDetect({ phase: "egress", mode: egressMode, category: result.rule?.category, rule: result.rule, message: result.message });
4819
+ let egressPath = null;
4820
+ try {
4821
+ const u = new URL(url);
4822
+ egressPath = u.pathname + u.search;
4823
+ } catch {
4824
+ egressPath = typeof url === "string" ? url : null;
4825
+ }
4826
+ onDetect({
4827
+ phase: "egress",
4828
+ mode: egressMode,
4829
+ category: result.rule?.category,
4830
+ rule: result.rule,
4831
+ message: result.message,
4832
+ method: typeof method === "string" ? method : null,
4833
+ path: egressPath,
4834
+ capture: evidenceFrom(result)
4835
+ });
3531
4836
  return egressMode === "block";
3532
4837
  };
3533
4838
  const protection = {
@@ -3548,28 +4853,26 @@ async function createProtection(options = {}) {
3548
4853
  },
3549
4854
  // Screen a fetch Response through the response-phase rules (redact/block). Used by
3550
4855
  // .fetch(), and by the Supabase guard on its forwarded upstream response.
3551
- screenResponse: (response, request) => screenResp(response, request ? reqContextFromFetch(request) : void 0),
4856
+ // A standalone response screen with no request phase of its own — the Supabase guard's forwarded
4857
+ // upstream response. It resolves once here, which is the only resolution for this call.
4858
+ screenResponse: (response, request) => screenResp(
4859
+ response,
4860
+ request ? reqContextFromFetch(
4861
+ request,
4862
+ resolveClientIp({ headers: headerObject(request.headers), trustedProxy: options.trustedProxy })
4863
+ ) : void 0
4864
+ ),
3552
4865
  // (request) => Response | null (null = allow, caller proceeds). Request phase only.
3553
4866
  fetchGuard() {
3554
- return async (request) => {
3555
- let result;
3556
- try {
3557
- result = engine.evaluate(await fromFetchRequest(request));
3558
- } catch (err) {
3559
- notify(onError, err, "onError");
3560
- return null;
3561
- }
3562
- return decide("request", result, () => blockResponse(result, request), () => null, fetchRequestMeta(request));
3563
- };
4867
+ return async (request) => (await screenFetchRequest(request)).blocked;
3564
4868
  },
3565
4869
  // Wrap a fetch handler: screens the request, then the response (redact/block).
3566
4870
  fetch(handler) {
3567
- const guard = protection.fetchGuard();
3568
4871
  return async (request, ...rest) => {
3569
- const blocked = await guard(request);
4872
+ const { blocked, client } = await screenFetchRequest(request);
3570
4873
  if (blocked) return blocked;
3571
4874
  const response = await handler(request, ...rest);
3572
- return screenResp(response, reqContextFromFetch(request));
4875
+ return screenResp(response, reqContextFromFetch(request, client));
3573
4876
  };
3574
4877
  },
3575
4878
  // Express middleware (request phase; expects express-parsed req.query/req.body).
@@ -3577,11 +4880,12 @@ async function createProtection(options = {}) {
3577
4880
  express(exprOptions = {}) {
3578
4881
  return (req, res, next) => {
3579
4882
  let result;
4883
+ const { shaped, client } = shapeExpressRequest(req);
3580
4884
  try {
3581
- result = engine.evaluate(req);
4885
+ result = engine.evaluate(shaped);
3582
4886
  } catch (err) {
3583
4887
  notify(onError, err, "onError");
3584
- if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
4888
+ if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
3585
4889
  return next();
3586
4890
  }
3587
4891
  decide(
@@ -3595,10 +4899,10 @@ async function createProtection(options = {}) {
3595
4899
  }
3596
4900
  },
3597
4901
  () => {
3598
- if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
4902
+ if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
3599
4903
  next();
3600
4904
  },
3601
- nodeRequestMeta(req)
4905
+ nodeRequestMeta(req, client)
3602
4906
  );
3603
4907
  };
3604
4908
  },
@@ -3637,7 +4941,7 @@ async function createProtection(options = {}) {
3637
4941
  let shaped;
3638
4942
  let result;
3639
4943
  try {
3640
- shaped = fromNodeRequest(req, rawBody);
4944
+ shaped = fromNodeRequest(req, rawBody, { trustedProxy: options.trustedProxy });
3641
4945
  if (parsedBody !== void 0 && parsedBody !== null) shaped.body = parsedBody;
3642
4946
  result = engine.evaluate(shaped);
3643
4947
  } catch (err) {
@@ -3659,10 +4963,10 @@ async function createProtection(options = {}) {
3659
4963
  },
3660
4964
  () => {
3661
4965
  if (req.body === void 0) req.body = shaped.body;
3662
- if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
4966
+ if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, shaped?._clientIp));
3663
4967
  next();
3664
4968
  },
3665
- nodeRequestMeta(req)
4969
+ nodeRequestMeta(req, shaped?._clientIp)
3666
4970
  );
3667
4971
  }
3668
4972
  }
@@ -3685,7 +4989,7 @@ async function createProtection(options = {}) {
3685
4989
  let reporter = null;
3686
4990
  if (refreshable && options.siteUuid && options.reportManifest !== false && cwd) {
3687
4991
  try {
3688
- ({ reportManifest: reporter } = await import("./refresh-manifest-ZSP76JWQ.js"));
4992
+ ({ reportManifest: reporter } = await import("./refresh-manifest-SWCPQ52Z.js"));
3689
4993
  } catch (err) {
3690
4994
  notify(onError, err, "onError");
3691
4995
  }
@@ -3698,9 +5002,15 @@ async function createProtection(options = {}) {
3698
5002
  notify(onError, err, "onError");
3699
5003
  }
3700
5004
  }
3701
- const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
5005
+ const declaredState = stateFor(currentOrigin).state;
5006
+ const next = await resolveRules(options, store, {
5007
+ timeoutMs: options.refreshTimeoutMs,
5008
+ pulseAuth,
5009
+ detectionState: declaredState
5010
+ });
3702
5011
  mode = resolveMode(options, next);
3703
5012
  applyBundle(next);
5013
+ await applyAndAcknowledge(next.source?.origin, declaredState);
3704
5014
  if (detections) detections.setRulesEtag((await store.read())?.etag ?? null);
3705
5015
  return next.source ?? { ok: true };
3706
5016
  };
@@ -3711,12 +5021,20 @@ async function createProtection(options = {}) {
3711
5021
  const loop = options.refreshMs > 0 && live ? startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError }) : null;
3712
5022
  protection.stop = () => {
3713
5023
  loop?.stop();
3714
- firewallLog?.stop();
3715
- detections?.stop();
5024
+ const outstanding = [firewallLog?.stop(), detections?.stop()].filter(
5025
+ (wait) => wait && typeof wait.then === "function"
5026
+ );
5027
+ return Promise.all(outstanding).then(() => void 0);
3716
5028
  };
3717
5029
  protection.stopRefresh = protection.stop;
3718
- protection.detectionReporting = detectionReporting;
3719
- if (detections) protection.detectionHealth = () => detections.health();
5030
+ Object.defineProperty(protection, "detectionReporting", {
5031
+ get: () => detectionReporting,
5032
+ enumerable: true
5033
+ });
5034
+ Object.defineProperty(protection, "detectionHealth", {
5035
+ get: () => detections ? () => detections.health() : void 0,
5036
+ enumerable: true
5037
+ });
3720
5038
  return protection;
3721
5039
  }
3722
5040
  function resolveMode(options, bundle) {
@@ -3897,6 +5215,8 @@ function extractRedactors(rule) {
3897
5215
  }
3898
5216
  } else if ((m.type === "contains" || m.type === "stripos") && m.value != null) {
3899
5217
  out.push({ literal: String(m.value) });
5218
+ } else if (m.type === "jwt_claim_equals" && typeof m.claim === "string") {
5219
+ out.push({ jwtClaim: { claim: m.claim, value: String(m.value ?? "") } });
3900
5220
  } else if (m.type === "array_key_value" && m.match && isBodyParam(c.parameter)) {
3901
5221
  const keys = Array.isArray(m.key) ? m.key : [m.key];
3902
5222
  for (const key of keys) {
@@ -3915,7 +5235,11 @@ function applyRedactors(body, redactors, mask, transform) {
3915
5235
  let out = body;
3916
5236
  for (const r of redactors) {
3917
5237
  if (r.re) out = out.replace(r.re, transform ? (m) => transform(m) : mask);
3918
- else if (r.literal) {
5238
+ else if (r.jwtClaim) {
5239
+ for (const token of jwtClaimSpans(out, r.jwtClaim.claim, r.jwtClaim.value)) {
5240
+ out = out.split(token).join(transform ? transform(token) : mask);
5241
+ }
5242
+ } else if (r.literal) {
3919
5243
  const re = new RegExp(r.literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
3920
5244
  out = out.replace(re, (m) => transform ? transform(m) : mask);
3921
5245
  }
@@ -4116,30 +5440,48 @@ function defaultOnDetect({ phase, mode, category, rule, message }) {
4116
5440
  const tag = mode === "block" ? "BLOCK" : "DETECT (dry-run)";
4117
5441
  console.warn(`[patchstack] ${tag} phase=${phase ?? "request"} category=${category ?? "?"} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
4118
5442
  }
4119
- function fetchRequestMeta(request) {
4120
- if (!request) return {};
4121
- let path = null;
4122
- try {
4123
- path = new URL(request.url).pathname;
4124
- } catch {
4125
- path = typeof request.url === "string" ? request.url : null;
4126
- }
5443
+ function requestMetaFromContext(reqCtx) {
5444
+ if (!reqCtx) return {};
5445
+ const client = reqCtx._clientIp ?? { ip: null, source: "unavailable" };
4127
5446
  return {
4128
- method: request.method ?? null,
4129
- path,
4130
- ip: request.headers?.get?.("x-forwarded-for") ?? null,
4131
- userAgent: request.headers?.get?.("user-agent") ?? null
5447
+ method: reqCtx.method ?? null,
5448
+ // Path AND query. The reporter is what drops the query's VALUES, keeping its parameter names, so
5449
+ // trimming it here would leave a Fetch or response detection unable to say what was requested.
5450
+ path: typeof reqCtx.originalUrl === "string" ? reqCtx.originalUrl : null,
5451
+ ip: client.ip,
5452
+ clientIpSource: client.source,
5453
+ userAgent: reqCtx.headers?.["user-agent"] ?? null
4132
5454
  };
4133
5455
  }
4134
- function nodeRequestMeta(req) {
5456
+ function requestMeta(shaped, request) {
5457
+ const client = shaped?._clientIp ?? { ip: null, source: "unavailable" };
5458
+ let path = null;
5459
+ let method = null;
5460
+ let userAgent = null;
5461
+ if (request) {
5462
+ try {
5463
+ const u = new URL(request.url);
5464
+ path = u.pathname + u.search;
5465
+ } catch {
5466
+ path = typeof request.url === "string" ? request.url : null;
5467
+ }
5468
+ method = request.method ?? null;
5469
+ userAgent = request.headers?.get?.("user-agent") ?? null;
5470
+ }
5471
+ return { method, path, ip: client.ip, clientIpSource: client.source, userAgent };
5472
+ }
5473
+ function nodeRequestMeta(req, client) {
4135
5474
  if (!req) return {};
4136
5475
  const headers = req.headers ?? {};
4137
5476
  const ua = headers["user-agent"] ?? headers["User-Agent"];
4138
- const fwd = headers["x-forwarded-for"] ?? headers["X-Forwarded-For"];
5477
+ const resolved = client ?? { ip: null, source: "unavailable" };
4139
5478
  return {
4140
5479
  method: req.method ?? null,
4141
5480
  path: req.originalUrl || req.url || null,
4142
- ip: req.ip ?? (typeof fwd === "string" ? fwd : null),
5481
+ // Never `req.ip`: under Express's `trust proxy` that is header-derived by a policy this guard has not
5482
+ // verified, and a second derivation could disagree with the one the engine evaluated.
5483
+ ip: resolved.ip,
5484
+ clientIpSource: resolved.source,
4143
5485
  userAgent: typeof ua === "string" ? ua : Array.isArray(ua) ? ua[0] : null
4144
5486
  };
4145
5487
  }