@patchstack/connect 0.3.31 → 0.3.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/protect.cjs CHANGED
@@ -5,8 +5,13 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
6
  var __getProtoOf = Object.getPrototypeOf;
7
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
- var __esm = (fn, res) => function __init() {
9
- return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
8
+ var __esm = (fn, res, err) => function __init() {
9
+ if (err) throw err[0];
10
+ try {
11
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
12
+ } catch (e) {
13
+ throw err = [e], e;
14
+ }
10
15
  };
11
16
  var __export = (target, all) => {
12
17
  for (var name in all)
@@ -1624,6 +1629,220 @@ __export(runtime_exports, {
1624
1629
  });
1625
1630
  module.exports = __toCommonJS(runtime_exports);
1626
1631
 
1632
+ // src/protect/client-ip.js
1633
+ var ZONE = /^[A-Za-z0-9._-]{1,64}$/;
1634
+ var POLICY_FIELDS = Object.freeze(["peers", "hops", "header", "isTrusted"]);
1635
+ var IP_SOURCES = Object.freeze(["runtime", "trusted-proxy", "unavailable"]);
1636
+ var DEFAULT_FORWARDED_HEADER = "x-forwarded-for";
1637
+ function parseIpv4(value) {
1638
+ const parts = value.split(".");
1639
+ if (parts.length !== 4) return null;
1640
+ const octets = [];
1641
+ for (const part of parts) {
1642
+ if (!/^\d{1,3}$/.test(part)) return null;
1643
+ if (part.length > 1 && part.startsWith("0")) return null;
1644
+ const n = Number(part);
1645
+ if (n > 255) return null;
1646
+ octets.push(n);
1647
+ }
1648
+ return octets;
1649
+ }
1650
+ function parseIpv6(value) {
1651
+ if (value === "" || value.includes(":::")) return null;
1652
+ let head = value;
1653
+ let embedded = null;
1654
+ const lastColon = head.lastIndexOf(":");
1655
+ const tail = lastColon === -1 ? "" : head.slice(lastColon + 1);
1656
+ if (tail.includes(".")) {
1657
+ embedded = parseIpv4(tail);
1658
+ if (embedded === null) return null;
1659
+ head = head.slice(0, lastColon + 1) + "0:0";
1660
+ }
1661
+ const halves = head.split("::");
1662
+ if (halves.length > 2) return null;
1663
+ const readGroups = (text) => {
1664
+ if (text === "") return [];
1665
+ const groups2 = [];
1666
+ for (const g of text.split(":")) {
1667
+ if (!/^[0-9a-fA-F]{1,4}$/.test(g)) return null;
1668
+ groups2.push(Number.parseInt(g, 16));
1669
+ }
1670
+ return groups2;
1671
+ };
1672
+ const left = readGroups(halves[0]);
1673
+ const right = halves.length === 2 ? readGroups(halves[1]) : [];
1674
+ if (left === null || right === null) return null;
1675
+ let groups;
1676
+ if (halves.length === 2) {
1677
+ const fill = 8 - (left.length + right.length);
1678
+ if (fill < 1) return null;
1679
+ groups = [...left, ...new Array(fill).fill(0), ...right];
1680
+ } else {
1681
+ groups = left;
1682
+ }
1683
+ if (groups.length !== 8) return null;
1684
+ if (embedded !== null) {
1685
+ groups[6] = embedded[0] << 8 | embedded[1];
1686
+ groups[7] = embedded[2] << 8 | embedded[3];
1687
+ }
1688
+ return groups;
1689
+ }
1690
+ function toNumeric(value) {
1691
+ const text = String(value).trim();
1692
+ if (text === "") return null;
1693
+ const zoneParts = text.split("%");
1694
+ if (zoneParts.length > 2) return null;
1695
+ const addr = zoneParts[0];
1696
+ const zone = zoneParts.length === 2 ? zoneParts[1] : null;
1697
+ if (zone !== null && (!ZONE.test(zone) || !addr.includes(":"))) return null;
1698
+ const v4 = parseIpv4(addr);
1699
+ if (v4 !== null) {
1700
+ return {
1701
+ bits: 32,
1702
+ value: v4.reduce((acc, octet) => acc << 8n | BigInt(octet), 0n),
1703
+ canonical: v4.join(".")
1704
+ };
1705
+ }
1706
+ if (!addr.includes(":")) return null;
1707
+ const v6 = parseIpv6(addr);
1708
+ if (v6 === null) return null;
1709
+ const mapped = v6.slice(0, 5).every((g) => g === 0) && v6[5] === 65535;
1710
+ if (mapped) {
1711
+ if (zone !== null) return null;
1712
+ const octets = [v6[6] >> 8, v6[6] & 255, v6[7] >> 8, v6[7] & 255];
1713
+ return { bits: 32, value: BigInt(v6[6]) << 16n | BigInt(v6[7]), canonical: octets.join(".") };
1714
+ }
1715
+ return {
1716
+ bits: 128,
1717
+ value: v6.reduce((acc, group) => acc << 16n | BigInt(group), 0n),
1718
+ // The zone survives: without it a link-local address does not identify an interface.
1719
+ canonical: zone === null ? canonicalIpv6(v6) : `${canonicalIpv6(v6)}%${zone}`
1720
+ };
1721
+ }
1722
+ function canonicalIpv6(groups) {
1723
+ let bestStart = -1;
1724
+ let bestLength = 0;
1725
+ let runStart = -1;
1726
+ for (let i = 0; i <= groups.length; i++) {
1727
+ if (i < groups.length && groups[i] === 0) {
1728
+ if (runStart === -1) runStart = i;
1729
+ } else if (runStart !== -1) {
1730
+ const length = i - runStart;
1731
+ if (length > bestLength) {
1732
+ bestLength = length;
1733
+ bestStart = runStart;
1734
+ }
1735
+ runStart = -1;
1736
+ }
1737
+ }
1738
+ const hex = groups.map((g) => g.toString(16));
1739
+ if (bestLength < 2) return hex.join(":");
1740
+ const head = hex.slice(0, bestStart).join(":");
1741
+ const tail = hex.slice(bestStart + bestLength).join(":");
1742
+ return `${head}::${tail}`;
1743
+ }
1744
+ function canonicalIp(value) {
1745
+ return toNumeric(value)?.canonical ?? null;
1746
+ }
1747
+ function parseCidr(entry) {
1748
+ if (typeof entry !== "string") return null;
1749
+ if (entry.includes("%")) return null;
1750
+ const parts = entry.trim().split("/");
1751
+ if (parts.length > 2) return null;
1752
+ const numeric = toNumeric(parts[0]);
1753
+ if (numeric === null) return null;
1754
+ let length = numeric.bits;
1755
+ if (parts.length === 2) {
1756
+ if (!/^\d{1,3}$/.test(parts[1])) return null;
1757
+ length = Number(parts[1]);
1758
+ if (length > numeric.bits) return null;
1759
+ }
1760
+ const shift = BigInt(numeric.bits - length);
1761
+ return { bits: numeric.bits, network: numeric.value >> shift, shift };
1762
+ }
1763
+ function readTrustPolicy(trustedProxy) {
1764
+ if (trustedProxy === null || typeof trustedProxy !== "object" || Array.isArray(trustedProxy)) return null;
1765
+ const has = (field) => Object.hasOwn(trustedProxy, field);
1766
+ for (const key of Object.keys(trustedProxy)) {
1767
+ if (!POLICY_FIELDS.includes(key)) return null;
1768
+ }
1769
+ if (has("header") && (typeof trustedProxy.header !== "string" || trustedProxy.header.trim() === "")) {
1770
+ return null;
1771
+ }
1772
+ if (has("hops") && (!Number.isInteger(trustedProxy.hops) || trustedProxy.hops < 1)) return null;
1773
+ if (has("isTrusted") && typeof trustedProxy.isTrusted !== "function") return null;
1774
+ const header = has("header") ? trustedProxy.header.trim().toLowerCase() : DEFAULT_FORWARDED_HEADER;
1775
+ const cidrs = [];
1776
+ if (has("peers")) {
1777
+ if (!Array.isArray(trustedProxy.peers) || trustedProxy.peers.length === 0) return null;
1778
+ for (const entry of trustedProxy.peers) {
1779
+ const parsed = parseCidr(entry);
1780
+ if (parsed === null) return null;
1781
+ cidrs.push(parsed);
1782
+ }
1783
+ }
1784
+ const hops = has("hops") ? trustedProxy.hops : null;
1785
+ const predicate = has("isTrusted") ? trustedProxy.isTrusted : null;
1786
+ if (cidrs.length === 0 && hops === null && predicate === null) return null;
1787
+ return { header, cidrs, hops, predicate };
1788
+ }
1789
+ function isTrustedPeer(policy, value) {
1790
+ const numeric = toNumeric(value);
1791
+ if (numeric === null) return false;
1792
+ for (const cidr of policy.cidrs) {
1793
+ if (cidr.bits === numeric.bits && numeric.value >> cidr.shift === cidr.network) return true;
1794
+ }
1795
+ if (policy.predicate !== null) {
1796
+ try {
1797
+ if (policy.predicate(String(value)) === true) return true;
1798
+ } catch {
1799
+ return false;
1800
+ }
1801
+ }
1802
+ return false;
1803
+ }
1804
+ var UNSPECIFIED = /* @__PURE__ */ new Set(["0.0.0.0", "::"]);
1805
+ function identifyingIp(value) {
1806
+ const canonical = canonicalIp(value);
1807
+ return canonical === null || UNSPECIFIED.has(canonical) ? null : canonical;
1808
+ }
1809
+ function chainFrom(headers, header) {
1810
+ if (headers === null || typeof headers !== "object" || !Object.hasOwn(headers, header)) return [];
1811
+ const raw = headers[header];
1812
+ const value = Array.isArray(raw) ? raw.join(",") : raw;
1813
+ if (typeof value !== "string" || value.trim() === "") return [];
1814
+ return value.split(",").map((entry) => entry.trim()).filter((entry) => entry !== "");
1815
+ }
1816
+ function resolveClientIp(input) {
1817
+ const headers = input.headers ?? {};
1818
+ const peer = identifyingIp(input.peer);
1819
+ const policy = readTrustPolicy(input.trustedProxy);
1820
+ if (peer === null) return { ip: null, source: "unavailable" };
1821
+ if (policy === null) return { ip: peer, source: "runtime" };
1822
+ const gatedByAddress = policy.cidrs.length > 0 || policy.predicate !== null;
1823
+ const peerTrusted = gatedByAddress ? isTrustedPeer(policy, peer) : policy.hops !== null;
1824
+ if (!peerTrusted) return { ip: peer, source: "runtime" };
1825
+ const chain = chainFrom(headers, policy.header);
1826
+ if (chain.length === 0) return { ip: peer, source: "runtime" };
1827
+ if (policy.hops !== null) {
1828
+ const index = chain.length - policy.hops;
1829
+ const candidate = index >= 0 ? chain[index] : void 0;
1830
+ const canonical = identifyingIp(candidate);
1831
+ return canonical === null ? { ip: peer, source: "runtime" } : { ip: canonical, source: "trusted-proxy" };
1832
+ }
1833
+ for (let i = chain.length - 1; i >= 0; i--) {
1834
+ const canonical = identifyingIp(chain[i]);
1835
+ if (canonical === null) return { ip: peer, source: "runtime" };
1836
+ if (!isTrustedPeer(policy, canonical)) return { ip: canonical, source: "trusted-proxy" };
1837
+ }
1838
+ return { ip: peer, source: "runtime" };
1839
+ }
1840
+ function clientIpFields(resolved) {
1841
+ const established = resolved?.source === "runtime" || resolved?.source === "trusted-proxy";
1842
+ const address = identifyingIp(resolved?.ip);
1843
+ return established && address !== null ? { client_ip: address, client_ip_source: resolved.source } : { client_ip_source: "unavailable" };
1844
+ }
1845
+
1627
1846
  // src/protect/safe-origin.js
1628
1847
  function isSafeOrigin(value) {
1629
1848
  try {
@@ -1980,7 +2199,7 @@ var RequestResolver = class {
1980
2199
  return req.headers?.host ? [req.headers.host] : [];
1981
2200
  case "REMOTE_ADDR":
1982
2201
  case "ip":
1983
- return [req.ip ?? req.socket?.remoteAddress ?? ""];
2202
+ return [typeof req.ip === "string" ? req.ip : ""];
1984
2203
  case "CONTENT_TYPE":
1985
2204
  return req.headers?.["content-type"] ? [req.headers["content-type"]] : [];
1986
2205
  case "CONTENT_LENGTH":
@@ -2321,14 +2540,29 @@ function serializeForRawDetection(body, visited = /* @__PURE__ */ new Set(), isR
2321
2540
  });
2322
2541
  return "{" + parts.join(",") + "}";
2323
2542
  }
2543
+ var INHERITED_ACCESSORS = /* @__PURE__ */ new Set(["headers", "query"]);
2544
+ function requestField(req, key) {
2545
+ if (req === null || typeof req !== "object") return void 0;
2546
+ if (Object.hasOwn(req, key)) return req[key];
2547
+ if (!INHERITED_ACCESSORS.has(key)) return void 0;
2548
+ let holder = Object.getPrototypeOf(req);
2549
+ while (holder !== null && !Object.hasOwn(holder, key)) holder = Object.getPrototypeOf(holder);
2550
+ if (holder === null || holder === Object.prototype) return void 0;
2551
+ const descriptor = Object.getOwnPropertyDescriptor(holder, key);
2552
+ if (typeof descriptor?.get !== "function") return void 0;
2553
+ return req[key];
2554
+ }
2324
2555
  function normalizeRequest(req, options = {}) {
2325
- const rawBody = typeof req._rawBody === "string" ? req._rawBody : serializeForRawDetection(req.body ?? null);
2556
+ const ownRaw = Object.hasOwn(req ?? {}, "_rawBody") && typeof req._rawBody === "string";
2557
+ const body = requestField(req, "body");
2558
+ const url = requestField(req, "url");
2559
+ const rawBody = ownRaw ? req._rawBody : serializeForRawDetection(body ?? null);
2326
2560
  return {
2327
- query: normalizeObject(req.query || {}, options),
2328
- body: normalizeObject(req.body || {}, options),
2329
- headers: normalizeObject(req.headers || {}, options),
2330
- url: normalize(req.url || "", options),
2331
- originalUrl: normalize(req.originalUrl || req.url || "", options),
2561
+ query: normalizeObject(requestField(req, "query") || {}, options),
2562
+ body: normalizeObject(body || {}, options),
2563
+ headers: normalizeObject(requestField(req, "headers") || {}, options),
2564
+ url: normalize(url || "", options),
2565
+ originalUrl: normalize(requestField(req, "originalUrl") || url || "", options),
2332
2566
  _rawBody: rawBody
2333
2567
  };
2334
2568
  }
@@ -2890,7 +3124,12 @@ var RuleEngine = class {
2890
3124
  return {
2891
3125
  blocked: true,
2892
3126
  rule,
2893
- message: rule.message ?? `Blocked by Patchstack WAF rule: ${rule.title ?? rule.id}`
3127
+ message: rule.message ?? `Blocked by Patchstack WAF rule: ${rule.title ?? rule.id}`,
3128
+ // The resolver this match was decided by, for whatever needs to report on it. A consumer
3129
+ // that re-read the request instead would be reading it a second time: a getter, a stream or
3130
+ // anything else that answers once can give a different value, and evidence that disagrees
3131
+ // with the match it belongs to is worse than none.
3132
+ resolver
2894
3133
  };
2895
3134
  }
2896
3135
  } catch (err) {
@@ -2985,1397 +3224,1625 @@ var RuleEngine = class {
2985
3224
  }
2986
3225
  };
2987
3226
 
2988
- // src/protect/engine/pulse-client.js
2989
- init_pulse_token();
2990
- var DEFAULT_BASE_URL2 = "https://api.patchstack.com/monitor/pulse";
2991
- var DEFAULT_CACHE_TTL2 = 3e5;
2992
- var JITTER_FRACTION2 = 0.1;
2993
- var PulseRuleClient = class {
2994
- #siteUuid;
2995
- #timeoutMs;
2996
- #baseUrl;
2997
- #cacheTtl;
2998
- #cache = null;
2999
- #cacheTime = null;
3000
- #ttlEffective = 0;
3001
- #etag;
3002
- #pulseAuth;
3003
- #reportsDetections;
3004
- constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, reportsDetections } = {}) {
3005
- this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
3006
- this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
3007
- this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL, DEFAULT_BASE_URL2, "rule endpoint");
3008
- this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL2;
3009
- this.#etag = etag ?? null;
3010
- this.#pulseAuth = pulseAuth ?? null;
3011
- this.#reportsDetections = reportsDetections === true;
3012
- if (!this.#siteUuid) {
3013
- throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
3227
+ // src/protect/rules/contract.js
3228
+ var SOURCES = Object.freeze({
3229
+ raw: Object.freeze({ keyed: false }),
3230
+ all: Object.freeze({ keyed: false }),
3231
+ false: Object.freeze({ keyed: false }),
3232
+ rules: Object.freeze({ keyed: false, group: true }),
3233
+ get: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
3234
+ post: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
3235
+ request: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
3236
+ cookie: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
3237
+ // A bare `files.<name>` resolves to the filename; an optional trailing attribute selects another part
3238
+ // of the upload. Optional, not required — filename-scoped rules use the bare form.
3239
+ files: Object.freeze({
3240
+ keyed: true,
3241
+ keys: "any",
3242
+ wildcard: true,
3243
+ optional_key_suffixes: Object.freeze(["content", "filename", "type"])
3244
+ }),
3245
+ server: Object.freeze({
3246
+ keyed: true,
3247
+ keys: Object.freeze([
3248
+ "REQUEST_URI",
3249
+ "REQUEST_METHOD",
3250
+ "HTTP_USER_AGENT",
3251
+ "HTTP_REFERER",
3252
+ "HTTP_HOST",
3253
+ "REMOTE_ADDR",
3254
+ "ip",
3255
+ "CONTENT_TYPE",
3256
+ "CONTENT_LENGTH"
3257
+ ]),
3258
+ key_prefixes: Object.freeze(["HTTP_"])
3259
+ }),
3260
+ response: Object.freeze({
3261
+ keyed: true,
3262
+ keys: Object.freeze(["status", "body", "headers"]),
3263
+ key_prefixes: Object.freeze(["header."])
3264
+ }),
3265
+ egress: Object.freeze({ keyed: true, keys: Object.freeze(["url", "host", "method"]) })
3266
+ });
3267
+ var MATCH_TYPES = Object.freeze([
3268
+ "array_in_array",
3269
+ "array_key_value",
3270
+ "contains",
3271
+ "cors_reflected",
3272
+ "cross_origin",
3273
+ "ctype_alnum",
3274
+ "ctype_digit",
3275
+ "ctype_special",
3276
+ "equals",
3277
+ "equals_strict",
3278
+ "hostname",
3279
+ "in_array",
3280
+ "inline_js_xss",
3281
+ "inline_xss",
3282
+ "internal_host",
3283
+ "is_numeric",
3284
+ "isset",
3285
+ "jwt_claim_equals",
3286
+ "less_than",
3287
+ "more_than",
3288
+ "not_contains",
3289
+ "not_in_array",
3290
+ "off_origin",
3291
+ "quotes",
3292
+ "regex",
3293
+ "stripos"
3294
+ ]);
3295
+ var PARAMETERLESS_MATCH_TYPES = Object.freeze(["cross_origin", "off_origin", "cors_reflected"]);
3296
+ var OPERANDLESS_MATCH_TYPES = Object.freeze([
3297
+ "isset",
3298
+ "quotes",
3299
+ "is_numeric",
3300
+ "ctype_alnum",
3301
+ "ctype_digit",
3302
+ "ctype_special",
3303
+ "inline_xss",
3304
+ "inline_js_xss",
3305
+ "internal_host",
3306
+ "cross_origin",
3307
+ "off_origin",
3308
+ "cors_reflected"
3309
+ ]);
3310
+ var OPERAND_SHAPES = Object.freeze([
3311
+ "positive-number",
3312
+ "boolean",
3313
+ "non-empty-string",
3314
+ "regex-literal",
3315
+ "string-or-string-list",
3316
+ "scalar",
3317
+ "string",
3318
+ "numeric",
3319
+ "scalar-or-non-empty-list",
3320
+ "non-empty-list",
3321
+ "object",
3322
+ "non-empty-object",
3323
+ "non-empty-string-list",
3324
+ "header-name-map",
3325
+ "header-name-list",
3326
+ "cookie-flags"
3327
+ ]);
3328
+ var COOKIE_FLAGS = Object.freeze(["httpOnly", "secure", "sameSite"]);
3329
+ var SAME_SITE_VALUES = Object.freeze(["Strict", "Lax", "None"]);
3330
+ var HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
3331
+ var REGEX_LITERAL = /^\/(.+?)\/([gimsuy]*)$/s;
3332
+ var MATCH_OPERANDS = Object.freeze({
3333
+ array_key_value: Object.freeze({
3334
+ required: Object.freeze(["key", "match"]),
3335
+ shapes: Object.freeze({ key: "string-or-string-list", match: "object" })
3336
+ }),
3337
+ // Decides on a JWT's DECODED payload rather than its shape, and yields the matching token spans so
3338
+ // `redact` masks only those tokens. `claim` names an own, top-level claim; `value` is compared with
3339
+ // exact string equality. Anything not positively identified — undecodable, not JSON, not an object,
3340
+ // oversized, claim missing, claim not a string, or a different value — is a no-match.
3341
+ jwt_claim_equals: Object.freeze({
3342
+ required: Object.freeze(["claim", "value"]),
3343
+ shapes: Object.freeze({ claim: "non-empty-string", value: "non-empty-string" })
3344
+ }),
3345
+ regex: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "regex-literal" }) }),
3346
+ contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
3347
+ stripos: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
3348
+ not_contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
3349
+ // A hostname is compared to `new URL(value).hostname`, so an empty one only ever equals the empty
3350
+ // hostname of an opaque-scheme URL (`file:`, `mailto:`, `data:`) — never a host, and a false positive
3351
+ // on those. The other three are the list-membership family: an empty list makes `in_array` and
3352
+ // `array_in_array` inert and `not_in_array` true for every value.
3353
+ hostname: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
3354
+ in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
3355
+ not_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
3356
+ array_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-list" }) }),
3357
+ // The comparison family. `equals` coerces, so any scalar can match. `equals_strict` does not, and its
3358
+ // subject is always a string by the time it is compared — so a number operand is unconditionally
3359
+ // inert, which is the trap the engine's own note about `'1' !== 1` describes.
3360
+ equals: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar" }) }),
3361
+ equals_strict: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "string" }) }),
3362
+ less_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) }),
3363
+ more_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) })
3364
+ });
3365
+ function operandShapeProblem(shape, value) {
3366
+ const isFilledString = (v) => typeof v === "string" && v.trim() !== "";
3367
+ const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
3368
+ const isHeaderName = (v) => typeof v === "string" && HEADER_NAME.test(v);
3369
+ switch (shape) {
3370
+ case "positive-number":
3371
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? null : "must be a finite positive number";
3372
+ case "boolean":
3373
+ return typeof value === "boolean" ? null : "must be true or false";
3374
+ case "non-empty-string":
3375
+ return isFilledString(value) ? null : "must be a non-empty string";
3376
+ case "regex-literal":
3377
+ if (!isFilledString(value)) return "must be a non-empty string";
3378
+ return REGEX_LITERAL.test(value) ? null : "must be a delimited pattern, as in /pattern/i";
3379
+ case "string-or-string-list":
3380
+ if (isFilledString(value)) return null;
3381
+ if (Array.isArray(value) && value.length > 0 && value.every(isFilledString)) return null;
3382
+ return "must be a non-empty string, or a non-empty list of them";
3383
+ case "scalar":
3384
+ return isScalar(value) ? null : "must be a string, number or boolean";
3385
+ case "string":
3386
+ return typeof value === "string" ? null : "must be a string (this comparison is type-strict)";
3387
+ case "numeric":
3388
+ return isScalar(value) && Number.isFinite(Number(value)) ? null : "must be a number";
3389
+ case "scalar-or-non-empty-list":
3390
+ if (isScalar(value)) return null;
3391
+ if (Array.isArray(value) && value.length > 0 && value.every(isScalar)) return null;
3392
+ return "must be a scalar, or a non-empty list of them";
3393
+ case "non-empty-list":
3394
+ return Array.isArray(value) && value.length > 0 && value.every(isScalar) ? null : "must be a non-empty list of scalars";
3395
+ case "header-name-map": {
3396
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
3397
+ const names = Object.keys(value);
3398
+ if (names.length === 0) return "must be an object with at least one entry";
3399
+ const bad = names.find((name) => !isHeaderName(name));
3400
+ return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
3014
3401
  }
3015
- }
3016
- async getRules() {
3017
- const now = Date.now();
3018
- if (this.#cache && this.#cacheTime !== null && now - this.#cacheTime < this.#ttlEffective) {
3019
- return this.#cache;
3402
+ case "header-name-list": {
3403
+ if (!Array.isArray(value) || value.length === 0) return "must be a non-empty list of HTTP header names";
3404
+ const bad = value.find((name) => !isHeaderName(name));
3405
+ return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
3020
3406
  }
3021
- const url = `${this.#baseUrl}/rules/${encodeURIComponent(this.#siteUuid)}`;
3022
- try {
3023
- const auth = await pulseAuthHeader(
3024
- { pulseAuth: this.#pulseAuth, endpoint: this.#baseUrl, timeoutMs: this.#timeoutMs },
3025
- fetch
3026
- );
3027
- const headers = { Accept: "application/json", ...auth };
3028
- if (this.#reportsDetections && typeof auth.Authorization === "string") {
3029
- headers["X-Patchstack-Detections"] = "enabled";
3407
+ case "cookie-flags": {
3408
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
3409
+ const unknown = Object.keys(value).filter((key) => !COOKIE_FLAGS.includes(key));
3410
+ if (unknown.length > 0) return `has no flag named ${unknown.map((k) => JSON.stringify(k)).join(", ")}`;
3411
+ for (const flag of ["httpOnly", "secure"]) {
3412
+ if (value[flag] !== void 0 && typeof value[flag] !== "boolean") {
3413
+ return `flag "${flag}" must be true or false`;
3414
+ }
3030
3415
  }
3031
- if (this.#etag) headers["If-None-Match"] = this.#etag;
3032
- const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
3033
- if (response.status === 304) {
3034
- this.#touch(now);
3035
- return this.#cache ?? { success: true, notModified: true, etag: this.#etag, firewall: [], whitelists: [], whitelist_keys: {} };
3036
- }
3037
- if (!response.ok) {
3038
- return { success: false, error: `API returned ${response.status}`, firewall: [], whitelists: [], whitelist_keys: {} };
3416
+ const sameSite = value.sameSite;
3417
+ const sameSiteOff = sameSite === false;
3418
+ if (sameSite !== void 0 && !sameSiteOff) {
3419
+ if (typeof sameSite !== "string" || !SAME_SITE_VALUES.some((v) => v.toLowerCase() === sameSite.toLowerCase())) {
3420
+ return `flag "sameSite" must be false, or one of ${SAME_SITE_VALUES.join(", ")}`;
3421
+ }
3039
3422
  }
3040
- const data = await response.json();
3041
- if (!data || !Array.isArray(data.firewall)) {
3042
- return { success: false, error: "unexpected response shape (no firewall array)", firewall: [], whitelists: [], whitelist_keys: {} };
3423
+ if (value.httpOnly === false && value.secure === false && sameSiteOff) {
3424
+ return "turns off every flag, so it would harden nothing";
3043
3425
  }
3044
- const result = {
3045
- success: true,
3046
- notModified: false,
3047
- etag: response.headers?.get?.("etag") ?? null,
3048
- firewall: data.firewall,
3049
- whitelists: Array.isArray(data.whitelists) ? data.whitelists : [],
3050
- whitelist_keys: data.whitelist_keys ?? {},
3051
- ...enforcementField(data)
3052
- };
3053
- this.#cache = result;
3054
- this.#etag = result.etag;
3055
- this.#touch(now);
3056
- return result;
3057
- } catch (err) {
3058
- return {
3059
- success: false,
3060
- error: err.name === "TimeoutError" ? "Request timed out" : err.message,
3061
- firewall: [],
3062
- whitelists: [],
3063
- whitelist_keys: {}
3064
- };
3426
+ return null;
3065
3427
  }
3428
+ case "object":
3429
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? null : "must be an object";
3430
+ case "non-empty-object":
3431
+ return value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0 ? null : "must be an object with at least one entry";
3432
+ case "non-empty-string-list":
3433
+ return Array.isArray(value) && value.length > 0 && value.every(isFilledString) ? null : "must be a non-empty list of non-empty strings";
3434
+ default:
3435
+ return null;
3066
3436
  }
3067
- #touch(now) {
3068
- this.#cacheTime = now;
3069
- this.#ttlEffective = this.#cacheTtl - Math.floor(Math.random() * this.#cacheTtl * JITTER_FRACTION2);
3070
- }
3071
- clearCache() {
3072
- this.#cache = null;
3073
- this.#cacheTime = null;
3074
- this.#etag = null;
3437
+ }
3438
+ var MUTATIONS = Object.freeze([
3439
+ "base64_decode",
3440
+ "getArrayValues",
3441
+ "htmlentitydecode",
3442
+ "intval",
3443
+ "json_decode",
3444
+ "json_encode",
3445
+ "urldecode"
3446
+ ]);
3447
+ var PHASES = Object.freeze(["request", "response", "egress"]);
3448
+ var ACTIONS = Object.freeze([
3449
+ "block",
3450
+ "redact",
3451
+ "encode",
3452
+ "set-header",
3453
+ "remove-header",
3454
+ "harden-cookie"
3455
+ ]);
3456
+ var RULE_PROPERTY_SHAPES = Object.freeze({
3457
+ max_bytes: "positive-number",
3458
+ bypass_limit: "boolean",
3459
+ ensure: "boolean",
3460
+ prefilter: "non-empty-string-list"
3461
+ });
3462
+ var ENFORCEMENT_VALUES = Object.freeze(["dry-run"]);
3463
+ var WHEN_KEYS = Object.freeze(["path", "method"]);
3464
+ var RULE_PROPERTIES = Object.freeze([
3465
+ "id",
3466
+ "rule_id",
3467
+ "title",
3468
+ "category",
3469
+ "phase",
3470
+ "action",
3471
+ "rule_v2",
3472
+ "when",
3473
+ "message",
3474
+ "enforcement",
3475
+ "source_revision",
3476
+ "prefilter",
3477
+ "max_bytes",
3478
+ "bypass_limit",
3479
+ "set_headers",
3480
+ "remove_headers",
3481
+ "cookie_flags",
3482
+ "ensure",
3483
+ "capture"
3484
+ ]);
3485
+ var CAPTURE_VERSION = 1;
3486
+ var CAPTURE_KEYS = Object.freeze(["version", "raw_chars"]);
3487
+ var CAPTURE_RAW_CHARS_MAX = 512;
3488
+ var NULL_EXEMPT_PROPERTIES = Object.freeze(["capture"]);
3489
+ function captureProblem(capture) {
3490
+ if (capture === void 0 || capture === null) return null;
3491
+ if (typeof capture !== "object" || Array.isArray(capture)) return "capture must be an object";
3492
+ for (const key of Object.keys(capture)) {
3493
+ if (!CAPTURE_KEYS.includes(key)) return `capture has no "${key}" \u2014 it takes ${CAPTURE_KEYS.join(", ")}`;
3494
+ }
3495
+ if (!Object.hasOwn(capture, "version")) return "capture must name the version it was written against";
3496
+ if (capture.version !== CAPTURE_VERSION) {
3497
+ return `capture version ${String(capture.version)} is not one this guard understands`;
3498
+ }
3499
+ if (!Object.hasOwn(capture, "raw_chars")) return "capture must say what it is opting into";
3500
+ const chars = capture.raw_chars;
3501
+ if (typeof chars !== "number" || !Number.isInteger(chars) || chars <= 0) {
3502
+ return "capture.raw_chars must be a positive whole number of characters";
3075
3503
  }
3076
- };
3077
- function enforcementField(data) {
3078
- if (!data || typeof data !== "object") return {};
3079
- const v = data.enforcement ?? data.mode;
3080
- return v === "block" || v === "dry-run" ? { enforcement: v } : {};
3504
+ return null;
3081
3505
  }
3082
-
3083
- // src/protect/engine/fetch.js
3084
- var MAX_BODY_BYTES = 1024 * 1024;
3085
- async function fromFetchRequest(request, options = {}) {
3086
- const maxBodyBytes = options.maxBodyBytes ?? MAX_BODY_BYTES;
3087
- const url = new URL(request.url);
3088
- const method = (request.method || "GET").toUpperCase();
3089
- const headers = {};
3090
- request.headers.forEach((value, key) => {
3091
- headers[key.toLowerCase()] = value;
3092
- });
3093
- const query = {};
3094
- for (const [key, value] of url.searchParams) {
3095
- if (key in query) {
3096
- query[key] = Array.isArray(query[key]) ? [...query[key], value] : [query[key], value];
3097
- } else {
3098
- query[key] = value;
3099
- }
3506
+ var ACTION_PROPERTIES = Object.freeze({
3507
+ block: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["request", "response", "egress"]) }),
3508
+ redact: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
3509
+ encode: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
3510
+ "set-header": Object.freeze({
3511
+ required: Object.freeze(["set_headers"]),
3512
+ defaulted: Object.freeze(["ensure"]),
3513
+ phases: Object.freeze(["response"]),
3514
+ shapes: Object.freeze({ set_headers: "header-name-map" })
3515
+ }),
3516
+ "remove-header": Object.freeze({
3517
+ required: Object.freeze(["remove_headers"]),
3518
+ defaulted: Object.freeze([]),
3519
+ phases: Object.freeze(["response"]),
3520
+ shapes: Object.freeze({ remove_headers: "header-name-list" })
3521
+ }),
3522
+ "harden-cookie": Object.freeze({
3523
+ required: Object.freeze([]),
3524
+ defaulted: Object.freeze(["cookie_flags"]),
3525
+ phases: Object.freeze(["response"]),
3526
+ shapes: Object.freeze({ cookie_flags: "cookie-flags" })
3527
+ })
3528
+ });
3529
+ var LIMITS = Object.freeze({
3530
+ maxRules: 5e3,
3531
+ maxWhitelists: 2e3,
3532
+ maxConditionsPerRule: 250,
3533
+ maxNestingDepth: 12,
3534
+ maxRegexLength: 1e3,
3535
+ maxValueLength: 8192
3536
+ });
3537
+ var GROUP_PARAMETER = "rules";
3538
+ function isGroup(condition) {
3539
+ return condition?.parameter === GROUP_PARAMETER && Array.isArray(condition?.rules);
3540
+ }
3541
+ function conditionShapeProblem(condition) {
3542
+ if (!condition || typeof condition !== "object") return "condition is not an object";
3543
+ const named = condition.parameter === GROUP_PARAMETER;
3544
+ const nested = Array.isArray(condition.rules);
3545
+ if (named && !nested) {
3546
+ return `parameter "${GROUP_PARAMETER}" is a group and needs a "rules" array`;
3100
3547
  }
3101
- let rawBody = "";
3102
- if (method !== "GET" && method !== "HEAD") {
3103
- rawBody = await readCappedText(request, maxBodyBytes);
3548
+ if (nested && !named) {
3549
+ return `condition carries nested rules but its parameter is ${JSON.stringify(condition.parameter)}; a group must be {"parameter":"${GROUP_PARAMETER}"}`;
3104
3550
  }
3105
- const contentType = headers["content-type"] || "";
3106
- let body = {};
3107
- let files;
3108
- if (rawBody) {
3109
- const parsed = parseBody(rawBody, contentType);
3110
- body = parsed.body;
3111
- files = parsed.files;
3551
+ if (named && nested) {
3552
+ if (condition.match !== void 0) return "a group carries no match of its own; the engine ignores it";
3553
+ if (condition.mutations !== void 0) return "a group carries no mutations of its own; the engine ignores them";
3554
+ if (condition.rules.length === 0) return "group has no conditions (would never match)";
3112
3555
  }
3113
- const uri = url.pathname + url.search;
3114
- const forwarded = headers["cf-connecting-ip"] || headers["x-forwarded-for"] || headers["x-real-ip"] || "";
3115
- return {
3116
- method,
3117
- url: uri,
3118
- originalUrl: uri,
3119
- query,
3120
- body,
3121
- files,
3122
- headers,
3123
- ip: forwarded.split(",")[0].trim(),
3124
- cookies: parseCookies(headers.cookie),
3125
- // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify
3126
- // drops, so prototype-pollution rules on `raw` are robust.
3127
- _rawBody: rawBody
3128
- };
3556
+ return null;
3129
3557
  }
3130
- async function readCappedText(request, max) {
3131
- let clone;
3132
- try {
3133
- clone = request.clone();
3134
- } catch {
3135
- return "";
3136
- }
3137
- const body = clone.body;
3138
- if (body && typeof body.getReader === "function") {
3139
- const reader = body.getReader();
3140
- const chunks = [];
3141
- let buffered = 0;
3142
- try {
3143
- for (; ; ) {
3144
- const { done, value } = await reader.read();
3145
- if (done) break;
3146
- if (!value || buffered >= max) continue;
3147
- const take = Math.min(value.byteLength, max - buffered);
3148
- chunks.push(take === value.byteLength ? value : value.subarray(0, take));
3149
- buffered += take;
3150
- }
3151
- } catch {
3152
- return "";
3153
- }
3154
- try {
3155
- return new TextDecoder().decode(concatChunks(chunks, buffered));
3156
- } catch {
3157
- return "";
3558
+ function parameterProblem(parameter) {
3559
+ if (parameter === void 0 || parameter === null) return null;
3560
+ if (Array.isArray(parameter)) {
3561
+ if (parameter.length === 0) return "parameter list is empty";
3562
+ for (const member of parameter) {
3563
+ if (Array.isArray(member)) return "a parameter list holds parameters, not more lists";
3564
+ const problem = parameterProblem(member);
3565
+ if (problem !== null) return problem;
3158
3566
  }
3567
+ return null;
3159
3568
  }
3160
- try {
3161
- const text = await clone.text();
3162
- return text.length > max ? text.slice(0, max) : text;
3163
- } catch {
3164
- return "";
3569
+ if (typeof parameter !== "string" || parameter === "") return "parameter must be a non-empty string";
3570
+ if (parameter === GROUP_PARAMETER) {
3571
+ return `"${GROUP_PARAMETER}" is the group source and is only valid as a whole group, not as a parameter`;
3165
3572
  }
3166
- }
3167
- function concatChunks(chunks, total) {
3168
- const out = new Uint8Array(total);
3169
- let offset = 0;
3170
- for (const c of chunks) {
3171
- out.set(c, offset);
3172
- offset += c.byteLength;
3573
+ const dot = parameter.indexOf(".");
3574
+ const name = dot === -1 ? parameter : parameter.slice(0, dot);
3575
+ const source = SOURCES[name];
3576
+ if (source === void 0) return `unknown parameter source "${name}"`;
3577
+ if (dot === -1) {
3578
+ return source.keyed === true ? `source "${name}" needs a key, e.g. "${name}.fieldname"` : null;
3173
3579
  }
3174
- return out;
3580
+ if (source.keyed !== true) return `source "${name}" takes no key`;
3581
+ const key = parameter.slice(dot + 1);
3582
+ if (key === "") return `source "${name}" needs a key after the dot`;
3583
+ return keyProblem(name, source, key);
3175
3584
  }
3176
- function parseBody(rawBody, contentType) {
3177
- const ct = String(contentType || "").toLowerCase();
3178
- const isJson = ct.includes("application/json") || /\+json\b/.test(ct);
3179
- const isForm = ct.includes("application/x-www-form-urlencoded");
3180
- const isMultipart = ct.includes("multipart/form-data");
3181
- const isAmbiguous = ct === "" || ct.startsWith("text/plain") || ct.includes("csp-report") || ct.includes("/json");
3182
- if (isMultipart) {
3183
- const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
3184
- if (boundary) return parseMultipart(rawBody, boundary);
3185
- return { body: {}, files: void 0 };
3585
+ function keyProblem(name, source, key) {
3586
+ if (key.endsWith("*") && source.wildcard !== true) {
3587
+ return `"${name}" does not fan out over a wildcard key`;
3186
3588
  }
3187
- if (isForm) {
3188
- const body = {};
3189
- for (const [k, v] of new URLSearchParams(rawBody)) body[k] = k in body ? [].concat(body[k], v) : v;
3190
- return { body, files: void 0 };
3589
+ const bare = key.endsWith("*") ? key.slice(0, -1) : key;
3590
+ if (source.keys === "any") return null;
3591
+ if (Array.isArray(source.keys) && source.keys.includes(bare)) return null;
3592
+ for (const prefix of source.key_prefixes ?? []) {
3593
+ if (bare.startsWith(prefix) && bare.length > prefix.length) return null;
3191
3594
  }
3192
- if (isJson || isAmbiguous) {
3193
- try {
3194
- const parsed = JSON.parse(rawBody);
3195
- if (parsed && typeof parsed === "object") return { body: parsed, files: void 0 };
3196
- } catch {
3595
+ const accepted = [
3596
+ ...Array.isArray(source.keys) ? source.keys : [],
3597
+ ...(source.key_prefixes ?? []).map((p) => `${p}\u2026`)
3598
+ ].join(", ");
3599
+ return `"${name}" has no key "${key}" (expected one of: ${accepted})`;
3600
+ }
3601
+ function matchProblem(match, parameter) {
3602
+ if (!match || typeof match !== "object") return "condition has no match object";
3603
+ if (typeof match.type !== "string" || match.type === "") return "match.type must be a non-empty string";
3604
+ if (!MATCH_TYPES.includes(match.type)) return `unknown match type "${match.type}"`;
3605
+ const parameterless = PARAMETERLESS_MATCH_TYPES.includes(match.type);
3606
+ if (!parameterless && (parameter === void 0 || parameter === null)) {
3607
+ return `match type "${match.type}" needs a parameter`;
3608
+ }
3609
+ if (parameterless && parameter !== void 0 && parameter !== null) {
3610
+ return `match type "${match.type}" reads the whole request and takes no parameter`;
3611
+ }
3612
+ const operands = MATCH_OPERANDS[match.type];
3613
+ if (operands) {
3614
+ for (const required of operands.required) {
3615
+ if (match[required] === void 0 || match[required] === null) {
3616
+ return `match type "${match.type}" needs "${required}"`;
3617
+ }
3618
+ }
3619
+ for (const [operand, shape] of Object.entries(operands.shapes ?? {})) {
3620
+ if (match[operand] === void 0) continue;
3621
+ const problem = operandShapeProblem(shape, match[operand]);
3622
+ if (problem) return `match type "${match.type}" operand "${operand}" ${problem}`;
3197
3623
  }
3624
+ } else if (!OPERANDLESS_MATCH_TYPES.includes(match.type) && match.value === void 0) {
3625
+ return `match type "${match.type}" needs a value`;
3198
3626
  }
3199
- return { body: {}, files: void 0 };
3627
+ return null;
3200
3628
  }
3201
- function parseMultipart(rawBody, boundary) {
3202
- const body = {};
3203
- const files = {};
3204
- for (const part of rawBody.split("--" + boundary)) {
3205
- const sep = /\r?\n\r?\n/.exec(part);
3206
- if (!sep) continue;
3207
- const rawHeaders = part.slice(0, sep.index);
3208
- const disposition = /content-disposition:[^\r\n]*/i.exec(rawHeaders)?.[0];
3209
- if (!disposition) continue;
3210
- const name = /name="([^"]*)"/i.exec(disposition)?.[1];
3211
- if (name == null) continue;
3212
- const content = part.slice(sep.index + sep[0].length).replace(/\r?\n$/, "");
3213
- const filename = /filename="([^"]*)"/i.exec(disposition)?.[1];
3214
- if (filename !== void 0) {
3215
- const partType = /content-type:\s*([^\r\n;]+)/i.exec(rawHeaders)?.[1]?.trim() || "";
3216
- const file = { filename, type: partType, content };
3217
- files[name] = name in files ? [].concat(files[name], file) : file;
3218
- } else {
3219
- body[name] = name in body ? [].concat(body[name], content) : content;
3629
+ function mutationsProblem(mutations) {
3630
+ if (mutations === void 0 || mutations === null) return null;
3631
+ if (!Array.isArray(mutations)) return "mutations must be an array";
3632
+ for (const mutation of mutations) {
3633
+ if (typeof mutation !== "string" || !MUTATIONS.includes(mutation)) {
3634
+ return `unknown mutation "${String(mutation)}" \u2014 applied as a no-op, so the condition would test the untransformed value`;
3220
3635
  }
3221
3636
  }
3222
- return { body, files };
3637
+ return null;
3223
3638
  }
3224
- function parseCookies(header) {
3225
- const cookies = {};
3226
- if (!header) {
3227
- return cookies;
3639
+ function whenProblem(when) {
3640
+ if (when === void 0 || when === null) return null;
3641
+ if (typeof when !== "object" || Array.isArray(when)) return "when must be an object";
3642
+ const keys = Object.keys(when);
3643
+ if (keys.length === 0) return "when is empty \u2014 remove it, or name a path or method";
3644
+ const unknown = keys.filter((key) => !WHEN_KEYS.includes(key));
3645
+ if (unknown.length > 0) {
3646
+ 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`;
3228
3647
  }
3229
- for (const pair of header.split(";")) {
3230
- const idx = pair.indexOf("=");
3231
- if (idx === -1) {
3232
- continue;
3648
+ if ("path" in when && !isNonEmptyString(when.path)) {
3649
+ return "when.path must be a non-empty string";
3650
+ }
3651
+ if ("method" in when) {
3652
+ const methods = Array.isArray(when.method) ? when.method : [when.method];
3653
+ if (methods.length === 0) return "when.method is an empty list, so the rule would match no method";
3654
+ if (!methods.every(isNonEmptyString)) return "when.method must be a non-empty string, or a list of them";
3655
+ }
3656
+ return null;
3657
+ }
3658
+ function isNonEmptyString(value) {
3659
+ return typeof value === "string" && value.trim() !== "";
3660
+ }
3661
+ function nullPropertyProblem(rule) {
3662
+ if (!rule || typeof rule !== "object") return null;
3663
+ for (const property of RULE_PROPERTIES) {
3664
+ if (NULL_EXEMPT_PROPERTIES.includes(property)) continue;
3665
+ if (property in rule && rule[property] === null) {
3666
+ 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`;
3233
3667
  }
3234
- cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
3235
3668
  }
3236
- return cookies;
3669
+ return null;
3237
3670
  }
3238
-
3239
- // src/protect/engine/node.js
3240
- function fromNodeRequest(req, rawBody = "") {
3241
- const method = (req.method || "GET").toUpperCase();
3242
- const headers = {};
3243
- for (const [key, value] of Object.entries(req.headers || {})) {
3244
- headers[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;
3671
+ function rulePropertyProblem(rule) {
3672
+ if (!rule || typeof rule !== "object") return null;
3673
+ for (const [property, shape] of Object.entries(RULE_PROPERTY_SHAPES)) {
3674
+ if (rule[property] === void 0 || rule[property] === null) continue;
3675
+ const problem = operandShapeProblem(shape, rule[property]);
3676
+ if (problem) return `"${property}" ${problem}`;
3245
3677
  }
3246
- const host = headers.host || "localhost";
3247
- let url;
3248
- try {
3249
- url = new URL(req.url || "/", `http://${host}`);
3250
- } catch {
3251
- try {
3252
- url = new URL(req.url || "/", "http://localhost");
3253
- } catch {
3254
- url = new URL("http://localhost/");
3678
+ return null;
3679
+ }
3680
+ function actionProblem(action, rule) {
3681
+ if (action === void 0 || action === null) return null;
3682
+ if (!ACTIONS.includes(action)) return `unknown action "${action}"`;
3683
+ const spec = ACTION_PROPERTIES[action];
3684
+ for (const required of spec?.required ?? []) {
3685
+ if (rule?.[required] === void 0 || rule?.[required] === null) {
3686
+ return `action "${action}" needs "${required}"`;
3255
3687
  }
3256
3688
  }
3257
- const query = {};
3258
- for (const [key, value] of url.searchParams) {
3259
- if (key in query) {
3260
- query[key] = Array.isArray(query[key]) ? [...query[key], value] : [query[key], value];
3261
- } else {
3262
- query[key] = value;
3689
+ for (const [property, shape] of Object.entries(spec?.shapes ?? {})) {
3690
+ if (rule?.[property] === void 0 || rule?.[property] === null) continue;
3691
+ const problem = operandShapeProblem(shape, rule[property]);
3692
+ if (problem) return `action "${action}" property "${property}" ${problem}`;
3693
+ }
3694
+ const phase = rule?.phase ?? "request";
3695
+ if (spec !== void 0 && !spec.phases.includes(phase)) {
3696
+ return `action "${action}" is carried out on the ${spec.phases.join("/")} phase; on "${phase}" the rule blocks instead`;
3697
+ }
3698
+ return null;
3699
+ }
3700
+
3701
+ // src/protect/rules/validate.js
3702
+ var LIMITS2 = LIMITS;
3703
+ var PHASES2 = new Set(PHASES);
3704
+ var ACTIONS2 = new Set(ACTIONS);
3705
+ function validateBundle(bundle, opts = {}) {
3706
+ const rejected = [];
3707
+ const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
3708
+ const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
3709
+ const firewall = [];
3710
+ for (const rule of inFirewall) {
3711
+ if (firewall.length >= LIMITS2.maxRules) {
3712
+ rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS2.maxRules})` });
3713
+ continue;
3263
3714
  }
3715
+ const reason = enforceableRuleProblem(rule);
3716
+ if (reason) rejected.push({ id: idOf(rule), reason });
3717
+ else firewall.push(rule);
3264
3718
  }
3265
- const contentType = headers["content-type"] || "";
3266
- let body = {};
3267
- let files;
3268
- if (rawBody) {
3269
- const parsed = parseBody(rawBody, contentType);
3270
- body = parsed.body;
3271
- files = parsed.files;
3719
+ const whitelists = [];
3720
+ for (const wl of inWhitelists) {
3721
+ if (whitelists.length >= LIMITS2.maxWhitelists) {
3722
+ rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS2.maxWhitelists})` });
3723
+ continue;
3724
+ }
3725
+ if (!opts.allowGlobalWhitelists && wl && Array.isArray(wl.rule_v2) && !wl.rule_id) {
3726
+ rejected.push({ id: idOf(wl), reason: "whitelist has no rule_id (would suppress every rule); set allowGlobalWhitelists to permit" });
3727
+ continue;
3728
+ }
3729
+ const reason = conditionsProblem(wl?.rule_v2);
3730
+ if (reason) rejected.push({ id: idOf(wl), reason: `whitelist: ${reason}` });
3731
+ else whitelists.push(wl);
3272
3732
  }
3273
- const uri = url.pathname + url.search;
3274
- const forwarded = headers["cf-connecting-ip"] || headers["x-forwarded-for"] || headers["x-real-ip"] || "";
3275
3733
  return {
3276
- method,
3277
- url: uri,
3278
- originalUrl: uri,
3279
- query,
3280
- body,
3281
- files,
3282
- headers,
3283
- ip: forwarded.split(",")[0].trim() || req.socket && req.socket.remoteAddress || "",
3284
- cookies: parseCookies2(headers.cookie),
3285
- // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify drops.
3286
- _rawBody: rawBody
3734
+ bundle: { ...bundle, firewall, whitelists },
3735
+ rejected
3287
3736
  };
3288
3737
  }
3289
- function parseCookies2(header) {
3290
- const cookies = {};
3291
- if (!header) {
3292
- return cookies;
3738
+ function idOf(rule) {
3739
+ const id = rule?.id ?? rule?.rule_id;
3740
+ return id === void 0 || id === null ? "(unidentified)" : String(id);
3741
+ }
3742
+ function enforceableRuleProblem(rule) {
3743
+ if (!rule || typeof rule !== "object") return "not an object";
3744
+ const nullReason = nullPropertyProblem(rule);
3745
+ if (nullReason) return nullReason;
3746
+ if (rule.phase !== void 0 && !PHASES2.has(rule.phase)) return `unknown phase "${rule.phase}"`;
3747
+ const actionReason = actionProblem(rule.action, rule);
3748
+ if (actionReason) return actionReason;
3749
+ const scopeReason = whenProblem(rule.when);
3750
+ if (scopeReason) return scopeReason;
3751
+ const propertyReason = rulePropertyProblem(rule);
3752
+ if (propertyReason) return propertyReason;
3753
+ return conditionsProblem(rule.rule_v2);
3754
+ }
3755
+ function conditionsProblem(conditions, depth = 0) {
3756
+ if (!Array.isArray(conditions)) return "rule_v2 must be an array of conditions";
3757
+ if (conditions.length === 0) return "rule_v2 is empty (would never match)";
3758
+ if (depth > LIMITS2.maxNestingDepth) return `nesting deeper than ${LIMITS2.maxNestingDepth}`;
3759
+ if (conditions.length > LIMITS2.maxConditionsPerRule) {
3760
+ return `more than ${LIMITS2.maxConditionsPerRule} conditions`;
3293
3761
  }
3294
- for (const pair of header.split(";")) {
3295
- const idx = pair.indexOf("=");
3296
- if (idx === -1) {
3762
+ for (const c of conditions) {
3763
+ const shapeReason = conditionShapeProblem(c);
3764
+ if (shapeReason) return shapeReason;
3765
+ if (isGroup(c)) {
3766
+ const nested = conditionsProblem(c.rules, depth + 1);
3767
+ if (nested) return nested;
3297
3768
  continue;
3298
3769
  }
3299
- cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
3300
- }
3301
- return cookies;
3770
+ const parameterReason = parameterProblem(c.parameter);
3771
+ if (parameterReason) return parameterReason;
3772
+ const mutationReason = mutationsProblem(c.mutations);
3773
+ if (mutationReason) return mutationReason;
3774
+ const matchReason = matchProblem(c.match, c.parameter);
3775
+ if (matchReason) return matchReason;
3776
+ const m = c.match;
3777
+ if (m.type === "regex") {
3778
+ if (typeof m.value !== "string") return "regex match.value must be a string";
3779
+ if (m.value.length > LIMITS2.maxRegexLength) return `regex longer than ${LIMITS2.maxRegexLength} chars`;
3780
+ } else if (typeof m.value === "string" && m.value.length > LIMITS2.maxValueLength) {
3781
+ return `match.value longer than ${LIMITS2.maxValueLength} chars`;
3782
+ }
3783
+ if (m.match) {
3784
+ const nested = conditionsProblem([{ parameter: c.parameter, match: m.match }], depth + 1);
3785
+ if (nested) return nested;
3786
+ }
3787
+ }
3788
+ return null;
3302
3789
  }
3303
3790
 
3304
- // src/protect/egress.js
3305
- async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = true, lookup, allowHosts } = {}) {
3306
- const restores = [];
3307
- if (typeof shouldBlock !== "function") return () => {
3308
- };
3309
- const exempt = new Set((allowHosts ?? []).map((h) => String(h).toLowerCase()));
3310
- const block = (url, host, method) => {
3311
- if (!shouldBlock(url, host, method)) return false;
3312
- notify(onBlock, { url, host, method }, "onEgressBlock");
3313
- return true;
3791
+ // src/protect/capture-plan.js
3792
+ var NEVER_CAPTURABLE = /* @__PURE__ */ new Set(["response"]);
3793
+ var CAPTURE_LIMITS = Object.freeze({
3794
+ /** Named and prefix values in one event, together. Raw has its own allowance. */
3795
+ capturedValues: 10,
3796
+ /** Characters of any single captured value. */
3797
+ valueChars: 512,
3798
+ /** Resolved values one prefix permission may contribute. */
3799
+ prefixValues: 5
3800
+ });
3801
+ function parametersOf(rule) {
3802
+ const out = /* @__PURE__ */ new Set();
3803
+ const add = (parameter) => {
3804
+ if (typeof parameter === "string" && parameter !== "rules") out.add(parameter);
3314
3805
  };
3315
- let screen = null;
3316
- if (dnsScreen) {
3317
- try {
3318
- const resolveLookup = lookup ?? (await import("dns")).lookup;
3319
- const { isIP } = await import("net");
3320
- if (typeof resolveLookup === "function" && typeof isIP === "function") {
3321
- screen = { lookup: resolveLookup, isIP, isExempt: (h) => exempt.has(String(h).toLowerCase()) };
3322
- }
3323
- } catch {
3324
- screen = null;
3806
+ const collect = (parameter) => {
3807
+ if (Array.isArray(parameter)) {
3808
+ for (const member of parameter) add(member);
3809
+ return;
3325
3810
  }
3326
- }
3327
- const skip = (reason, detail) => notify(onSkip, { phase: "egress", reason, detail }, "onSkip");
3328
- const resolvesToDisallowed = (url, host, method) => new Promise((resolve) => {
3329
- if (!screen) {
3330
- if (host && dnsScreen) skip("resolver-unavailable", { host });
3331
- return resolve(false);
3811
+ add(parameter);
3812
+ };
3813
+ const walk2 = (conditions, depth) => {
3814
+ if (!Array.isArray(conditions) || depth > 20) return;
3815
+ for (const condition of conditions) {
3816
+ if (!condition || typeof condition !== "object") continue;
3817
+ collect(condition.parameter);
3818
+ if (Array.isArray(condition.rules)) walk2(condition.rules, depth + 1);
3332
3819
  }
3333
- if (!host || screen.isIP(host) !== 0 || screen.isExempt(host)) return resolve(false);
3334
- try {
3335
- screen.lookup(host, { all: true }, (err, addresses) => {
3336
- if (err || !Array.isArray(addresses)) {
3337
- skip("resolver-failed", { host });
3338
- return resolve(false);
3339
- }
3340
- for (const a of addresses) {
3341
- const ip = a && typeof a === "object" ? a.address : a;
3342
- if (ip && block(url, ip, method)) return resolve(true);
3343
- }
3344
- resolve(false);
3345
- });
3346
- } catch {
3347
- skip("resolver-failed", { host });
3348
- resolve(false);
3820
+ };
3821
+ walk2(rule?.rule_v2, 0);
3822
+ return [...out];
3823
+ }
3824
+ function rawOptIn(rule) {
3825
+ if (!rule || typeof rule !== "object" || !Object.hasOwn(rule, "capture")) return null;
3826
+ const capture = rule.capture;
3827
+ if (capture === null || typeof capture !== "object") return null;
3828
+ if (captureProblem(capture) !== null) return null;
3829
+ return { chars: Math.min(capture.raw_chars, CAPTURE_RAW_CHARS_MAX) };
3830
+ }
3831
+ var NOTHING = Object.freeze({
3832
+ named: Object.freeze([]),
3833
+ prefixes: Object.freeze([]),
3834
+ raw: null,
3835
+ limits: CAPTURE_LIMITS
3836
+ });
3837
+ function derivePlan(rule) {
3838
+ const named = /* @__PURE__ */ new Set();
3839
+ const prefixes = /* @__PURE__ */ new Set();
3840
+ if (enforceableRuleProblem(rule) !== null) return NOTHING;
3841
+ const parameters = parametersOf(rule).filter((parameter) => parameterProblem(parameter) === null);
3842
+ for (const parameter of parameters) {
3843
+ const dot = parameter.indexOf(".");
3844
+ if (dot === -1) continue;
3845
+ const source = parameter.slice(0, dot);
3846
+ const key = parameter.slice(dot + 1);
3847
+ if (SOURCES[source]?.keyed !== true || NEVER_CAPTURABLE.has(source)) continue;
3848
+ if (key.endsWith("*")) {
3849
+ const prefix = key.slice(0, -1);
3850
+ if (prefix !== "") prefixes.add(`${source}.${prefix}`);
3851
+ continue;
3349
3852
  }
3853
+ named.add(`${source}.${key}`);
3854
+ }
3855
+ const raw = rawOptIn(rule);
3856
+ return Object.freeze({
3857
+ named: Object.freeze([...named].sort()),
3858
+ prefixes: Object.freeze([...prefixes].sort()),
3859
+ raw: raw === null ? null : Object.freeze(raw),
3860
+ limits: CAPTURE_LIMITS
3350
3861
  });
3351
- const originalFetch = globalThis.fetch;
3352
- if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
3353
- const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
3354
- const MAX_REDIRECTS = 20;
3355
- const screenUrl = async (u, method) => {
3356
- let host = null;
3357
- try {
3358
- host = new URL(u).hostname;
3359
- } catch {
3360
- host = null;
3361
- }
3362
- if (block(u, host, method) || await resolvesToDisallowed(u, host, method)) {
3363
- throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? u}`);
3364
- }
3365
- };
3366
- const guarded = async (input, init) => {
3367
- let cur;
3368
- try {
3369
- cur = new Request(input, { ...init || {}, redirect: "manual" });
3370
- } catch {
3371
- return originalFetch(input, init);
3372
- }
3373
- const callerRedirect = init && init.redirect || input && input.redirect || "follow";
3374
- let url = cur.url;
3375
- let method = cur.method;
3376
- await screenUrl(url, method);
3377
- if (callerRedirect !== "follow") return originalFetch(input, init);
3378
- const headers = new Headers(cur.headers);
3379
- const signal = cur.signal;
3380
- let body = method === "GET" || method === "HEAD" ? void 0 : await cur.clone().arrayBuffer();
3381
- for (let hop = 0; ; hop++) {
3382
- const resp = await originalFetch(
3383
- hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
3384
- );
3385
- const location = REDIRECT_STATUSES.has(resp.status) ? resp.headers.get("location") : null;
3386
- if (!location) return resp;
3387
- if (hop >= MAX_REDIRECTS) throw new Error("Patchstack blocked an outbound request: too many redirects");
3388
- const next = new URL(location, url).href;
3389
- if (resp.status === 303 || (resp.status === 301 || resp.status === 302) && method === "POST") {
3390
- method = "GET";
3391
- body = void 0;
3392
- headers.delete("content-type");
3393
- headers.delete("content-length");
3394
- }
3395
- if (new URL(next).origin !== new URL(url).origin) {
3396
- headers.delete("authorization");
3397
- headers.delete("cookie");
3398
- }
3399
- await screenUrl(next, method);
3400
- url = next;
3862
+ }
3863
+ function permitsAnything(plan) {
3864
+ return plan.named.length > 0 || plan.prefixes.length > 0 || plan.raw !== null;
3865
+ }
3866
+ var LANES = Object.freeze([2166136261, 16777619, 2654435769, 2246822507]);
3867
+ function planReference(plan) {
3868
+ const limits = plan?.limits ?? {};
3869
+ const canonical = JSON.stringify([
3870
+ [...plan?.named ?? []].sort(),
3871
+ [...plan?.prefixes ?? []].sort(),
3872
+ plan?.raw?.chars ?? 0,
3873
+ Object.entries(limits).map(([key, value]) => [key, value]).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
3874
+ ]);
3875
+ const digest = LANES.map((base) => {
3876
+ let hash = base >>> 0;
3877
+ for (let i = 0; i < canonical.length; i++) {
3878
+ hash ^= canonical.charCodeAt(i);
3879
+ hash = Math.imul(hash, 16777619) >>> 0;
3880
+ }
3881
+ return hash.toString(16).padStart(8, "0");
3882
+ }).join("");
3883
+ return `cp2-${digest}`;
3884
+ }
3885
+ function createPlanCache() {
3886
+ const byRule = /* @__PURE__ */ new WeakMap();
3887
+ let derivations = 0;
3888
+ return {
3889
+ for(rule) {
3890
+ if (rule === null || typeof rule !== "object") {
3891
+ const plan2 = derivePlan(rule);
3892
+ derivations += 1;
3893
+ return Object.freeze({ plan: plan2, reference: planReference(plan2) });
3401
3894
  }
3402
- };
3403
- guarded.__patchstackGuarded = true;
3404
- globalThis.fetch = guarded;
3405
- restores.push(() => {
3406
- if (globalThis.fetch === guarded) globalThis.fetch = originalFetch;
3407
- });
3408
- }
3409
- for (const moduleName of ["node:http", "node:https"]) {
3895
+ const hit = byRule.get(rule);
3896
+ if (hit) return hit;
3897
+ const plan = derivePlan(rule);
3898
+ derivations += 1;
3899
+ const entry = Object.freeze({ plan, reference: planReference(plan) });
3900
+ byRule.set(rule, entry);
3901
+ return entry;
3902
+ },
3903
+ /** How many plans have been derived — the observable difference between a hit and a miss. */
3904
+ get derivations() {
3905
+ return derivations;
3906
+ }
3907
+ };
3908
+ }
3909
+ function captureValues(plan, resolver) {
3910
+ const nothing = { values: [], omitted: 0, unsupported: 0, failed: 0, unavailable: false, raw: null };
3911
+ if (!plan || !permitsAnything(plan)) return nothing;
3912
+ if (!resolver || typeof resolver.resolve !== "function") return { ...nothing, unavailable: true };
3913
+ const limits = plan.limits ?? CAPTURE_LIMITS;
3914
+ const values = [];
3915
+ let omitted = 0;
3916
+ let unsupported = 0;
3917
+ let failed = 0;
3918
+ let attempted = 0;
3919
+ const read = (parameter) => {
3920
+ attempted += 1;
3410
3921
  try {
3411
- const mod = await import(moduleName);
3412
- const restore = patchHttpModule(mod.default ?? mod, block, screen);
3413
- if (restore) restores.push(restore);
3922
+ const resolved = resolver.resolve(parameter);
3923
+ return Array.isArray(resolved) ? resolved : [];
3414
3924
  } catch {
3925
+ failed += 1;
3926
+ return null;
3415
3927
  }
3416
- }
3417
- return () => {
3418
- for (const restore of restores) {
3419
- try {
3420
- restore();
3421
- } catch {
3422
- }
3928
+ };
3929
+ const classify = (value) => {
3930
+ const text = asText(value);
3931
+ if (text === null) {
3932
+ unsupported += 1;
3933
+ return null;
3423
3934
  }
3935
+ return text;
3424
3936
  };
3425
- }
3426
- function patchHttpModule(http, block, screen) {
3427
- if (!http || typeof http.request !== "function" || http.__patchstackGuarded) return null;
3428
- const originalRequest = http.request;
3429
- const originalGet = http.get;
3430
- const wrap = (original) => function(...args) {
3431
- const target = extractHttpTarget(args);
3432
- if (target && block(target.url, target.host, target.method)) {
3433
- throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host ?? target.url}`);
3937
+ const record = (parameter, text) => {
3938
+ if (values.length >= limits.capturedValues) {
3939
+ omitted += 1;
3940
+ return false;
3434
3941
  }
3435
- if (target && screen && target.host && screen.isIP(target.host) === 0 && !screen.isExempt(target.host)) {
3436
- try {
3437
- args = withScreeningLookup(args, target, block, screen.lookup);
3438
- } catch {
3942
+ const capped = text.length > limits.valueChars;
3943
+ values.push({
3944
+ parameter,
3945
+ value: capped ? text.slice(0, limits.valueChars) : text,
3946
+ ...capped ? { truncated: true } : {}
3947
+ });
3948
+ return true;
3949
+ };
3950
+ for (const parameter of plan.named) {
3951
+ for (const value of read(parameter) ?? []) {
3952
+ const text = classify(value);
3953
+ if (text !== null) record(parameter, text);
3954
+ }
3955
+ }
3956
+ for (const prefix of plan.prefixes) {
3957
+ const pattern = `${prefix}*`;
3958
+ let taken = 0;
3959
+ for (const value of read(pattern) ?? []) {
3960
+ const text = classify(value);
3961
+ if (text === null) continue;
3962
+ if (taken >= limits.prefixValues) {
3963
+ omitted += 1;
3964
+ continue;
3439
3965
  }
3966
+ if (record(pattern, text)) taken += 1;
3440
3967
  }
3441
- return original.apply(this, args);
3442
- };
3443
- const guardedRequest = wrap(originalRequest);
3444
- http.request = guardedRequest;
3445
- let guardedGet;
3446
- if (typeof originalGet === "function") {
3447
- guardedGet = wrap(originalGet);
3448
- http.get = guardedGet;
3449
3968
  }
3450
- http.__patchstackGuarded = true;
3451
- return () => {
3452
- if (http.request === guardedRequest) http.request = originalRequest;
3453
- if (guardedGet && http.get === guardedGet) http.get = originalGet;
3454
- delete http.__patchstackGuarded;
3455
- };
3456
- }
3457
- function extractHttpTarget(args) {
3458
- const first = args[0];
3459
- try {
3460
- if (typeof first === "string" || first instanceof URL) {
3461
- const url = new URL(String(first));
3462
- const opts = args.find((a) => a && typeof a === "object" && !(a instanceof URL));
3463
- return { url: url.href, host: url.hostname, method: opts && opts.method || "GET" };
3464
- }
3465
- if (first && typeof first === "object") {
3466
- const host = normalizeHost(first.hostname || first.host) || "localhost";
3467
- const protocol = first.protocol || "http:";
3468
- const port = first.port ? `:${first.port}` : "";
3469
- const path7 = first.path || "/";
3470
- return { url: `${protocol}//${host}${port}${path7}`, host, method: first.method || "GET" };
3969
+ let raw = null;
3970
+ if (plan.raw !== null) {
3971
+ const text = asText((read("raw") ?? [])[0]);
3972
+ if (text !== null) {
3973
+ const capped = text.length > plan.raw.chars;
3974
+ raw = { value: capped ? text.slice(0, plan.raw.chars) : text, ...capped ? { truncated: true } : {} };
3471
3975
  }
3472
- } catch {
3473
3976
  }
3474
- return null;
3475
- }
3476
- function normalizeHost(raw) {
3477
- const host = String(raw || "").trim();
3478
- const bracketed = /^\[([^\]]+)\]/.exec(host);
3479
- if (bracketed) return bracketed[1];
3480
- if ((host.match(/:/g) || []).length > 1) return host;
3481
- return host.split(":")[0];
3977
+ const unavailable = attempted > 0 && failed === attempted;
3978
+ return { values, omitted, unsupported, failed, unavailable, raw };
3482
3979
  }
3483
- function screenResolved(addresses, target, block) {
3484
- for (const a of addresses || []) {
3485
- const ip = a && typeof a === "object" ? a.address : a;
3486
- if (ip && block(target.url, ip, target.method)) return ip;
3487
- }
3980
+ function asText(value) {
3981
+ if (typeof value === "string") return value;
3982
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
3983
+ if (typeof value === "boolean") return String(value);
3488
3984
  return null;
3489
3985
  }
3490
- function withScreeningLookup(args, target, block, lookup) {
3491
- const screeningLookup = (hostname, options, callback) => {
3492
- let opts = options;
3493
- let cb = callback;
3494
- if (typeof opts === "function") {
3495
- cb = opts;
3496
- opts = {};
3986
+
3987
+ // src/protect/engine/pulse-client.js
3988
+ init_pulse_token();
3989
+ var DEFAULT_BASE_URL2 = "https://api.patchstack.com/monitor/pulse";
3990
+ var DEFAULT_CACHE_TTL2 = 3e5;
3991
+ var JITTER_FRACTION2 = 0.1;
3992
+ var PulseRuleClient = class {
3993
+ #siteUuid;
3994
+ #timeoutMs;
3995
+ #baseUrl;
3996
+ #cacheTtl;
3997
+ #cache = null;
3998
+ #cacheTime = null;
3999
+ #ttlEffective = 0;
4000
+ #etag;
4001
+ #pulseAuth;
4002
+ #detectionState;
4003
+ constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, detectionState } = {}) {
4004
+ this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
4005
+ this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
4006
+ this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL, DEFAULT_BASE_URL2, "rule endpoint");
4007
+ this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL2;
4008
+ this.#etag = etag ?? null;
4009
+ this.#pulseAuth = pulseAuth ?? null;
4010
+ this.#detectionState = typeof detectionState === "string" ? detectionState : null;
4011
+ if (!this.#siteUuid) {
4012
+ throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
3497
4013
  }
3498
- if (!opts || typeof opts !== "object") opts = {};
4014
+ }
4015
+ async getRules() {
4016
+ const now = Date.now();
4017
+ if (this.#cache && this.#cacheTime !== null && now - this.#cacheTime < this.#ttlEffective) {
4018
+ return this.#cache;
4019
+ }
4020
+ const url = `${this.#baseUrl}/rules/${encodeURIComponent(this.#siteUuid)}`;
3499
4021
  try {
3500
- lookup(hostname, { ...opts, all: true }, (err, addresses) => {
3501
- if (err) return cb(err);
3502
- const list = Array.isArray(addresses) ? addresses : [];
3503
- const blocked = screenResolved(list, target, block);
3504
- if (blocked) {
3505
- return cb(new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host} resolved to ${blocked}`));
3506
- }
3507
- if (opts.all) return cb(null, list);
3508
- const first = list[0];
3509
- if (!first) return cb(new Error(`Patchstack: could not resolve ${hostname}`));
3510
- return cb(null, first.address, first.family);
3511
- });
3512
- } catch {
3513
- try {
3514
- lookup(hostname, opts, cb);
3515
- } catch {
3516
- cb(new Error(`Patchstack: lookup failed for ${hostname}`));
4022
+ const auth = await pulseAuthHeader(
4023
+ { pulseAuth: this.#pulseAuth, endpoint: this.#baseUrl, timeoutMs: this.#timeoutMs },
4024
+ fetch
4025
+ );
4026
+ const headers = { Accept: "application/json", ...auth };
4027
+ if (this.#detectionState !== null && typeof auth.Authorization === "string") {
4028
+ headers["X-Patchstack-Detections"] = this.#detectionState;
4029
+ }
4030
+ if (this.#etag) headers["If-None-Match"] = this.#etag;
4031
+ const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
4032
+ if (response.status === 304) {
4033
+ this.#touch(now);
4034
+ return this.#cache ?? { success: true, notModified: true, etag: this.#etag, firewall: [], whitelists: [], whitelist_keys: {} };
4035
+ }
4036
+ if (!response.ok) {
4037
+ return { success: false, error: `API returned ${response.status}`, firewall: [], whitelists: [], whitelist_keys: {} };
4038
+ }
4039
+ const data = await response.json();
4040
+ if (!data || !Array.isArray(data.firewall)) {
4041
+ return { success: false, error: "unexpected response shape (no firewall array)", firewall: [], whitelists: [], whitelist_keys: {} };
3517
4042
  }
4043
+ const result = {
4044
+ success: true,
4045
+ notModified: false,
4046
+ etag: response.headers?.get?.("etag") ?? null,
4047
+ firewall: data.firewall,
4048
+ whitelists: Array.isArray(data.whitelists) ? data.whitelists : [],
4049
+ whitelist_keys: data.whitelist_keys ?? {},
4050
+ ...enforcementField(data)
4051
+ };
4052
+ this.#cache = result;
4053
+ this.#etag = result.etag;
4054
+ this.#touch(now);
4055
+ return result;
4056
+ } catch (err) {
4057
+ return {
4058
+ success: false,
4059
+ error: err.name === "TimeoutError" ? "Request timed out" : err.message,
4060
+ firewall: [],
4061
+ whitelists: [],
4062
+ whitelist_keys: {}
4063
+ };
3518
4064
  }
3519
- };
3520
- return injectLookupOption(args, screeningLookup);
3521
- }
3522
- function injectLookupOption(args, lookup) {
3523
- const first = args[0];
3524
- if (first && typeof first === "object" && !(first instanceof URL)) {
3525
- return [{ ...first, lookup }, ...args.slice(1)];
3526
4065
  }
3527
- const rest = args.slice(1);
3528
- const optIdx = rest.findIndex((a) => a && typeof a === "object" && !(a instanceof URL));
3529
- if (optIdx !== -1) {
3530
- const next = [...rest];
3531
- next[optIdx] = { ...rest[optIdx], lookup };
3532
- return [first, ...next];
4066
+ #touch(now) {
4067
+ this.#cacheTime = now;
4068
+ this.#ttlEffective = this.#cacheTtl - Math.floor(Math.random() * this.#cacheTtl * JITTER_FRACTION2);
3533
4069
  }
3534
- const cbIdx = rest.findIndex((a) => typeof a === "function");
3535
- if (cbIdx === -1) return [first, { lookup }, ...rest];
3536
- return [first, ...rest.slice(0, cbIdx), { lookup }, ...rest.slice(cbIdx)];
4070
+ clearCache() {
4071
+ this.#cache = null;
4072
+ this.#cacheTime = null;
4073
+ this.#etag = null;
4074
+ }
4075
+ };
4076
+ function enforcementField(data) {
4077
+ if (!data || typeof data !== "object") return {};
4078
+ const v = data.enforcement ?? data.mode;
4079
+ return v === "block" || v === "dry-run" ? { enforcement: v } : {};
3537
4080
  }
3538
4081
 
3539
- // src/protect/defaults.js
3540
- var DEFAULT_RESPONSE_RULES = [
3541
- {
3542
- id: "resp-private-key",
3543
- title: "Private key in response body",
3544
- phase: "response",
3545
- category: "secret-exposure",
3546
- action: "redact",
3547
- prefilter: ["PRIVATE KEY"],
3548
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/" } }]
3549
- },
3550
- {
3551
- id: "resp-aws-access-key",
3552
- title: "AWS access key id in response body",
3553
- phase: "response",
3554
- category: "secret-exposure",
3555
- action: "redact",
3556
- prefilter: ["AKIA", "ASIA"],
3557
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b/" } }]
3558
- },
3559
- {
3560
- id: "resp-gcp-api-key",
3561
- title: "Google API key in response body",
3562
- phase: "response",
3563
- category: "secret-exposure",
3564
- action: "redact",
3565
- prefilter: ["AIza"],
3566
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\bAIza[0-9A-Za-z_-]{35}\\b/" } }]
3567
- },
3568
- {
3569
- id: "resp-vendor-api-key",
3570
- title: "Vendor API key / token in response body",
3571
- phase: "response",
3572
- category: "secret-exposure",
3573
- action: "redact",
3574
- // High-signal, prefix-anchored provider tokens that never legitimately appear in a response
3575
- // body: Stripe (sk_live_/rk_live_), GitHub (gh[opsu]_ / github_pat_), GitLab (glpat-),
3576
- // Slack (xox[baprs]-), Anthropic (sk-ant-), Google OAuth (ya29.), npm (npm_). Trailing
3577
- // (?![0-9A-Za-z]) instead of \\b since some tokens end in - / _ .
3578
- prefilter: ["sk_live_", "rk_live_", "ghp_", "gho_", "ghs_", "ghu_", "github_pat_", "glpat-", "xox", "sk-ant-", "ya29.", "npm_"],
3579
- 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])/" } }]
3580
- },
3581
- {
3582
- id: "resp-supabase-secret-key",
3583
- title: "Supabase secret key in response body",
3584
- phase: "response",
3585
- category: "secret-exposure",
3586
- action: "redact",
3587
- // Supabase's opaque key format, which is not a JWT and so is not covered by the JWT rule below.
3588
- // `sb_secret_` is the elevated one — documented as full access, bypassing Row Level Security,
3589
- // backend-only — while `sb_publishable_` is meant for public clients and must pass untouched.
3590
- // Both key systems are live at once: new keys sit alongside the legacy `anon` / `service_role`
3591
- // JWTs rather than replacing them, so this rule and the JWT rule cover different halves.
3592
- //
3593
- // The documented grammar is the prefix, 22 base64url characters, `_`, then an 8-character
3594
- // base64url checksum. Matching it exactly is what keeps the mask on the key and off the response
3595
- // around it: a variable-length suffix would either run past the key into adjacent syntax or stop
3596
- // short and leave the tail of a real key visible. The trailing guard rejects a longer run of key
3597
- // characters, which is not this format.
3598
- prefilter: ["sb_secret_"],
3599
- 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_-])/" } }]
3600
- },
3601
- {
3602
- id: "resp-supabase-service-role-key",
3603
- title: "Supabase service_role key in response body",
3604
- phase: "response",
3605
- category: "secret-exposure",
3606
- action: "redact",
3607
- // Scoped to the one role that must never leave: `service_role` bypasses Row Level Security
3608
- // entirely. A JWT in a response body is not inherently a leak — the Supabase `anon` key is public
3609
- // by design and a login response carries the user's own access token — so this decides on the
3610
- // decoded payload rather than on the shape of the token.
3611
- //
3612
- // `jwt_claim_equals` also yields the matching token spans, which is what lets `redact` mask those
3613
- // tokens and serve the rest of the response.
3614
- //
3615
- // A broad any-JWT rule is a reasonable thing to want; it belongs in the managed hardening bundle as
3616
- // an opt-in, observe-only rule rather than an on-by-default mask.
3617
- prefilter: ["eyJ"],
3618
- rule_v2: [{ parameter: "response.body", match: { type: "jwt_claim_equals", claim: "role", value: "service_role" } }]
3619
- },
3620
- {
3621
- id: "resp-db-connection-string",
3622
- title: "Database connection string with credentials in response body",
3623
- phase: "response",
3624
- category: "secret-exposure",
3625
- action: "redact",
3626
- prefilter: ["mongodb", "postgres", "mysql", "redis", "amqp"],
3627
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[^\\s:@\\/]+:[^\\s:@\\/]+@/i" } }]
3628
- },
3629
- {
3630
- id: "resp-stack-trace",
3631
- title: "Node stack trace leaking in response body",
3632
- phase: "response",
3633
- category: "info-exposure",
3634
- action: "redact",
3635
- // No prefilter: a Node stack frame has no single distinctive literal (` at ` is too common to
3636
- // gate on). The pattern is linearly bounded per line, so it runs on every screened body.
3637
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\n\\s+at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
3638
- },
3639
- {
3640
- id: "resp-sql-error",
3641
- title: "SQL / ORM error disclosure in response body",
3642
- phase: "response",
3643
- category: "info-exposure",
3644
- action: "redact",
3645
- prefilter: ["SQLSTATE", "Sequelize", "ER_", "ORA-", "PG::", "SQLITE_ERROR", "SQL syntax"],
3646
- 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" } }]
3647
- },
3648
- {
3649
- id: "resp-exception-trace",
3650
- title: "Backend exception / stack trace disclosure in response body",
3651
- phase: "response",
3652
- category: "info-exposure",
3653
- action: "redact",
3654
- // Multi-language exception/traceback signatures a normal API response never carries:
3655
- // Python traceback, Java "Exception in thread", .NET System.*Exception, JVM stack frames,
3656
- // Go goroutine dumps. (Node `at fn (file:line:col)` frames are handled by resp-stack-trace.)
3657
- prefilter: ["Traceback", "Exception in thread", "System.", "goroutine ", ".java:", ".kt:", ".scala:", ".rb:", ".py:", ".cs:"],
3658
- 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+ \\[)/' } }]
4082
+ // src/protect/engine/fetch.js
4083
+ var MAX_BODY_BYTES = 1024 * 1024;
4084
+ async function fromFetchRequest(request, options = {}) {
4085
+ const maxBodyBytes = options.maxBodyBytes ?? MAX_BODY_BYTES;
4086
+ const url = new URL(request.url);
4087
+ const method = (request.method || "GET").toUpperCase();
4088
+ const headers = {};
4089
+ request.headers.forEach((value, key) => {
4090
+ headers[key.toLowerCase()] = value;
4091
+ });
4092
+ const query = {};
4093
+ for (const [key, value] of url.searchParams) {
4094
+ if (key in query) {
4095
+ query[key] = Array.isArray(query[key]) ? [...query[key], value] : [query[key], value];
4096
+ } else {
4097
+ query[key] = value;
4098
+ }
3659
4099
  }
3660
- ];
3661
- var DEFAULT_EGRESS_RULES = [
3662
- {
3663
- id: "egress-internal-address",
3664
- title: "Outbound request to an internal / metadata address (SSRF)",
3665
- phase: "egress",
3666
- category: "ssrf",
3667
- rule_v2: [{ parameter: "egress.host", match: { type: "internal_host" } }]
4100
+ let rawBody = "";
4101
+ if (method !== "GET" && method !== "HEAD") {
4102
+ rawBody = await readCappedText(request, maxBodyBytes);
3668
4103
  }
3669
- ];
3670
-
3671
- // src/protect/block-page.js
3672
- function escapeHtml(s) {
3673
- return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
3674
- }
3675
- function renderBlockPage({ url = "", code = "patchstack" } = {}) {
3676
- const safeUrl = escapeHtml(url);
3677
- const safeCode = escapeHtml(code || "patchstack");
3678
- return `<!DOCTYPE html>
3679
- <html lang="en">
3680
- <head>
3681
- <meta charset="UTF-8">
3682
- <meta http-equiv="X-UA-Compatible" content="IE=edge">
3683
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
3684
- <style type="text/css">
3685
- .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}}
3686
- </style>
3687
- <title>Access Denied</title>
3688
- </head>
3689
- <body>
3690
- <div class="main-container">
3691
- <div class="container">
3692
- <div class="content">
3693
- <span class="access-denied">Access Denied</span>
3694
- <div class="site-url">
3695
- <input id="url" class="site-url-text" type="text"
3696
- value="${safeUrl}" readonly>
3697
- <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')">
3698
- </div>
3699
- <div class="site-url">
3700
- <input id="response" class="reason-code-text" type="text" value="${safeCode}" readonly>
3701
- <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')">
3702
- </div>
3703
- <div class="descp">
3704
- <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>
3705
- </div>
3706
- </div>
3707
- </div>
3708
- <div class="bottom-container">
3709
- <div class="bottom-text-containers">
3710
- <img class="arrow-vector" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOCIgaGVpZ2h0PSIxMyIgdmlld0JveD0iMCAwIDggMTMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik03LjQxIDEuNDMzNDRMNiAwLjAyMzQzNzVMMCA2LjAyMzQ0TDYgMTIuMDIzNEw3LjQxIDEwLjYxMzRMMi44MyA2LjAyMzQ0TDcuNDEgMS40MzM0NFoiIGZpbGw9IiNBRkU2MTQiLz4KPC9zdmc+Cg==" alt="Arrow Left">
3711
- <a href="/" class="return-text">Return to homepage</a>
3712
- </div>
3713
- <div class="bottom-second-containers">
3714
- <span class="secured-text">This website is secured by</span>
3715
- <a href="https://patchstack.com" class="logoText">
3716
- <img class="patch-logo" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTg0IiBoZWlnaHQ9IjIyIiB2aWV3Qm94PSIwIDAgMTg0IDIyIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTUwLjQwMDEgMTEuNTAwOEM1MC40MDAxIDkuMTAwNzggNDkuMzAwMSA4LjEwMDc4IDQ2LjYwMDEgOC4xMDA3OEM0NC4wMDAxIDguMTAwNzggNDIuNzAwMSA5LjIwMDc4IDQyLjcwMDEgMTEuNTAwOEM0Mi43MDAxIDEzLjgwMDggNDQuMDAwMSAxNC45MDA4IDQ2LjYwMDEgMTQuOTAwOEM0OS4zMDAxIDE0LjkwMDggNTAuNDAwMSAxMy44MDA4IDUwLjQwMDEgMTEuNTAwOFpNNTMuNDAwMSAxMS41MDA4QzUzLjQwMDEgMTUuMzAwOCA1MS4zMDAxIDE3LjYwMDggNDcuNDAwMSAxNy42MDA4QzQ1LjQwMDEgMTcuNjAwOCA0My42MDAxIDE2LjUwMDggNDIuNzAwMSAxNC41MDA4VjIxLjUwMDhIMzkuNjAwMVY1LjYwMDc4SDQyLjcwMDFWOC41MDA3OEM0My42MDAxIDYuNTAwNzggNDUuNDAwMSA1LjMwMDc4IDQ3LjUwMDEgNS4zMDA3OEM1MS40MDAxIDUuMzAwNzggNTMuNDAwMSA3LjcwMDc4IDUzLjQwMDEgMTEuNTAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNjYuNDAwMSAxMS40OTg0QzY2LjQwMDEgOS4xOTg0NCA2NS4xMDAxIDguMDk4NDQgNjIuNTAwMSA4LjA5ODQ0QzU5LjgwMDEgOC4wOTg0NCA1OC43MDAxIDkuMDk4NDQgNTguNzAwMSAxMS40OTg0QzU4LjcwMDEgMTMuNzk4NCA1OS44MDAxIDE0Ljg5ODQgNjIuNTAwMSAxNC44OTg0QzY1LjEwMDEgMTQuODk4NCA2Ni40MDAxIDEzLjc5ODQgNjYuNDAwMSAxMS40OTg0Wk02Ni4zMDAxIDUuNTk4NDRINjkuNDAwMVYxNy4zOTg0SDY2LjMwMDFWMTQuNDk4NEM2NS40MDAxIDE2LjQ5ODQgNjMuNzAwMSAxNy42OTg0IDYxLjYwMDEgMTcuNjk4NEM1Ny43MDAxIDE3LjY5ODQgNTUuNjAwMSAxNS4yOTg0IDU1LjYwMDEgMTEuNTk4NEM1NS42MDAxIDcuNzk4NDQgNTcuNjAwMSA1LjM5ODQ0IDYxLjQwMDEgNS4zOTg0NEM2My41MDAxIDUuMzk4NDQgNjUuNDAwMSA2LjU5ODQ0IDY2LjIwMDEgOC41OTg0NFY1LjU5ODQ0SDY2LjMwMDFaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTgxLjM5OTkgOC4zSDc3LjM5OTlWMTQuN0g4MS4wOTk5VjE3LjRINzcuOTk5OUM3NS43OTk5IDE3LjQgNzQuMjk5OSAxNS45IDc0LjI5OTkgMTMuN1Y4LjNINzEuMzk5OVY1LjZINzQuMjk5OVYySDc3LjM5OTlWNS41SDgxLjM5OTlWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik05My42OTk5IDEyLjIwMDhMOTYuNjk5OSAxMi45MDA4Qzk1Ljk5OTkgMTYuMDAwOCA5My42OTk5IDE3LjYwMDggODkuOTk5OSAxNy42MDA4Qzg3Ljc5OTkgMTcuNjAwOCA4Ni4wOTk5IDE3LjAwMDggODQuNzk5OSAxNS45MDA4QzgzLjQ5OTkgMTQuODAwOCA4Mi44OTk5IDEzLjMwMDggODIuODk5OSAxMS41MDA4QzgyLjg5OTkgOS42MDA3OCA4My40OTk5IDguMTAwNzggODQuNzk5OSA3LjAwMDc4Qzg2LjA5OTkgNS45MDA3OCA4Ny43OTk5IDUuMzAwNzggODkuOTk5OSA1LjMwMDc4QzkzLjY5OTkgNS4zMDA3OCA5NS45OTk5IDYuOTAwNzggOTYuNjk5OSAxMC4wMDA4TDkzLjY5OTkgMTAuNzAwOEM5My4yOTk5IDguODAwNzggOTIuMjk5OSA4LjEwMDc4IDg5Ljg5OTkgOC4xMDA3OEM4Ni45OTk5IDguMTAwNzggODUuODk5OSA5LjIwMDc4IDg1Ljg5OTkgMTEuNTAwOEM4NS44OTk5IDEzLjgwMDggODYuOTk5OSAxNC45MDA4IDg5Ljg5OTkgMTQuOTAwOEM5Mi4yOTk5IDE0LjkwMDggOTMuMjk5OSAxNC4yMDA4IDkzLjY5OTkgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTExLjggMTAuNlYxNy40SDEwOC43VjExLjJDMTA4LjcgOSAxMDcuOCA4LjEgMTA1LjkgOC4xQzEwMy42IDguMSAxMDIuNCA5LjMgMTAyLjQgMTEuN1YxNy4zSDk5LjI5OThWMC41SDEwMi40VjguM0MxMDMuMSA2LjMgMTA0LjYgNS4zIDEwNyA1LjNDMTEwLjIgNS4zIDExMS44IDcuMSAxMTEuOCAxMC42WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMTQuMSAxNC4wOTg0TDExNy4xIDEzLjI5ODRDMTE3LjQgMTQuNzk4NCAxMTguMyAxNS4yOTg0IDEyMC4yIDE1LjI5ODRDMTIxLjggMTUuMjk4NCAxMjIuNyAxNC44OTg0IDEyMi43IDE0LjA5ODRDMTIyLjcgMTMuMjk4NCAxMjIuMiAxMi45OTg0IDEyMC42IDEyLjc5ODRMMTE4LjIgMTIuNDk4NEMxMTUuOSAxMi4xOTg0IDExNC41IDEwLjg5ODQgMTE0LjUgOC45OTg0NEMxMTQuNSA2Ljc5ODQ0IDExNi41IDUuMzk4NDQgMTE5LjggNS4zOTg0NEMxMjMuMSA1LjM5ODQ0IDEyNSA2LjY5ODQ0IDEyNS41IDguODk4NDRMMTIyLjUgOS41OTg0NEMxMjIuMyA4LjA5ODQ0IDEyMS41IDcuNjk4NDQgMTE5LjkgNy42OTg0NEMxMTguNCA3LjY5ODQ0IDExNy42IDguMDk4NDQgMTE3LjYgOC44OTg0NEMxMTcuNiA5LjU5ODQ0IDExOC4xIDkuOTk4NDQgMTE5LjMgMTAuMDk4NEwxMjIuMiAxMC40OTg0QzEyNC40IDEwLjc5ODQgMTI1LjggMTEuOTk4NCAxMjUuOCAxMy44OTg0QzEyNS44IDE2LjI5ODQgMTIzLjcgMTcuNzk4NCAxMjAuMSAxNy43OTg0QzExNi43IDE3LjU5ODQgMTE0LjUgMTYuMTk4NCAxMTQuMSAxNC4wOTg0WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMzYuNyA4LjNIMTMyLjdWMTQuN0gxMzYuNFYxNy40SDEzMy4zQzEzMS4xIDE3LjQgMTI5LjYgMTUuOSAxMjkuNiAxMy43VjguM0gxMjYuN1Y1LjZIMTI5LjZWMkgxMzIuN1Y1LjVIMTM2LjdWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xNDkuMiAxMS40OTg0QzE0OS4yIDkuMTk4NDQgMTQ3LjkgOC4wOTg0NCAxNDUuMyA4LjA5ODQ0QzE0Mi42IDguMDk4NDQgMTQxLjUgOS4wOTg0NCAxNDEuNSAxMS40OTg0QzE0MS41IDEzLjc5ODQgMTQyLjYgMTQuODk4NCAxNDUuMyAxNC44OTg0QzE0Ny45IDE0Ljg5ODQgMTQ5LjIgMTMuNzk4NCAxNDkuMiAxMS40OTg0Wk0xNDkuMSA1LjU5ODQ0SDE1Mi4yVjE3LjM5ODRIMTQ5LjFWMTQuNDk4NEMxNDguMiAxNi40OTg0IDE0Ni41IDE3LjY5ODQgMTQ0LjQgMTcuNjk4NEMxNDAuNSAxNy42OTg0IDEzOC40IDE1LjI5ODQgMTM4LjQgMTEuNTk4NEMxMzguNCA3Ljc5ODQ0IDE0MC40IDUuMzk4NDQgMTQ0LjIgNS4zOTg0NEMxNDYuMyA1LjM5ODQ0IDE0OC4yIDYuNTk4NDQgMTQ5IDguNTk4NDRWNS41OTg0NEgxNDkuMVoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTY1LjYgMTIuMjAwOEwxNjguNiAxMi45MDA4QzE2Ny45IDE2LjAwMDggMTY1LjYgMTcuNjAwOCAxNjEuOSAxNy42MDA4QzE1OS43IDE3LjYwMDggMTU4IDE3LjAwMDggMTU2LjcgMTUuOTAwOEMxNTUuNCAxNC44MDA4IDE1NC44IDEzLjMwMDggMTU0LjggMTEuNTAwOEMxNTQuOCA5LjYwMDc4IDE1NS40IDguMTAwNzggMTU2LjcgNy4wMDA3OEMxNTggNS45MDA3OCAxNTkuNyA1LjMwMDc4IDE2MS45IDUuMzAwNzhDMTY1LjYgNS4zMDA3OCAxNjcuOSA2LjkwMDc4IDE2OC42IDEwLjAwMDhMMTY1LjYgMTAuNzAwOEMxNjUuMiA4LjgwMDc4IDE2NC4yIDguMTAwNzggMTYxLjggOC4xMDA3OEMxNTguOSA4LjEwMDc4IDE1Ny44IDkuMjAwNzggMTU3LjggMTEuNTAwOEMxNTcuOCAxMy44MDA4IDE1OC45IDE0LjkwMDggMTYxLjggMTQuOTAwOEMxNjQuMSAxNC45MDA4IDE2NS4xIDE0LjIwMDggMTY1LjYgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTgwLjI1NyAxNy40TDE3Ni42NTcgMTIuM0wxNzQuMTU3IDE0LjZWMTcuNEgxNzEuMTU3VjAuNUgxNzQuMTU3VjExLjFMMTc5Ljg1NyA1LjZIMTgzLjc1N0wxNzguNTU3IDEwLjVMMTgzLjk1NyAxNy40SDE4MC4yNTdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTAgMjEuNTAwOEg3LjNWMTYuMzAwOEgwVjIxLjUwMDhaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNy4yOTk4IDIxLjUwMDhIMTQuNTk5OFYxNi4zMDA4SDcuMjk5OFYyMS41MDA4WiIgZmlsbD0iIzZFQkUwMCIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTE0LjYwMDEgMTYuM0gyOS4yMDAxVjAuNUgxNC42MDAxVjE2LjNaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMCAxMUgxNC42VjAuNUgwVjExWiIgZmlsbD0iIzZFQkUwMCIvPgo8L3N2Zz4K" alt="Patchstack Logo">
3717
- </a>
3718
- </div>
3719
- </div>
3720
-
3721
- <div class="mobile-bottom-container">
3722
- <div class="mobile-bottom-text-containers">
3723
- <img class="arrow-vector" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOCIgaGVpZ2h0PSIxMyIgdmlld0JveD0iMCAwIDggMTMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik03LjQxIDEuNDMzNDRMNiAwLjAyMzQzNzVMMCA2LjAyMzQ0TDYgMTIuMDIzNEw3LjQxIDEwLjYxMzRMMi44MyA2LjAyMzQ0TDcuNDEgMS40MzM0NFoiIGZpbGw9IiNBRkU2MTQiLz4KPC9zdmc+Cg==" alt="Arrow Left">
3724
- <a href="/" class="return-text">Return to homepage</a>
3725
- </div>
3726
- <div class="mobile-bottom-second-containers">
3727
- <span class="secured-text">This website is secured by</span>
3728
- <a href="https://patchstack.com" class="logoText">
3729
- <img class="patch-logo" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTg0IiBoZWlnaHQ9IjIyIiB2aWV3Qm94PSIwIDAgMTg0IDIyIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTUwLjQwMDEgMTEuNTAwOEM1MC40MDAxIDkuMTAwNzggNDkuMzAwMSA4LjEwMDc4IDQ2LjYwMDEgOC4xMDA3OEM0NC4wMDAxIDguMTAwNzggNDIuNzAwMSA5LjIwMDc4IDQyLjcwMDEgMTEuNTAwOEM0Mi43MDAxIDEzLjgwMDggNDQuMDAwMSAxNC45MDA4IDQ2LjYwMDEgMTQuOTAwOEM0OS4zMDAxIDE0LjkwMDggNTAuNDAwMSAxMy44MDA4IDUwLjQwMDEgMTEuNTAwOFpNNTMuNDAwMSAxMS41MDA4QzUzLjQwMDEgMTUuMzAwOCA1MS4zMDAxIDE3LjYwMDggNDcuNDAwMSAxNy42MDA4QzQ1LjQwMDEgMTcuNjAwOCA0My42MDAxIDE2LjUwMDggNDIuNzAwMSAxNC41MDA4VjIxLjUwMDhIMzkuNjAwMVY1LjYwMDc4SDQyLjcwMDFWOC41MDA3OEM0My42MDAxIDYuNTAwNzggNDUuNDAwMSA1LjMwMDc4IDQ3LjUwMDEgNS4zMDA3OEM1MS40MDAxIDUuMzAwNzggNTMuNDAwMSA3LjcwMDc4IDUzLjQwMDEgMTEuNTAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNjYuNDAwMSAxMS40OTg0QzY2LjQwMDEgOS4xOTg0NCA2NS4xMDAxIDguMDk4NDQgNjIuNTAwMSA4LjA5ODQ0QzU5LjgwMDEgOC4wOTg0NCA1OC43MDAxIDkuMDk4NDQgNTguNzAwMSAxMS40OTg0QzU4LjcwMDEgMTMuNzk4NCA1OS44MDAxIDE0Ljg5ODQgNjIuNTAwMSAxNC44OTg0QzY1LjEwMDEgMTQuODk4NCA2Ni40MDAxIDEzLjc5ODQgNjYuNDAwMSAxMS40OTg0Wk02Ni4zMDAxIDUuNTk4NDRINjkuNDAwMVYxNy4zOTg0SDY2LjMwMDFWMTQuNDk4NEM2NS40MDAxIDE2LjQ5ODQgNjMuNzAwMSAxNy42OTg0IDYxLjYwMDEgMTcuNjk4NEM1Ny43MDAxIDE3LjY5ODQgNTUuNjAwMSAxNS4yOTg0IDU1LjYwMDEgMTEuNTk4NEM1NS42MDAxIDcuNzk4NDQgNTcuNjAwMSA1LjM5ODQ0IDYxLjQwMDEgNS4zOTg0NEM2My41MDAxIDUuMzk4NDQgNjUuNDAwMSA2LjU5ODQ0IDY2LjIwMDEgOC41OTg0NFY1LjU5ODQ0SDY2LjMwMDFaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTgxLjM5OTkgOC4zSDc3LjM5OTlWMTQuN0g4MS4wOTk5VjE3LjRINzcuOTk5OUM3NS43OTk5IDE3LjQgNzQuMjk5OSAxNS45IDc0LjI5OTkgMTMuN1Y4LjNINzEuMzk5OVY1LjZINzQuMjk5OVYySDc3LjM5OTlWNS41SDgxLjM5OTlWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik05My42OTk5IDEyLjIwMDhMOTYuNjk5OSAxMi45MDA4Qzk1Ljk5OTkgMTYuMDAwOCA5My42OTk5IDE3LjYwMDggODkuOTk5OSAxNy42MDA4Qzg3Ljc5OTkgMTcuNjAwOCA4Ni4wOTk5IDE3LjAwMDggODQuNzk5OSAxNS45MDA4QzgzLjQ5OTkgMTQuODAwOCA4Mi44OTk5IDEzLjMwMDggODIuODk5OSAxMS41MDA4QzgyLjg5OTkgOS42MDA3OCA4My40OTk5IDguMTAwNzggODQuNzk5OSA3LjAwMDc4Qzg2LjA5OTkgNS45MDA3OCA4Ny43OTk5IDUuMzAwNzggODkuOTk5OSA1LjMwMDc4QzkzLjY5OTkgNS4zMDA3OCA5NS45OTk5IDYuOTAwNzggOTYuNjk5OSAxMC4wMDA4TDkzLjY5OTkgMTAuNzAwOEM5My4yOTk5IDguODAwNzggOTIuMjk5OSA4LjEwMDc4IDg5Ljg5OTkgOC4xMDA3OEM4Ni45OTk5IDguMTAwNzggODUuODk5OSA5LjIwMDc4IDg1Ljg5OTkgMTEuNTAwOEM4NS44OTk5IDEzLjgwMDggODYuOTk5OSAxNC45MDA4IDg5Ljg5OTkgMTQuOTAwOEM5Mi4yOTk5IDE0LjkwMDggOTMuMjk5OSAxNC4yMDA4IDkzLjY5OTkgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTExLjggMTAuNlYxNy40SDEwOC43VjExLjJDMTA4LjcgOSAxMDcuOCA4LjEgMTA1LjkgOC4xQzEwMy42IDguMSAxMDIuNCA5LjMgMTAyLjQgMTEuN1YxNy4zSDk5LjI5OThWMC41SDEwMi40VjguM0MxMDMuMSA2LjMgMTA0LjYgNS4zIDEwNyA1LjNDMTEwLjIgNS4zIDExMS44IDcuMSAxMTEuOCAxMC42WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMTQuMSAxNC4wOTg0TDExNy4xIDEzLjI5ODRDMTE3LjQgMTQuNzk4NCAxMTguMyAxNS4yOTg0IDEyMC4yIDE1LjI5ODRDMTIxLjggMTUuMjk4NCAxMjIuNyAxNC44OTg0IDEyMi43IDE0LjA5ODRDMTIyLjcgMTMuMjk4NCAxMjIuMiAxMi45OTg0IDEyMC42IDEyLjc5ODRMMTE4LjIgMTIuNDk4NEMxMTUuOSAxMi4xOTg0IDExNC41IDEwLjg5ODQgMTE0LjUgOC45OTg0NEMxMTQuNSA2Ljc5ODQ0IDExNi41IDUuMzk4NDQgMTE5LjggNS4zOTg0NEMxMjMuMSA1LjM5ODQ0IDEyNSA2LjY5ODQ0IDEyNS41IDguODk4NDRMMTIyLjUgOS41OTg0NEMxMjIuMyA4LjA5ODQ0IDEyMS41IDcuNjk4NDQgMTE5LjkgNy42OTg0NEMxMTguNCA3LjY5ODQ0IDExNy42IDguMDk4NDQgMTE3LjYgOC44OTg0NEMxMTcuNiA5LjU5ODQ0IDExOC4xIDkuOTk4NDQgMTE5LjMgMTAuMDk4NEwxMjIuMiAxMC40OTg0QzEyNC40IDEwLjc5ODQgMTI1LjggMTEuOTk4NCAxMjUuOCAxMy44OTg0QzEyNS44IDE2LjI5ODQgMTIzLjcgMTcuNzk4NCAxMjAuMSAxNy43OTg0QzExNi43IDE3LjU5ODQgMTE0LjUgMTYuMTk4NCAxMTQuMSAxNC4wOTg0WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMzYuNyA4LjNIMTMyLjdWMTQuN0gxMzYuNFYxNy40SDEzMy4zQzEzMS4xIDE3LjQgMTI5LjYgMTUuOSAxMjkuNiAxMy43VjguM0gxMjYuN1Y1LjZIMTI5LjZWMkgxMzIuN1Y1LjVIMTM2LjdWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xNDkuMiAxMS40OTg0QzE0OS4yIDkuMTk4NDQgMTQ3LjkgOC4wOTg0NCAxNDUuMyA4LjA5ODQ0QzE0Mi42IDguMDk4NDQgMTQxLjUgOS4wOTg0NCAxNDEuNSAxMS40OTg0QzE0MS41IDEzLjc5ODQgMTQyLjYgMTQuODk4NCAxNDUuMyAxNC44OTg0QzE0Ny45IDE0Ljg5ODQgMTQ5LjIgMTMuNzk4NCAxNDkuMiAxMS40OTg0Wk0xNDkuMSA1LjU5ODQ0SDE1Mi4yVjE3LjM5ODRIMTQ5LjFWMTQuNDk4NEMxNDguMiAxNi40OTg0IDE0Ni41IDE3LjY5ODQgMTQ0LjQgMTcuNjk4NEMxNDAuNSAxNy42OTg0IDEzOC40IDE1LjI5ODQgMTM4LjQgMTEuNTk4NEMxMzguNCA3Ljc5ODQ0IDE0MC40IDUuMzk4NDQgMTQ0LjIgNS4zOTg0NEMxNDYuMyA1LjM5ODQ0IDE0OC4yIDYuNTk4NDQgMTQ5IDguNTk4NDRWNS41OTg0NEgxNDkuMVoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTY1LjYgMTIuMjAwOEwxNjguNiAxMi45MDA4QzE2Ny45IDE2LjAwMDggMTY1LjYgMTcuNjAwOCAxNjEuOSAxNy42MDA4QzE1OS43IDE3LjYwMDggMTU4IDE3LjAwMDggMTU2LjcgMTUuOTAwOEMxNTUuNCAxNC44MDA4IDE1NC44IDEzLjMwMDggMTU0LjggMTEuNTAwOEMxNTQuOCA5LjYwMDc4IDE1NS40IDguMTAwNzggMTU2LjcgNy4wMDA3OEMxNTggNS45MDA3OCAxNTkuNyA1LjMwMDc4IDE2MS45IDUuMzAwNzhDMTY1LjYgNS4zMDA3OCAxNjcuOSA2LjkwMDc4IDE2OC42IDEwLjAwMDhMMTY1LjYgMTAuNzAwOEMxNjUuMiA4LjgwMDc4IDE2NC4yIDguMTAwNzggMTYxLjggOC4xMDA3OEMxNTguOSA4LjEwMDc4IDE1Ny44IDkuMjAwNzggMTU3LjggMTEuNTAwOEMxNTcuOCAxMy44MDA4IDE1OC45IDE0LjkwMDggMTYxLjggMTQuOTAwOEMxNjQuMSAxNC45MDA4IDE2NS4xIDE0LjIwMDggMTY1LjYgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTgwLjI1NyAxNy40TDE3Ni42NTcgMTIuM0wxNzQuMTU3IDE0LjZWMTcuNEgxNzEuMTU3VjAuNUgxNzQuMTU3VjExLjFMMTc5Ljg1NyA1LjZIMTgzLjc1N0wxNzguNTU3IDEwLjVMMTgzLjk1NyAxNy40SDE4MC4yNTdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTAgMjEuNTAwOEg3LjNWMTYuMzAwOEgwVjIxLjUwMDhaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNy4yOTk4IDIxLjUwMDhIMTQuNTk5OFYxNi4zMDA4SDcuMjk5OFYyMS41MDA4WiIgZmlsbD0iIzZFQkUwMCIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTE0LjYwMDEgMTYuM0gyOS4yMDAxVjAuNUgxNC42MDAxVjE2LjNaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMCAxMUgxNC42VjAuNUgwVjExWiIgZmlsbD0iIzZFQkUwMCIvPgo8L3N2Zz4K" alt="Patchstack Logo">
3730
- </a>
3731
- </div>
3732
- </div>
3733
- </div>
3734
- </body>
3735
- <script>
3736
- function copyText(id) {
3737
- var copyText = document.getElementById(id)
3738
- copyText.select();
3739
- copyText.setSelectionRange(0, 99999);
3740
- navigator.clipboard.writeText(copyText.value);
3741
- }
3742
-
3743
- function hover(element) {
3744
- element.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkiIGhlaWdodD0iMjMiIHZpZXdCb3g9IjAgMCAxOSAyMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0IDAuMDIzNDM3NUgwVjE2LjAyMzRIMlYyLjAyMzQ0SDE0VjAuMDIzNDM3NVpNMTkgNC4wMjM0NEg0VjIyLjAyMzRIMTlWNC4wMjM0NFpNMTcgMjAuMDIzNEg2VjYuMDIzNDRIMTdWMjAuMDIzNFoiIGZpbGw9IiNBRkU2MTQiLz4KPC9zdmc+Cg==');
3745
- }
3746
-
3747
- function unhover(element) {
3748
- element.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkiIGhlaWdodD0iMjMiIHZpZXdCb3g9IjAgMCAxOSAyMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0IDAuMDIzNDM3NUgwVjE2LjAyMzRIMlYyLjAyMzQ0SDE0VjAuMDIzNDM3NVpNMTkgNC4wMjM0NEg0VjIyLjAyMzRIMTlWNC4wMjM0NFpNMTcgMjAuMDIzNEg2VjYuMDIzNDRIMTdWMjAuMDIzNFoiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=');
3749
- }
3750
- </script>
3751
- </html>`;
3752
- }
3753
-
3754
- // src/protect/rules/store.js
3755
- var fsMod;
3756
- async function loadFs() {
3757
- if (fsMod !== void 0) return fsMod;
3758
- try {
3759
- const [fs, path7] = await Promise.all([import("fs"), import("path")]);
3760
- fsMod = {
3761
- readFileSync: fs.readFileSync,
3762
- writeFileSync: fs.writeFileSync,
3763
- mkdirSync: fs.mkdirSync,
3764
- join: path7.join
3765
- };
3766
- } catch {
3767
- fsMod = null;
3768
- }
3769
- return fsMod;
3770
- }
3771
- function makeStore(options = {}) {
3772
- let mem = null;
3773
- const durable = durableTier(options);
3774
- return {
3775
- async read() {
3776
- if (mem) return mem;
3777
- const env = await durable.read();
3778
- if (env) mem = env;
3779
- return env;
3780
- },
3781
- async write(env) {
3782
- if (env) mem = env;
3783
- await durable.write(env);
3784
- }
3785
- };
3786
- }
3787
- function durableTier(options) {
3788
- const adapter = options.ruleCache;
3789
- if (adapter && typeof adapter.read === "function" && typeof adapter.write === "function") {
3790
- return {
3791
- read: async () => {
3792
- try {
3793
- return toEnvelope(await adapter.read());
3794
- } catch {
3795
- return null;
3796
- }
3797
- },
3798
- write: async (env) => {
3799
- try {
3800
- await adapter.write(env);
3801
- } catch {
3802
- }
3803
- }
3804
- };
4104
+ const contentType = headers["content-type"] || "";
4105
+ let body = {};
4106
+ let files;
4107
+ if (rawBody) {
4108
+ const parsed = parseBody(rawBody, contentType);
4109
+ body = parsed.body;
4110
+ files = parsed.files;
3805
4111
  }
3806
- const dir = options.cacheDir;
4112
+ const uri = url.pathname + url.search;
4113
+ const client = resolveClientIp({ peer: options.peer, headers, trustedProxy: options.trustedProxy });
3807
4114
  return {
3808
- read: async () => cacheRead(dir),
3809
- write: async (env) => cacheWrite(dir, env)
4115
+ method,
4116
+ url: uri,
4117
+ originalUrl: uri,
4118
+ query,
4119
+ body,
4120
+ files,
4121
+ headers,
4122
+ // The resolved address, as an own property. Nothing downstream re-derives or falls back: a consumer
4123
+ // that guessed differently would attribute one request to two addresses.
4124
+ ip: client.ip ?? "",
4125
+ _clientIp: client,
4126
+ cookies: parseCookies(headers.cookie),
4127
+ // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify
4128
+ // drops, so prototype-pollution rules on `raw` are robust.
4129
+ _rawBody: rawBody
3810
4130
  };
3811
4131
  }
3812
- async function cacheWrite(dir, env) {
3813
- if (!dir) return;
3814
- const fs = await loadFs();
3815
- if (!fs) return;
4132
+ async function readCappedText(request, max) {
4133
+ let clone;
3816
4134
  try {
3817
- fs.mkdirSync(dir, { recursive: true });
3818
- fs.writeFileSync(fs.join(dir, "patchstack-rules.json"), JSON.stringify(env));
4135
+ clone = request.clone();
3819
4136
  } catch {
4137
+ return "";
4138
+ }
4139
+ const body = clone.body;
4140
+ if (body && typeof body.getReader === "function") {
4141
+ const reader = body.getReader();
4142
+ const chunks = [];
4143
+ let buffered = 0;
4144
+ try {
4145
+ for (; ; ) {
4146
+ const { done, value } = await reader.read();
4147
+ if (done) break;
4148
+ if (!value || buffered >= max) continue;
4149
+ const take = Math.min(value.byteLength, max - buffered);
4150
+ chunks.push(take === value.byteLength ? value : value.subarray(0, take));
4151
+ buffered += take;
4152
+ }
4153
+ } catch {
4154
+ return "";
4155
+ }
4156
+ try {
4157
+ return new TextDecoder().decode(concatChunks(chunks, buffered));
4158
+ } catch {
4159
+ return "";
4160
+ }
3820
4161
  }
3821
- }
3822
- async function cacheRead(dir) {
3823
- if (!dir) return null;
3824
- const fs = await loadFs();
3825
- if (!fs) return null;
3826
4162
  try {
3827
- return toEnvelope(JSON.parse(fs.readFileSync(fs.join(dir, "patchstack-rules.json"), "utf8")));
4163
+ const text = await clone.text();
4164
+ return text.length > max ? text.slice(0, max) : text;
3828
4165
  } catch {
3829
- return null;
3830
- }
3831
- }
3832
- function toEnvelope(value) {
3833
- if (!value || typeof value !== "object") return null;
3834
- if (value.bundle && typeof value.bundle === "object") {
3835
- return { bundle: value.bundle, etag: value.etag ?? null };
3836
- }
3837
- if (Array.isArray(value.firewall) || Array.isArray(value.whitelists)) {
3838
- return { bundle: value, etag: null };
4166
+ return "";
3839
4167
  }
3840
- return null;
3841
4168
  }
3842
-
3843
- // src/protect/rules/contract.js
3844
- var SOURCES = Object.freeze({
3845
- raw: Object.freeze({ keyed: false }),
3846
- all: Object.freeze({ keyed: false }),
3847
- false: Object.freeze({ keyed: false }),
3848
- rules: Object.freeze({ keyed: false, group: true }),
3849
- get: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
3850
- post: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
3851
- request: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
3852
- cookie: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
3853
- // A bare `files.<name>` resolves to the filename; an optional trailing attribute selects another part
3854
- // of the upload. Optional, not required — filename-scoped rules use the bare form.
3855
- files: Object.freeze({
3856
- keyed: true,
3857
- keys: "any",
3858
- wildcard: true,
3859
- optional_key_suffixes: Object.freeze(["content", "filename", "type"])
3860
- }),
3861
- server: Object.freeze({
3862
- keyed: true,
3863
- keys: Object.freeze([
3864
- "REQUEST_URI",
3865
- "REQUEST_METHOD",
3866
- "HTTP_USER_AGENT",
3867
- "HTTP_REFERER",
3868
- "HTTP_HOST",
3869
- "REMOTE_ADDR",
3870
- "ip",
3871
- "CONTENT_TYPE",
3872
- "CONTENT_LENGTH"
3873
- ]),
3874
- key_prefixes: Object.freeze(["HTTP_"])
3875
- }),
3876
- response: Object.freeze({
3877
- keyed: true,
3878
- keys: Object.freeze(["status", "body", "headers"]),
3879
- key_prefixes: Object.freeze(["header."])
3880
- }),
3881
- egress: Object.freeze({ keyed: true, keys: Object.freeze(["url", "host", "method"]) })
3882
- });
3883
- var MATCH_TYPES = Object.freeze([
3884
- "array_in_array",
3885
- "array_key_value",
3886
- "contains",
3887
- "cors_reflected",
3888
- "cross_origin",
3889
- "ctype_alnum",
3890
- "ctype_digit",
3891
- "ctype_special",
3892
- "equals",
3893
- "equals_strict",
3894
- "hostname",
3895
- "in_array",
3896
- "inline_js_xss",
3897
- "inline_xss",
3898
- "internal_host",
3899
- "is_numeric",
3900
- "isset",
3901
- "jwt_claim_equals",
3902
- "less_than",
3903
- "more_than",
3904
- "not_contains",
3905
- "not_in_array",
3906
- "off_origin",
3907
- "quotes",
3908
- "regex",
3909
- "stripos"
3910
- ]);
3911
- var PARAMETERLESS_MATCH_TYPES = Object.freeze(["cross_origin", "off_origin", "cors_reflected"]);
3912
- var OPERANDLESS_MATCH_TYPES = Object.freeze([
3913
- "isset",
3914
- "quotes",
3915
- "is_numeric",
3916
- "ctype_alnum",
3917
- "ctype_digit",
3918
- "ctype_special",
3919
- "inline_xss",
3920
- "inline_js_xss",
3921
- "internal_host",
3922
- "cross_origin",
3923
- "off_origin",
3924
- "cors_reflected"
3925
- ]);
3926
- var OPERAND_SHAPES = Object.freeze([
3927
- "positive-number",
3928
- "boolean",
3929
- "non-empty-string",
3930
- "regex-literal",
3931
- "string-or-string-list",
3932
- "scalar",
3933
- "string",
3934
- "numeric",
3935
- "scalar-or-non-empty-list",
3936
- "non-empty-list",
3937
- "object",
3938
- "non-empty-object",
3939
- "non-empty-string-list",
3940
- "header-name-map",
3941
- "header-name-list",
3942
- "cookie-flags"
3943
- ]);
3944
- var COOKIE_FLAGS = Object.freeze(["httpOnly", "secure", "sameSite"]);
3945
- var SAME_SITE_VALUES = Object.freeze(["Strict", "Lax", "None"]);
3946
- var HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
3947
- var REGEX_LITERAL = /^\/(.+?)\/([gimsuy]*)$/s;
3948
- var MATCH_OPERANDS = Object.freeze({
3949
- array_key_value: Object.freeze({
3950
- required: Object.freeze(["key", "match"]),
3951
- shapes: Object.freeze({ key: "string-or-string-list", match: "object" })
3952
- }),
3953
- // Decides on a JWT's DECODED payload rather than its shape, and yields the matching token spans so
3954
- // `redact` masks only those tokens. `claim` names an own, top-level claim; `value` is compared with
3955
- // exact string equality. Anything not positively identified — undecodable, not JSON, not an object,
3956
- // oversized, claim missing, claim not a string, or a different value — is a no-match.
3957
- jwt_claim_equals: Object.freeze({
3958
- required: Object.freeze(["claim", "value"]),
3959
- shapes: Object.freeze({ claim: "non-empty-string", value: "non-empty-string" })
3960
- }),
3961
- regex: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "regex-literal" }) }),
3962
- contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
3963
- stripos: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
3964
- not_contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
3965
- // A hostname is compared to `new URL(value).hostname`, so an empty one only ever equals the empty
3966
- // hostname of an opaque-scheme URL (`file:`, `mailto:`, `data:`) — never a host, and a false positive
3967
- // on those. The other three are the list-membership family: an empty list makes `in_array` and
3968
- // `array_in_array` inert and `not_in_array` true for every value.
3969
- hostname: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
3970
- in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
3971
- not_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
3972
- array_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-list" }) }),
3973
- // The comparison family. `equals` coerces, so any scalar can match. `equals_strict` does not, and its
3974
- // subject is always a string by the time it is compared — so a number operand is unconditionally
3975
- // inert, which is the trap the engine's own note about `'1' !== 1` describes.
3976
- equals: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar" }) }),
3977
- equals_strict: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "string" }) }),
3978
- less_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) }),
3979
- more_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) })
3980
- });
3981
- function operandShapeProblem(shape, value) {
3982
- const isFilledString = (v) => typeof v === "string" && v.trim() !== "";
3983
- const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
3984
- const isHeaderName = (v) => typeof v === "string" && HEADER_NAME.test(v);
3985
- switch (shape) {
3986
- case "positive-number":
3987
- return typeof value === "number" && Number.isFinite(value) && value > 0 ? null : "must be a finite positive number";
3988
- case "boolean":
3989
- return typeof value === "boolean" ? null : "must be true or false";
3990
- case "non-empty-string":
3991
- return isFilledString(value) ? null : "must be a non-empty string";
3992
- case "regex-literal":
3993
- if (!isFilledString(value)) return "must be a non-empty string";
3994
- return REGEX_LITERAL.test(value) ? null : "must be a delimited pattern, as in /pattern/i";
3995
- case "string-or-string-list":
3996
- if (isFilledString(value)) return null;
3997
- if (Array.isArray(value) && value.length > 0 && value.every(isFilledString)) return null;
3998
- return "must be a non-empty string, or a non-empty list of them";
3999
- case "scalar":
4000
- return isScalar(value) ? null : "must be a string, number or boolean";
4001
- case "string":
4002
- return typeof value === "string" ? null : "must be a string (this comparison is type-strict)";
4003
- case "numeric":
4004
- return isScalar(value) && Number.isFinite(Number(value)) ? null : "must be a number";
4005
- case "scalar-or-non-empty-list":
4006
- if (isScalar(value)) return null;
4007
- if (Array.isArray(value) && value.length > 0 && value.every(isScalar)) return null;
4008
- return "must be a scalar, or a non-empty list of them";
4009
- case "non-empty-list":
4010
- return Array.isArray(value) && value.length > 0 && value.every(isScalar) ? null : "must be a non-empty list of scalars";
4011
- case "header-name-map": {
4012
- if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
4013
- const names = Object.keys(value);
4014
- if (names.length === 0) return "must be an object with at least one entry";
4015
- const bad = names.find((name) => !isHeaderName(name));
4016
- return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
4017
- }
4018
- case "header-name-list": {
4019
- if (!Array.isArray(value) || value.length === 0) return "must be a non-empty list of HTTP header names";
4020
- const bad = value.find((name) => !isHeaderName(name));
4021
- return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
4022
- }
4023
- case "cookie-flags": {
4024
- if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
4025
- const unknown = Object.keys(value).filter((key) => !COOKIE_FLAGS.includes(key));
4026
- if (unknown.length > 0) return `has no flag named ${unknown.map((k) => JSON.stringify(k)).join(", ")}`;
4027
- for (const flag of ["httpOnly", "secure"]) {
4028
- if (value[flag] !== void 0 && typeof value[flag] !== "boolean") {
4029
- return `flag "${flag}" must be true or false`;
4030
- }
4031
- }
4032
- const sameSite = value.sameSite;
4033
- const sameSiteOff = sameSite === false;
4034
- if (sameSite !== void 0 && !sameSiteOff) {
4035
- if (typeof sameSite !== "string" || !SAME_SITE_VALUES.some((v) => v.toLowerCase() === sameSite.toLowerCase())) {
4036
- return `flag "sameSite" must be false, or one of ${SAME_SITE_VALUES.join(", ")}`;
4037
- }
4038
- }
4039
- if (value.httpOnly === false && value.secure === false && sameSiteOff) {
4040
- return "turns off every flag, so it would harden nothing";
4041
- }
4042
- return null;
4043
- }
4044
- case "object":
4045
- return value !== null && typeof value === "object" && !Array.isArray(value) ? null : "must be an object";
4046
- case "non-empty-object":
4047
- return value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0 ? null : "must be an object with at least one entry";
4048
- case "non-empty-string-list":
4049
- return Array.isArray(value) && value.length > 0 && value.every(isFilledString) ? null : "must be a non-empty list of non-empty strings";
4050
- default:
4051
- return null;
4169
+ function concatChunks(chunks, total) {
4170
+ const out = new Uint8Array(total);
4171
+ let offset = 0;
4172
+ for (const c of chunks) {
4173
+ out.set(c, offset);
4174
+ offset += c.byteLength;
4052
4175
  }
4176
+ return out;
4053
4177
  }
4054
- var MUTATIONS = Object.freeze([
4055
- "base64_decode",
4056
- "getArrayValues",
4057
- "htmlentitydecode",
4058
- "intval",
4059
- "json_decode",
4060
- "json_encode",
4061
- "urldecode"
4062
- ]);
4063
- var PHASES = Object.freeze(["request", "response", "egress"]);
4064
- var ACTIONS = Object.freeze([
4065
- "block",
4066
- "redact",
4067
- "encode",
4068
- "set-header",
4069
- "remove-header",
4070
- "harden-cookie"
4071
- ]);
4072
- var RULE_PROPERTY_SHAPES = Object.freeze({
4073
- max_bytes: "positive-number",
4074
- bypass_limit: "boolean",
4075
- ensure: "boolean",
4076
- prefilter: "non-empty-string-list"
4077
- });
4078
- var ENFORCEMENT_VALUES = Object.freeze(["dry-run"]);
4079
- var WHEN_KEYS = Object.freeze(["path", "method"]);
4080
- var RULE_PROPERTIES = Object.freeze([
4081
- "id",
4082
- "rule_id",
4083
- "title",
4084
- "category",
4085
- "phase",
4086
- "action",
4087
- "rule_v2",
4088
- "when",
4089
- "message",
4090
- "enforcement",
4091
- "source_revision",
4092
- "prefilter",
4093
- "max_bytes",
4094
- "bypass_limit",
4095
- "set_headers",
4096
- "remove_headers",
4097
- "cookie_flags",
4098
- "ensure"
4099
- ]);
4100
- var ACTION_PROPERTIES = Object.freeze({
4101
- block: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["request", "response", "egress"]) }),
4102
- redact: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
4103
- encode: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
4104
- "set-header": Object.freeze({
4105
- required: Object.freeze(["set_headers"]),
4106
- defaulted: Object.freeze(["ensure"]),
4107
- phases: Object.freeze(["response"]),
4108
- shapes: Object.freeze({ set_headers: "header-name-map" })
4109
- }),
4110
- "remove-header": Object.freeze({
4111
- required: Object.freeze(["remove_headers"]),
4112
- defaulted: Object.freeze([]),
4113
- phases: Object.freeze(["response"]),
4114
- shapes: Object.freeze({ remove_headers: "header-name-list" })
4115
- }),
4116
- "harden-cookie": Object.freeze({
4117
- required: Object.freeze([]),
4118
- defaulted: Object.freeze(["cookie_flags"]),
4119
- phases: Object.freeze(["response"]),
4120
- shapes: Object.freeze({ cookie_flags: "cookie-flags" })
4121
- })
4122
- });
4123
- var LIMITS = Object.freeze({
4124
- maxRules: 5e3,
4125
- maxWhitelists: 2e3,
4126
- maxConditionsPerRule: 250,
4127
- maxNestingDepth: 12,
4128
- maxRegexLength: 1e3,
4129
- maxValueLength: 8192
4130
- });
4131
- var GROUP_PARAMETER = "rules";
4132
- function isGroup(condition) {
4133
- return condition?.parameter === GROUP_PARAMETER && Array.isArray(condition?.rules);
4178
+ function parseBody(rawBody, contentType) {
4179
+ const ct = String(contentType || "").toLowerCase();
4180
+ const isJson = ct.includes("application/json") || /\+json\b/.test(ct);
4181
+ const isForm = ct.includes("application/x-www-form-urlencoded");
4182
+ const isMultipart = ct.includes("multipart/form-data");
4183
+ const isAmbiguous = ct === "" || ct.startsWith("text/plain") || ct.includes("csp-report") || ct.includes("/json");
4184
+ if (isMultipart) {
4185
+ const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
4186
+ if (boundary) return parseMultipart(rawBody, boundary);
4187
+ return { body: {}, files: void 0 };
4188
+ }
4189
+ if (isForm) {
4190
+ const body = {};
4191
+ for (const [k, v] of new URLSearchParams(rawBody)) body[k] = k in body ? [].concat(body[k], v) : v;
4192
+ return { body, files: void 0 };
4193
+ }
4194
+ if (isJson || isAmbiguous) {
4195
+ try {
4196
+ const parsed = JSON.parse(rawBody);
4197
+ if (parsed && typeof parsed === "object") return { body: parsed, files: void 0 };
4198
+ } catch {
4199
+ }
4200
+ }
4201
+ return { body: {}, files: void 0 };
4134
4202
  }
4135
- function conditionShapeProblem(condition) {
4136
- if (!condition || typeof condition !== "object") return "condition is not an object";
4137
- const named = condition.parameter === GROUP_PARAMETER;
4138
- const nested = Array.isArray(condition.rules);
4139
- if (named && !nested) {
4140
- return `parameter "${GROUP_PARAMETER}" is a group and needs a "rules" array`;
4203
+ function parseMultipart(rawBody, boundary) {
4204
+ const body = {};
4205
+ const files = {};
4206
+ for (const part of rawBody.split("--" + boundary)) {
4207
+ const sep = /\r?\n\r?\n/.exec(part);
4208
+ if (!sep) continue;
4209
+ const rawHeaders = part.slice(0, sep.index);
4210
+ const disposition = /content-disposition:[^\r\n]*/i.exec(rawHeaders)?.[0];
4211
+ if (!disposition) continue;
4212
+ const name = /name="([^"]*)"/i.exec(disposition)?.[1];
4213
+ if (name == null) continue;
4214
+ const content = part.slice(sep.index + sep[0].length).replace(/\r?\n$/, "");
4215
+ const filename = /filename="([^"]*)"/i.exec(disposition)?.[1];
4216
+ if (filename !== void 0) {
4217
+ const partType = /content-type:\s*([^\r\n;]+)/i.exec(rawHeaders)?.[1]?.trim() || "";
4218
+ const file = { filename, type: partType, content };
4219
+ files[name] = name in files ? [].concat(files[name], file) : file;
4220
+ } else {
4221
+ body[name] = name in body ? [].concat(body[name], content) : content;
4222
+ }
4141
4223
  }
4142
- if (nested && !named) {
4143
- return `condition carries nested rules but its parameter is ${JSON.stringify(condition.parameter)}; a group must be {"parameter":"${GROUP_PARAMETER}"}`;
4224
+ return { body, files };
4225
+ }
4226
+ function parseCookies(header) {
4227
+ const cookies = {};
4228
+ if (!header) {
4229
+ return cookies;
4144
4230
  }
4145
- if (named && nested) {
4146
- if (condition.match !== void 0) return "a group carries no match of its own; the engine ignores it";
4147
- if (condition.mutations !== void 0) return "a group carries no mutations of its own; the engine ignores them";
4148
- if (condition.rules.length === 0) return "group has no conditions (would never match)";
4231
+ for (const pair of header.split(";")) {
4232
+ const idx = pair.indexOf("=");
4233
+ if (idx === -1) {
4234
+ continue;
4235
+ }
4236
+ cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
4149
4237
  }
4150
- return null;
4238
+ return cookies;
4151
4239
  }
4152
- function parameterProblem(parameter) {
4153
- if (parameter === void 0 || parameter === null) return null;
4154
- if (Array.isArray(parameter)) {
4155
- if (parameter.length === 0) return "parameter list is empty";
4156
- for (const member of parameter) {
4157
- const problem = parameterProblem(member);
4158
- if (problem !== null) return problem;
4240
+
4241
+ // src/protect/engine/node.js
4242
+ function fromNodeRequest(req, rawBody = "", options = {}) {
4243
+ const method = (req.method || "GET").toUpperCase();
4244
+ const headers = {};
4245
+ for (const [key, value] of Object.entries(req.headers || {})) {
4246
+ headers[key.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;
4247
+ }
4248
+ const host = headers.host || "localhost";
4249
+ let url;
4250
+ try {
4251
+ url = new URL(req.url || "/", `http://${host}`);
4252
+ } catch {
4253
+ try {
4254
+ url = new URL(req.url || "/", "http://localhost");
4255
+ } catch {
4256
+ url = new URL("http://localhost/");
4159
4257
  }
4160
- return null;
4161
4258
  }
4162
- if (typeof parameter !== "string" || parameter === "") return "parameter must be a non-empty string";
4163
- if (parameter === GROUP_PARAMETER) {
4164
- return `"${GROUP_PARAMETER}" is the group source and is only valid as a whole group, not as a parameter`;
4259
+ const query = {};
4260
+ for (const [key, value] of url.searchParams) {
4261
+ if (key in query) {
4262
+ query[key] = Array.isArray(query[key]) ? [...query[key], value] : [query[key], value];
4263
+ } else {
4264
+ query[key] = value;
4265
+ }
4165
4266
  }
4166
- const dot = parameter.indexOf(".");
4167
- const name = dot === -1 ? parameter : parameter.slice(0, dot);
4168
- const source = SOURCES[name];
4169
- if (source === void 0) return `unknown parameter source "${name}"`;
4170
- if (dot === -1) {
4171
- return source.keyed === true ? `source "${name}" needs a key, e.g. "${name}.fieldname"` : null;
4267
+ const contentType = headers["content-type"] || "";
4268
+ let body = {};
4269
+ let files;
4270
+ if (rawBody) {
4271
+ const parsed = parseBody(rawBody, contentType);
4272
+ body = parsed.body;
4273
+ files = parsed.files;
4172
4274
  }
4173
- if (source.keyed !== true) return `source "${name}" takes no key`;
4174
- const key = parameter.slice(dot + 1);
4175
- if (key === "") return `source "${name}" needs a key after the dot`;
4176
- return keyProblem(name, source, key);
4275
+ const uri = url.pathname + url.search;
4276
+ const client = resolveClientIp({
4277
+ peer: req.socket?.remoteAddress,
4278
+ headers,
4279
+ trustedProxy: options.trustedProxy
4280
+ });
4281
+ return {
4282
+ method,
4283
+ url: uri,
4284
+ originalUrl: uri,
4285
+ query,
4286
+ body,
4287
+ files,
4288
+ headers,
4289
+ ip: client.ip ?? "",
4290
+ _clientIp: client,
4291
+ cookies: parseCookies2(headers.cookie),
4292
+ // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify drops.
4293
+ _rawBody: rawBody
4294
+ };
4177
4295
  }
4178
- function keyProblem(name, source, key) {
4179
- if (key.endsWith("*") && source.wildcard !== true) {
4180
- return `"${name}" does not fan out over a wildcard key`;
4296
+ function parseCookies2(header) {
4297
+ const cookies = {};
4298
+ if (!header) {
4299
+ return cookies;
4181
4300
  }
4182
- const bare = key.endsWith("*") ? key.slice(0, -1) : key;
4183
- if (source.keys === "any") return null;
4184
- if (Array.isArray(source.keys) && source.keys.includes(bare)) return null;
4185
- for (const prefix of source.key_prefixes ?? []) {
4186
- if (bare.startsWith(prefix) && bare.length > prefix.length) return null;
4301
+ for (const pair of header.split(";")) {
4302
+ const idx = pair.indexOf("=");
4303
+ if (idx === -1) {
4304
+ continue;
4305
+ }
4306
+ cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
4187
4307
  }
4188
- const accepted = [
4189
- ...Array.isArray(source.keys) ? source.keys : [],
4190
- ...(source.key_prefixes ?? []).map((p) => `${p}\u2026`)
4191
- ].join(", ");
4192
- return `"${name}" has no key "${key}" (expected one of: ${accepted})`;
4308
+ return cookies;
4193
4309
  }
4194
- function matchProblem(match, parameter) {
4195
- if (!match || typeof match !== "object") return "condition has no match object";
4196
- if (typeof match.type !== "string" || match.type === "") return "match.type must be a non-empty string";
4197
- if (!MATCH_TYPES.includes(match.type)) return `unknown match type "${match.type}"`;
4198
- const parameterless = PARAMETERLESS_MATCH_TYPES.includes(match.type);
4199
- if (!parameterless && (parameter === void 0 || parameter === null)) {
4200
- return `match type "${match.type}" needs a parameter`;
4310
+
4311
+ // src/protect/egress.js
4312
+ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = true, lookup, allowHosts } = {}) {
4313
+ const restores = [];
4314
+ if (typeof shouldBlock !== "function") return () => {
4315
+ };
4316
+ const exempt = new Set((allowHosts ?? []).map((h) => String(h).toLowerCase()));
4317
+ const block = (url, host, method) => {
4318
+ if (!shouldBlock(url, host, method)) return false;
4319
+ notify(onBlock, { url, host, method }, "onEgressBlock");
4320
+ return true;
4321
+ };
4322
+ let screen = null;
4323
+ if (dnsScreen) {
4324
+ try {
4325
+ const resolveLookup = lookup ?? (await import("dns")).lookup;
4326
+ const { isIP } = await import("net");
4327
+ if (typeof resolveLookup === "function" && typeof isIP === "function") {
4328
+ screen = { lookup: resolveLookup, isIP, isExempt: (h) => exempt.has(String(h).toLowerCase()) };
4329
+ }
4330
+ } catch {
4331
+ screen = null;
4332
+ }
4333
+ }
4334
+ const skip = (reason, detail) => notify(onSkip, { phase: "egress", reason, detail }, "onSkip");
4335
+ const resolvesToDisallowed = (url, host, method) => new Promise((resolve) => {
4336
+ if (!screen) {
4337
+ if (host && dnsScreen) skip("resolver-unavailable", { host });
4338
+ return resolve(false);
4339
+ }
4340
+ if (!host || screen.isIP(host) !== 0 || screen.isExempt(host)) return resolve(false);
4341
+ try {
4342
+ screen.lookup(host, { all: true }, (err, addresses) => {
4343
+ if (err || !Array.isArray(addresses)) {
4344
+ skip("resolver-failed", { host });
4345
+ return resolve(false);
4346
+ }
4347
+ for (const a of addresses) {
4348
+ const ip = a && typeof a === "object" ? a.address : a;
4349
+ if (ip && block(url, ip, method)) return resolve(true);
4350
+ }
4351
+ resolve(false);
4352
+ });
4353
+ } catch {
4354
+ skip("resolver-failed", { host });
4355
+ resolve(false);
4356
+ }
4357
+ });
4358
+ const originalFetch = globalThis.fetch;
4359
+ if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
4360
+ const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
4361
+ const MAX_REDIRECTS = 20;
4362
+ const screenUrl = async (u, method) => {
4363
+ let host = null;
4364
+ try {
4365
+ host = new URL(u).hostname;
4366
+ } catch {
4367
+ host = null;
4368
+ }
4369
+ if (block(u, host, method) || await resolvesToDisallowed(u, host, method)) {
4370
+ throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? u}`);
4371
+ }
4372
+ };
4373
+ const guarded = async (input, init) => {
4374
+ let cur;
4375
+ try {
4376
+ cur = new Request(input, { ...init || {}, redirect: "manual" });
4377
+ } catch {
4378
+ return originalFetch(input, init);
4379
+ }
4380
+ const callerRedirect = init && init.redirect || input && input.redirect || "follow";
4381
+ let url = cur.url;
4382
+ let method = cur.method;
4383
+ await screenUrl(url, method);
4384
+ if (callerRedirect !== "follow") return originalFetch(input, init);
4385
+ const headers = new Headers(cur.headers);
4386
+ const signal = cur.signal;
4387
+ let body = method === "GET" || method === "HEAD" ? void 0 : await cur.clone().arrayBuffer();
4388
+ for (let hop = 0; ; hop++) {
4389
+ const resp = await originalFetch(
4390
+ hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
4391
+ );
4392
+ const location = REDIRECT_STATUSES.has(resp.status) ? resp.headers.get("location") : null;
4393
+ if (!location) return resp;
4394
+ if (hop >= MAX_REDIRECTS) throw new Error("Patchstack blocked an outbound request: too many redirects");
4395
+ const next = new URL(location, url).href;
4396
+ if (resp.status === 303 || (resp.status === 301 || resp.status === 302) && method === "POST") {
4397
+ method = "GET";
4398
+ body = void 0;
4399
+ headers.delete("content-type");
4400
+ headers.delete("content-length");
4401
+ }
4402
+ if (new URL(next).origin !== new URL(url).origin) {
4403
+ headers.delete("authorization");
4404
+ headers.delete("cookie");
4405
+ }
4406
+ await screenUrl(next, method);
4407
+ url = next;
4408
+ }
4409
+ };
4410
+ guarded.__patchstackGuarded = true;
4411
+ globalThis.fetch = guarded;
4412
+ restores.push(() => {
4413
+ if (globalThis.fetch === guarded) globalThis.fetch = originalFetch;
4414
+ });
4201
4415
  }
4202
- if (parameterless && parameter !== void 0 && parameter !== null) {
4203
- return `match type "${match.type}" reads the whole request and takes no parameter`;
4416
+ for (const moduleName of ["node:http", "node:https"]) {
4417
+ try {
4418
+ const mod = await import(moduleName);
4419
+ const restore = patchHttpModule(mod.default ?? mod, block, screen);
4420
+ if (restore) restores.push(restore);
4421
+ } catch {
4422
+ }
4204
4423
  }
4205
- const operands = MATCH_OPERANDS[match.type];
4206
- if (operands) {
4207
- for (const required of operands.required) {
4208
- if (match[required] === void 0 || match[required] === null) {
4209
- return `match type "${match.type}" needs "${required}"`;
4424
+ return () => {
4425
+ for (const restore of restores) {
4426
+ try {
4427
+ restore();
4428
+ } catch {
4210
4429
  }
4211
4430
  }
4212
- for (const [operand, shape] of Object.entries(operands.shapes ?? {})) {
4213
- if (match[operand] === void 0) continue;
4214
- const problem = operandShapeProblem(shape, match[operand]);
4215
- if (problem) return `match type "${match.type}" operand "${operand}" ${problem}`;
4431
+ };
4432
+ }
4433
+ function patchHttpModule(http, block, screen) {
4434
+ if (!http || typeof http.request !== "function" || http.__patchstackGuarded) return null;
4435
+ const originalRequest = http.request;
4436
+ const originalGet = http.get;
4437
+ const wrap = (original) => function(...args) {
4438
+ const target = extractHttpTarget(args);
4439
+ if (target && block(target.url, target.host, target.method)) {
4440
+ throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host ?? target.url}`);
4216
4441
  }
4217
- } else if (!OPERANDLESS_MATCH_TYPES.includes(match.type) && match.value === void 0) {
4218
- return `match type "${match.type}" needs a value`;
4442
+ if (target && screen && target.host && screen.isIP(target.host) === 0 && !screen.isExempt(target.host)) {
4443
+ try {
4444
+ args = withScreeningLookup(args, target, block, screen.lookup);
4445
+ } catch {
4446
+ }
4447
+ }
4448
+ return original.apply(this, args);
4449
+ };
4450
+ const guardedRequest = wrap(originalRequest);
4451
+ http.request = guardedRequest;
4452
+ let guardedGet;
4453
+ if (typeof originalGet === "function") {
4454
+ guardedGet = wrap(originalGet);
4455
+ http.get = guardedGet;
4219
4456
  }
4220
- return null;
4457
+ http.__patchstackGuarded = true;
4458
+ return () => {
4459
+ if (http.request === guardedRequest) http.request = originalRequest;
4460
+ if (guardedGet && http.get === guardedGet) http.get = originalGet;
4461
+ delete http.__patchstackGuarded;
4462
+ };
4221
4463
  }
4222
- function mutationsProblem(mutations) {
4223
- if (mutations === void 0 || mutations === null) return null;
4224
- if (!Array.isArray(mutations)) return "mutations must be an array";
4225
- for (const mutation of mutations) {
4226
- if (typeof mutation !== "string" || !MUTATIONS.includes(mutation)) {
4227
- return `unknown mutation "${String(mutation)}" \u2014 applied as a no-op, so the condition would test the untransformed value`;
4464
+ function extractHttpTarget(args) {
4465
+ const first = args[0];
4466
+ try {
4467
+ if (typeof first === "string" || first instanceof URL) {
4468
+ const url = new URL(String(first));
4469
+ const opts = args.find((a) => a && typeof a === "object" && !(a instanceof URL));
4470
+ return { url: url.href, host: url.hostname, method: opts && opts.method || "GET" };
4471
+ }
4472
+ if (first && typeof first === "object") {
4473
+ const host = normalizeHost(first.hostname || first.host) || "localhost";
4474
+ const protocol = first.protocol || "http:";
4475
+ const port = first.port ? `:${first.port}` : "";
4476
+ const path7 = first.path || "/";
4477
+ return { url: `${protocol}//${host}${port}${path7}`, host, method: first.method || "GET" };
4228
4478
  }
4479
+ } catch {
4229
4480
  }
4230
4481
  return null;
4231
4482
  }
4232
- function whenProblem(when) {
4233
- if (when === void 0 || when === null) return null;
4234
- if (typeof when !== "object" || Array.isArray(when)) return "when must be an object";
4235
- const keys = Object.keys(when);
4236
- if (keys.length === 0) return "when is empty \u2014 remove it, or name a path or method";
4237
- const unknown = keys.filter((key) => !WHEN_KEYS.includes(key));
4238
- if (unknown.length > 0) {
4239
- 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`;
4483
+ function normalizeHost(raw) {
4484
+ const host = String(raw || "").trim();
4485
+ const bracketed = /^\[([^\]]+)\]/.exec(host);
4486
+ if (bracketed) return bracketed[1];
4487
+ if ((host.match(/:/g) || []).length > 1) return host;
4488
+ return host.split(":")[0];
4489
+ }
4490
+ function screenResolved(addresses, target, block) {
4491
+ for (const a of addresses || []) {
4492
+ const ip = a && typeof a === "object" ? a.address : a;
4493
+ if (ip && block(target.url, ip, target.method)) return ip;
4240
4494
  }
4241
- if ("path" in when && !isNonEmptyString(when.path)) {
4242
- return "when.path must be a non-empty string";
4495
+ return null;
4496
+ }
4497
+ function withScreeningLookup(args, target, block, lookup) {
4498
+ const screeningLookup = (hostname, options, callback) => {
4499
+ let opts = options;
4500
+ let cb = callback;
4501
+ if (typeof opts === "function") {
4502
+ cb = opts;
4503
+ opts = {};
4504
+ }
4505
+ if (!opts || typeof opts !== "object") opts = {};
4506
+ try {
4507
+ lookup(hostname, { ...opts, all: true }, (err, addresses) => {
4508
+ if (err) return cb(err);
4509
+ const list = Array.isArray(addresses) ? addresses : [];
4510
+ const blocked = screenResolved(list, target, block);
4511
+ if (blocked) {
4512
+ return cb(new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host} resolved to ${blocked}`));
4513
+ }
4514
+ if (opts.all) return cb(null, list);
4515
+ const first = list[0];
4516
+ if (!first) return cb(new Error(`Patchstack: could not resolve ${hostname}`));
4517
+ return cb(null, first.address, first.family);
4518
+ });
4519
+ } catch {
4520
+ try {
4521
+ lookup(hostname, opts, cb);
4522
+ } catch {
4523
+ cb(new Error(`Patchstack: lookup failed for ${hostname}`));
4524
+ }
4525
+ }
4526
+ };
4527
+ return injectLookupOption(args, screeningLookup);
4528
+ }
4529
+ function injectLookupOption(args, lookup) {
4530
+ const first = args[0];
4531
+ if (first && typeof first === "object" && !(first instanceof URL)) {
4532
+ return [{ ...first, lookup }, ...args.slice(1)];
4533
+ }
4534
+ const rest = args.slice(1);
4535
+ const optIdx = rest.findIndex((a) => a && typeof a === "object" && !(a instanceof URL));
4536
+ if (optIdx !== -1) {
4537
+ const next = [...rest];
4538
+ next[optIdx] = { ...rest[optIdx], lookup };
4539
+ return [first, ...next];
4540
+ }
4541
+ const cbIdx = rest.findIndex((a) => typeof a === "function");
4542
+ if (cbIdx === -1) return [first, { lookup }, ...rest];
4543
+ return [first, ...rest.slice(0, cbIdx), { lookup }, ...rest.slice(cbIdx)];
4544
+ }
4545
+
4546
+ // src/protect/defaults.js
4547
+ var DEFAULT_RESPONSE_RULES = [
4548
+ {
4549
+ id: "resp-private-key",
4550
+ title: "Private key in response body",
4551
+ phase: "response",
4552
+ category: "secret-exposure",
4553
+ action: "redact",
4554
+ prefilter: ["PRIVATE KEY"],
4555
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/" } }]
4556
+ },
4557
+ {
4558
+ id: "resp-aws-access-key",
4559
+ title: "AWS access key id in response body",
4560
+ phase: "response",
4561
+ category: "secret-exposure",
4562
+ action: "redact",
4563
+ prefilter: ["AKIA", "ASIA"],
4564
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b/" } }]
4565
+ },
4566
+ {
4567
+ id: "resp-gcp-api-key",
4568
+ title: "Google API key in response body",
4569
+ phase: "response",
4570
+ category: "secret-exposure",
4571
+ action: "redact",
4572
+ prefilter: ["AIza"],
4573
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\bAIza[0-9A-Za-z_-]{35}\\b/" } }]
4574
+ },
4575
+ {
4576
+ id: "resp-vendor-api-key",
4577
+ title: "Vendor API key / token in response body",
4578
+ phase: "response",
4579
+ category: "secret-exposure",
4580
+ action: "redact",
4581
+ // High-signal, prefix-anchored provider tokens that never legitimately appear in a response
4582
+ // body: Stripe (sk_live_/rk_live_), GitHub (gh[opsu]_ / github_pat_), GitLab (glpat-),
4583
+ // Slack (xox[baprs]-), Anthropic (sk-ant-), Google OAuth (ya29.), npm (npm_). Trailing
4584
+ // (?![0-9A-Za-z]) instead of \\b since some tokens end in - / _ .
4585
+ prefilter: ["sk_live_", "rk_live_", "ghp_", "gho_", "ghs_", "ghu_", "github_pat_", "glpat-", "xox", "sk-ant-", "ya29.", "npm_"],
4586
+ 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])/" } }]
4587
+ },
4588
+ {
4589
+ id: "resp-supabase-secret-key",
4590
+ title: "Supabase secret key in response body",
4591
+ phase: "response",
4592
+ category: "secret-exposure",
4593
+ action: "redact",
4594
+ // Supabase's opaque key format, which is not a JWT and so is not covered by the JWT rule below.
4595
+ // `sb_secret_` is the elevated one — documented as full access, bypassing Row Level Security,
4596
+ // backend-only — while `sb_publishable_` is meant for public clients and must pass untouched.
4597
+ // Both key systems are live at once: new keys sit alongside the legacy `anon` / `service_role`
4598
+ // JWTs rather than replacing them, so this rule and the JWT rule cover different halves.
4599
+ //
4600
+ // The documented grammar is the prefix, 22 base64url characters, `_`, then an 8-character
4601
+ // base64url checksum. Matching it exactly is what keeps the mask on the key and off the response
4602
+ // around it: a variable-length suffix would either run past the key into adjacent syntax or stop
4603
+ // short and leave the tail of a real key visible. The trailing guard rejects a longer run of key
4604
+ // characters, which is not this format.
4605
+ prefilter: ["sb_secret_"],
4606
+ 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_-])/" } }]
4607
+ },
4608
+ {
4609
+ id: "resp-supabase-service-role-key",
4610
+ title: "Supabase service_role key in response body",
4611
+ phase: "response",
4612
+ category: "secret-exposure",
4613
+ action: "redact",
4614
+ // Scoped to the one role that must never leave: `service_role` bypasses Row Level Security
4615
+ // entirely. A JWT in a response body is not inherently a leak — the Supabase `anon` key is public
4616
+ // by design and a login response carries the user's own access token — so this decides on the
4617
+ // decoded payload rather than on the shape of the token.
4618
+ //
4619
+ // `jwt_claim_equals` also yields the matching token spans, which is what lets `redact` mask those
4620
+ // tokens and serve the rest of the response.
4621
+ //
4622
+ // A broad any-JWT rule is a reasonable thing to want; it belongs in the managed hardening bundle as
4623
+ // an opt-in, observe-only rule rather than an on-by-default mask.
4624
+ prefilter: ["eyJ"],
4625
+ rule_v2: [{ parameter: "response.body", match: { type: "jwt_claim_equals", claim: "role", value: "service_role" } }]
4626
+ },
4627
+ {
4628
+ id: "resp-db-connection-string",
4629
+ title: "Database connection string with credentials in response body",
4630
+ phase: "response",
4631
+ category: "secret-exposure",
4632
+ action: "redact",
4633
+ prefilter: ["mongodb", "postgres", "mysql", "redis", "amqp"],
4634
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[^\\s:@\\/]+:[^\\s:@\\/]+@/i" } }]
4635
+ },
4636
+ {
4637
+ id: "resp-stack-trace",
4638
+ title: "Node stack trace leaking in response body",
4639
+ phase: "response",
4640
+ category: "info-exposure",
4641
+ action: "redact",
4642
+ // No prefilter: a Node stack frame has no single distinctive literal (` at ` is too common to
4643
+ // gate on). The pattern is linearly bounded per line, so it runs on every screened body.
4644
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\n\\s+at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
4645
+ },
4646
+ {
4647
+ id: "resp-sql-error",
4648
+ title: "SQL / ORM error disclosure in response body",
4649
+ phase: "response",
4650
+ category: "info-exposure",
4651
+ action: "redact",
4652
+ prefilter: ["SQLSTATE", "Sequelize", "ER_", "ORA-", "PG::", "SQLITE_ERROR", "SQL syntax"],
4653
+ 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" } }]
4654
+ },
4655
+ {
4656
+ id: "resp-exception-trace",
4657
+ title: "Backend exception / stack trace disclosure in response body",
4658
+ phase: "response",
4659
+ category: "info-exposure",
4660
+ action: "redact",
4661
+ // Multi-language exception/traceback signatures a normal API response never carries:
4662
+ // Python traceback, Java "Exception in thread", .NET System.*Exception, JVM stack frames,
4663
+ // Go goroutine dumps. (Node `at fn (file:line:col)` frames are handled by resp-stack-trace.)
4664
+ prefilter: ["Traceback", "Exception in thread", "System.", "goroutine ", ".java:", ".kt:", ".scala:", ".rb:", ".py:", ".cs:"],
4665
+ 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+ \\[)/' } }]
4243
4666
  }
4244
- if ("method" in when) {
4245
- const methods = Array.isArray(when.method) ? when.method : [when.method];
4246
- if (methods.length === 0) return "when.method is an empty list, so the rule would match no method";
4247
- if (!methods.every(isNonEmptyString)) return "when.method must be a non-empty string, or a list of them";
4667
+ ];
4668
+ var DEFAULT_EGRESS_RULES = [
4669
+ {
4670
+ id: "egress-internal-address",
4671
+ title: "Outbound request to an internal / metadata address (SSRF)",
4672
+ phase: "egress",
4673
+ category: "ssrf",
4674
+ rule_v2: [{ parameter: "egress.host", match: { type: "internal_host" } }]
4248
4675
  }
4249
- return null;
4250
- }
4251
- function isNonEmptyString(value) {
4252
- return typeof value === "string" && value.trim() !== "";
4676
+ ];
4677
+
4678
+ // src/protect/block-page.js
4679
+ function escapeHtml(s) {
4680
+ return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
4253
4681
  }
4254
- function nullPropertyProblem(rule) {
4255
- if (!rule || typeof rule !== "object") return null;
4256
- for (const property of RULE_PROPERTIES) {
4257
- if (property in rule && rule[property] === null) {
4258
- 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`;
4682
+ function renderBlockPage({ url = "", code = "patchstack" } = {}) {
4683
+ const safeUrl = escapeHtml(url);
4684
+ const safeCode = escapeHtml(code || "patchstack");
4685
+ return `<!DOCTYPE html>
4686
+ <html lang="en">
4687
+ <head>
4688
+ <meta charset="UTF-8">
4689
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
4690
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
4691
+ <style type="text/css">
4692
+ .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}}
4693
+ </style>
4694
+ <title>Access Denied</title>
4695
+ </head>
4696
+ <body>
4697
+ <div class="main-container">
4698
+ <div class="container">
4699
+ <div class="content">
4700
+ <span class="access-denied">Access Denied</span>
4701
+ <div class="site-url">
4702
+ <input id="url" class="site-url-text" type="text"
4703
+ value="${safeUrl}" readonly>
4704
+ <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')">
4705
+ </div>
4706
+ <div class="site-url">
4707
+ <input id="response" class="reason-code-text" type="text" value="${safeCode}" readonly>
4708
+ <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')">
4709
+ </div>
4710
+ <div class="descp">
4711
+ <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>
4712
+ </div>
4713
+ </div>
4714
+ </div>
4715
+ <div class="bottom-container">
4716
+ <div class="bottom-text-containers">
4717
+ <img class="arrow-vector" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOCIgaGVpZ2h0PSIxMyIgdmlld0JveD0iMCAwIDggMTMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik03LjQxIDEuNDMzNDRMNiAwLjAyMzQzNzVMMCA2LjAyMzQ0TDYgMTIuMDIzNEw3LjQxIDEwLjYxMzRMMi44MyA2LjAyMzQ0TDcuNDEgMS40MzM0NFoiIGZpbGw9IiNBRkU2MTQiLz4KPC9zdmc+Cg==" alt="Arrow Left">
4718
+ <a href="/" class="return-text">Return to homepage</a>
4719
+ </div>
4720
+ <div class="bottom-second-containers">
4721
+ <span class="secured-text">This website is secured by</span>
4722
+ <a href="https://patchstack.com" class="logoText">
4723
+ <img class="patch-logo" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTg0IiBoZWlnaHQ9IjIyIiB2aWV3Qm94PSIwIDAgMTg0IDIyIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTUwLjQwMDEgMTEuNTAwOEM1MC40MDAxIDkuMTAwNzggNDkuMzAwMSA4LjEwMDc4IDQ2LjYwMDEgOC4xMDA3OEM0NC4wMDAxIDguMTAwNzggNDIuNzAwMSA5LjIwMDc4IDQyLjcwMDEgMTEuNTAwOEM0Mi43MDAxIDEzLjgwMDggNDQuMDAwMSAxNC45MDA4IDQ2LjYwMDEgMTQuOTAwOEM0OS4zMDAxIDE0LjkwMDggNTAuNDAwMSAxMy44MDA4IDUwLjQwMDEgMTEuNTAwOFpNNTMuNDAwMSAxMS41MDA4QzUzLjQwMDEgMTUuMzAwOCA1MS4zMDAxIDE3LjYwMDggNDcuNDAwMSAxNy42MDA4QzQ1LjQwMDEgMTcuNjAwOCA0My42MDAxIDE2LjUwMDggNDIuNzAwMSAxNC41MDA4VjIxLjUwMDhIMzkuNjAwMVY1LjYwMDc4SDQyLjcwMDFWOC41MDA3OEM0My42MDAxIDYuNTAwNzggNDUuNDAwMSA1LjMwMDc4IDQ3LjUwMDEgNS4zMDA3OEM1MS40MDAxIDUuMzAwNzggNTMuNDAwMSA3LjcwMDc4IDUzLjQwMDEgMTEuNTAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNjYuNDAwMSAxMS40OTg0QzY2LjQwMDEgOS4xOTg0NCA2NS4xMDAxIDguMDk4NDQgNjIuNTAwMSA4LjA5ODQ0QzU5LjgwMDEgOC4wOTg0NCA1OC43MDAxIDkuMDk4NDQgNTguNzAwMSAxMS40OTg0QzU4LjcwMDEgMTMuNzk4NCA1OS44MDAxIDE0Ljg5ODQgNjIuNTAwMSAxNC44OTg0QzY1LjEwMDEgMTQuODk4NCA2Ni40MDAxIDEzLjc5ODQgNjYuNDAwMSAxMS40OTg0Wk02Ni4zMDAxIDUuNTk4NDRINjkuNDAwMVYxNy4zOTg0SDY2LjMwMDFWMTQuNDk4NEM2NS40MDAxIDE2LjQ5ODQgNjMuNzAwMSAxNy42OTg0IDYxLjYwMDEgMTcuNjk4NEM1Ny43MDAxIDE3LjY5ODQgNTUuNjAwMSAxNS4yOTg0IDU1LjYwMDEgMTEuNTk4NEM1NS42MDAxIDcuNzk4NDQgNTcuNjAwMSA1LjM5ODQ0IDYxLjQwMDEgNS4zOTg0NEM2My41MDAxIDUuMzk4NDQgNjUuNDAwMSA2LjU5ODQ0IDY2LjIwMDEgOC41OTg0NFY1LjU5ODQ0SDY2LjMwMDFaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTgxLjM5OTkgOC4zSDc3LjM5OTlWMTQuN0g4MS4wOTk5VjE3LjRINzcuOTk5OUM3NS43OTk5IDE3LjQgNzQuMjk5OSAxNS45IDc0LjI5OTkgMTMuN1Y4LjNINzEuMzk5OVY1LjZINzQuMjk5OVYySDc3LjM5OTlWNS41SDgxLjM5OTlWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik05My42OTk5IDEyLjIwMDhMOTYuNjk5OSAxMi45MDA4Qzk1Ljk5OTkgMTYuMDAwOCA5My42OTk5IDE3LjYwMDggODkuOTk5OSAxNy42MDA4Qzg3Ljc5OTkgMTcuNjAwOCA4Ni4wOTk5IDE3LjAwMDggODQuNzk5OSAxNS45MDA4QzgzLjQ5OTkgMTQuODAwOCA4Mi44OTk5IDEzLjMwMDggODIuODk5OSAxMS41MDA4QzgyLjg5OTkgOS42MDA3OCA4My40OTk5IDguMTAwNzggODQuNzk5OSA3LjAwMDc4Qzg2LjA5OTkgNS45MDA3OCA4Ny43OTk5IDUuMzAwNzggODkuOTk5OSA1LjMwMDc4QzkzLjY5OTkgNS4zMDA3OCA5NS45OTk5IDYuOTAwNzggOTYuNjk5OSAxMC4wMDA4TDkzLjY5OTkgMTAuNzAwOEM5My4yOTk5IDguODAwNzggOTIuMjk5OSA4LjEwMDc4IDg5Ljg5OTkgOC4xMDA3OEM4Ni45OTk5IDguMTAwNzggODUuODk5OSA5LjIwMDc4IDg1Ljg5OTkgMTEuNTAwOEM4NS44OTk5IDEzLjgwMDggODYuOTk5OSAxNC45MDA4IDg5Ljg5OTkgMTQuOTAwOEM5Mi4yOTk5IDE0LjkwMDggOTMuMjk5OSAxNC4yMDA4IDkzLjY5OTkgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTExLjggMTAuNlYxNy40SDEwOC43VjExLjJDMTA4LjcgOSAxMDcuOCA4LjEgMTA1LjkgOC4xQzEwMy42IDguMSAxMDIuNCA5LjMgMTAyLjQgMTEuN1YxNy4zSDk5LjI5OThWMC41SDEwMi40VjguM0MxMDMuMSA2LjMgMTA0LjYgNS4zIDEwNyA1LjNDMTEwLjIgNS4zIDExMS44IDcuMSAxMTEuOCAxMC42WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMTQuMSAxNC4wOTg0TDExNy4xIDEzLjI5ODRDMTE3LjQgMTQuNzk4NCAxMTguMyAxNS4yOTg0IDEyMC4yIDE1LjI5ODRDMTIxLjggMTUuMjk4NCAxMjIuNyAxNC44OTg0IDEyMi43IDE0LjA5ODRDMTIyLjcgMTMuMjk4NCAxMjIuMiAxMi45OTg0IDEyMC42IDEyLjc5ODRMMTE4LjIgMTIuNDk4NEMxMTUuOSAxMi4xOTg0IDExNC41IDEwLjg5ODQgMTE0LjUgOC45OTg0NEMxMTQuNSA2Ljc5ODQ0IDExNi41IDUuMzk4NDQgMTE5LjggNS4zOTg0NEMxMjMuMSA1LjM5ODQ0IDEyNSA2LjY5ODQ0IDEyNS41IDguODk4NDRMMTIyLjUgOS41OTg0NEMxMjIuMyA4LjA5ODQ0IDEyMS41IDcuNjk4NDQgMTE5LjkgNy42OTg0NEMxMTguNCA3LjY5ODQ0IDExNy42IDguMDk4NDQgMTE3LjYgOC44OTg0NEMxMTcuNiA5LjU5ODQ0IDExOC4xIDkuOTk4NDQgMTE5LjMgMTAuMDk4NEwxMjIuMiAxMC40OTg0QzEyNC40IDEwLjc5ODQgMTI1LjggMTEuOTk4NCAxMjUuOCAxMy44OTg0QzEyNS44IDE2LjI5ODQgMTIzLjcgMTcuNzk4NCAxMjAuMSAxNy43OTg0QzExNi43IDE3LjU5ODQgMTE0LjUgMTYuMTk4NCAxMTQuMSAxNC4wOTg0WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMzYuNyA4LjNIMTMyLjdWMTQuN0gxMzYuNFYxNy40SDEzMy4zQzEzMS4xIDE3LjQgMTI5LjYgMTUuOSAxMjkuNiAxMy43VjguM0gxMjYuN1Y1LjZIMTI5LjZWMkgxMzIuN1Y1LjVIMTM2LjdWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xNDkuMiAxMS40OTg0QzE0OS4yIDkuMTk4NDQgMTQ3LjkgOC4wOTg0NCAxNDUuMyA4LjA5ODQ0QzE0Mi42IDguMDk4NDQgMTQxLjUgOS4wOTg0NCAxNDEuNSAxMS40OTg0QzE0MS41IDEzLjc5ODQgMTQyLjYgMTQuODk4NCAxNDUuMyAxNC44OTg0QzE0Ny45IDE0Ljg5ODQgMTQ5LjIgMTMuNzk4NCAxNDkuMiAxMS40OTg0Wk0xNDkuMSA1LjU5ODQ0SDE1Mi4yVjE3LjM5ODRIMTQ5LjFWMTQuNDk4NEMxNDguMiAxNi40OTg0IDE0Ni41IDE3LjY5ODQgMTQ0LjQgMTcuNjk4NEMxNDAuNSAxNy42OTg0IDEzOC40IDE1LjI5ODQgMTM4LjQgMTEuNTk4NEMxMzguNCA3Ljc5ODQ0IDE0MC40IDUuMzk4NDQgMTQ0LjIgNS4zOTg0NEMxNDYuMyA1LjM5ODQ0IDE0OC4yIDYuNTk4NDQgMTQ5IDguNTk4NDRWNS41OTg0NEgxNDkuMVoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTY1LjYgMTIuMjAwOEwxNjguNiAxMi45MDA4QzE2Ny45IDE2LjAwMDggMTY1LjYgMTcuNjAwOCAxNjEuOSAxNy42MDA4QzE1OS43IDE3LjYwMDggMTU4IDE3LjAwMDggMTU2LjcgMTUuOTAwOEMxNTUuNCAxNC44MDA4IDE1NC44IDEzLjMwMDggMTU0LjggMTEuNTAwOEMxNTQuOCA5LjYwMDc4IDE1NS40IDguMTAwNzggMTU2LjcgNy4wMDA3OEMxNTggNS45MDA3OCAxNTkuNyA1LjMwMDc4IDE2MS45IDUuMzAwNzhDMTY1LjYgNS4zMDA3OCAxNjcuOSA2LjkwMDc4IDE2OC42IDEwLjAwMDhMMTY1LjYgMTAuNzAwOEMxNjUuMiA4LjgwMDc4IDE2NC4yIDguMTAwNzggMTYxLjggOC4xMDA3OEMxNTguOSA4LjEwMDc4IDE1Ny44IDkuMjAwNzggMTU3LjggMTEuNTAwOEMxNTcuOCAxMy44MDA4IDE1OC45IDE0LjkwMDggMTYxLjggMTQuOTAwOEMxNjQuMSAxNC45MDA4IDE2NS4xIDE0LjIwMDggMTY1LjYgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTgwLjI1NyAxNy40TDE3Ni42NTcgMTIuM0wxNzQuMTU3IDE0LjZWMTcuNEgxNzEuMTU3VjAuNUgxNzQuMTU3VjExLjFMMTc5Ljg1NyA1LjZIMTgzLjc1N0wxNzguNTU3IDEwLjVMMTgzLjk1NyAxNy40SDE4MC4yNTdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTAgMjEuNTAwOEg3LjNWMTYuMzAwOEgwVjIxLjUwMDhaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNy4yOTk4IDIxLjUwMDhIMTQuNTk5OFYxNi4zMDA4SDcuMjk5OFYyMS41MDA4WiIgZmlsbD0iIzZFQkUwMCIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTE0LjYwMDEgMTYuM0gyOS4yMDAxVjAuNUgxNC42MDAxVjE2LjNaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMCAxMUgxNC42VjAuNUgwVjExWiIgZmlsbD0iIzZFQkUwMCIvPgo8L3N2Zz4K" alt="Patchstack Logo">
4724
+ </a>
4725
+ </div>
4726
+ </div>
4727
+
4728
+ <div class="mobile-bottom-container">
4729
+ <div class="mobile-bottom-text-containers">
4730
+ <img class="arrow-vector" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOCIgaGVpZ2h0PSIxMyIgdmlld0JveD0iMCAwIDggMTMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik03LjQxIDEuNDMzNDRMNiAwLjAyMzQzNzVMMCA2LjAyMzQ0TDYgMTIuMDIzNEw3LjQxIDEwLjYxMzRMMi44MyA2LjAyMzQ0TDcuNDEgMS40MzM0NFoiIGZpbGw9IiNBRkU2MTQiLz4KPC9zdmc+Cg==" alt="Arrow Left">
4731
+ <a href="/" class="return-text">Return to homepage</a>
4732
+ </div>
4733
+ <div class="mobile-bottom-second-containers">
4734
+ <span class="secured-text">This website is secured by</span>
4735
+ <a href="https://patchstack.com" class="logoText">
4736
+ <img class="patch-logo" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTg0IiBoZWlnaHQ9IjIyIiB2aWV3Qm94PSIwIDAgMTg0IDIyIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTUwLjQwMDEgMTEuNTAwOEM1MC40MDAxIDkuMTAwNzggNDkuMzAwMSA4LjEwMDc4IDQ2LjYwMDEgOC4xMDA3OEM0NC4wMDAxIDguMTAwNzggNDIuNzAwMSA5LjIwMDc4IDQyLjcwMDEgMTEuNTAwOEM0Mi43MDAxIDEzLjgwMDggNDQuMDAwMSAxNC45MDA4IDQ2LjYwMDEgMTQuOTAwOEM0OS4zMDAxIDE0LjkwMDggNTAuNDAwMSAxMy44MDA4IDUwLjQwMDEgMTEuNTAwOFpNNTMuNDAwMSAxMS41MDA4QzUzLjQwMDEgMTUuMzAwOCA1MS4zMDAxIDE3LjYwMDggNDcuNDAwMSAxNy42MDA4QzQ1LjQwMDEgMTcuNjAwOCA0My42MDAxIDE2LjUwMDggNDIuNzAwMSAxNC41MDA4VjIxLjUwMDhIMzkuNjAwMVY1LjYwMDc4SDQyLjcwMDFWOC41MDA3OEM0My42MDAxIDYuNTAwNzggNDUuNDAwMSA1LjMwMDc4IDQ3LjUwMDEgNS4zMDA3OEM1MS40MDAxIDUuMzAwNzggNTMuNDAwMSA3LjcwMDc4IDUzLjQwMDEgMTEuNTAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNjYuNDAwMSAxMS40OTg0QzY2LjQwMDEgOS4xOTg0NCA2NS4xMDAxIDguMDk4NDQgNjIuNTAwMSA4LjA5ODQ0QzU5LjgwMDEgOC4wOTg0NCA1OC43MDAxIDkuMDk4NDQgNTguNzAwMSAxMS40OTg0QzU4LjcwMDEgMTMuNzk4NCA1OS44MDAxIDE0Ljg5ODQgNjIuNTAwMSAxNC44OTg0QzY1LjEwMDEgMTQuODk4NCA2Ni40MDAxIDEzLjc5ODQgNjYuNDAwMSAxMS40OTg0Wk02Ni4zMDAxIDUuNTk4NDRINjkuNDAwMVYxNy4zOTg0SDY2LjMwMDFWMTQuNDk4NEM2NS40MDAxIDE2LjQ5ODQgNjMuNzAwMSAxNy42OTg0IDYxLjYwMDEgMTcuNjk4NEM1Ny43MDAxIDE3LjY5ODQgNTUuNjAwMSAxNS4yOTg0IDU1LjYwMDEgMTEuNTk4NEM1NS42MDAxIDcuNzk4NDQgNTcuNjAwMSA1LjM5ODQ0IDYxLjQwMDEgNS4zOTg0NEM2My41MDAxIDUuMzk4NDQgNjUuNDAwMSA2LjU5ODQ0IDY2LjIwMDEgOC41OTg0NFY1LjU5ODQ0SDY2LjMwMDFaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTgxLjM5OTkgOC4zSDc3LjM5OTlWMTQuN0g4MS4wOTk5VjE3LjRINzcuOTk5OUM3NS43OTk5IDE3LjQgNzQuMjk5OSAxNS45IDc0LjI5OTkgMTMuN1Y4LjNINzEuMzk5OVY1LjZINzQuMjk5OVYySDc3LjM5OTlWNS41SDgxLjM5OTlWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik05My42OTk5IDEyLjIwMDhMOTYuNjk5OSAxMi45MDA4Qzk1Ljk5OTkgMTYuMDAwOCA5My42OTk5IDE3LjYwMDggODkuOTk5OSAxNy42MDA4Qzg3Ljc5OTkgMTcuNjAwOCA4Ni4wOTk5IDE3LjAwMDggODQuNzk5OSAxNS45MDA4QzgzLjQ5OTkgMTQuODAwOCA4Mi44OTk5IDEzLjMwMDggODIuODk5OSAxMS41MDA4QzgyLjg5OTkgOS42MDA3OCA4My40OTk5IDguMTAwNzggODQuNzk5OSA3LjAwMDc4Qzg2LjA5OTkgNS45MDA3OCA4Ny43OTk5IDUuMzAwNzggODkuOTk5OSA1LjMwMDc4QzkzLjY5OTkgNS4zMDA3OCA5NS45OTk5IDYuOTAwNzggOTYuNjk5OSAxMC4wMDA4TDkzLjY5OTkgMTAuNzAwOEM5My4yOTk5IDguODAwNzggOTIuMjk5OSA4LjEwMDc4IDg5Ljg5OTkgOC4xMDA3OEM4Ni45OTk5IDguMTAwNzggODUuODk5OSA5LjIwMDc4IDg1Ljg5OTkgMTEuNTAwOEM4NS44OTk5IDEzLjgwMDggODYuOTk5OSAxNC45MDA4IDg5Ljg5OTkgMTQuOTAwOEM5Mi4yOTk5IDE0LjkwMDggOTMuMjk5OSAxNC4yMDA4IDkzLjY5OTkgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTExLjggMTAuNlYxNy40SDEwOC43VjExLjJDMTA4LjcgOSAxMDcuOCA4LjEgMTA1LjkgOC4xQzEwMy42IDguMSAxMDIuNCA5LjMgMTAyLjQgMTEuN1YxNy4zSDk5LjI5OThWMC41SDEwMi40VjguM0MxMDMuMSA2LjMgMTA0LjYgNS4zIDEwNyA1LjNDMTEwLjIgNS4zIDExMS44IDcuMSAxMTEuOCAxMC42WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMTQuMSAxNC4wOTg0TDExNy4xIDEzLjI5ODRDMTE3LjQgMTQuNzk4NCAxMTguMyAxNS4yOTg0IDEyMC4yIDE1LjI5ODRDMTIxLjggMTUuMjk4NCAxMjIuNyAxNC44OTg0IDEyMi43IDE0LjA5ODRDMTIyLjcgMTMuMjk4NCAxMjIuMiAxMi45OTg0IDEyMC42IDEyLjc5ODRMMTE4LjIgMTIuNDk4NEMxMTUuOSAxMi4xOTg0IDExNC41IDEwLjg5ODQgMTE0LjUgOC45OTg0NEMxMTQuNSA2Ljc5ODQ0IDExNi41IDUuMzk4NDQgMTE5LjggNS4zOTg0NEMxMjMuMSA1LjM5ODQ0IDEyNSA2LjY5ODQ0IDEyNS41IDguODk4NDRMMTIyLjUgOS41OTg0NEMxMjIuMyA4LjA5ODQ0IDEyMS41IDcuNjk4NDQgMTE5LjkgNy42OTg0NEMxMTguNCA3LjY5ODQ0IDExNy42IDguMDk4NDQgMTE3LjYgOC44OTg0NEMxMTcuNiA5LjU5ODQ0IDExOC4xIDkuOTk4NDQgMTE5LjMgMTAuMDk4NEwxMjIuMiAxMC40OTg0QzEyNC40IDEwLjc5ODQgMTI1LjggMTEuOTk4NCAxMjUuOCAxMy44OTg0QzEyNS44IDE2LjI5ODQgMTIzLjcgMTcuNzk4NCAxMjAuMSAxNy43OTg0QzExNi43IDE3LjU5ODQgMTE0LjUgMTYuMTk4NCAxMTQuMSAxNC4wOTg0WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMzYuNyA4LjNIMTMyLjdWMTQuN0gxMzYuNFYxNy40SDEzMy4zQzEzMS4xIDE3LjQgMTI5LjYgMTUuOSAxMjkuNiAxMy43VjguM0gxMjYuN1Y1LjZIMTI5LjZWMkgxMzIuN1Y1LjVIMTM2LjdWOC4zWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xNDkuMiAxMS40OTg0QzE0OS4yIDkuMTk4NDQgMTQ3LjkgOC4wOTg0NCAxNDUuMyA4LjA5ODQ0QzE0Mi42IDguMDk4NDQgMTQxLjUgOS4wOTg0NCAxNDEuNSAxMS40OTg0QzE0MS41IDEzLjc5ODQgMTQyLjYgMTQuODk4NCAxNDUuMyAxNC44OTg0QzE0Ny45IDE0Ljg5ODQgMTQ5LjIgMTMuNzk4NCAxNDkuMiAxMS40OTg0Wk0xNDkuMSA1LjU5ODQ0SDE1Mi4yVjE3LjM5ODRIMTQ5LjFWMTQuNDk4NEMxNDguMiAxNi40OTg0IDE0Ni41IDE3LjY5ODQgMTQ0LjQgMTcuNjk4NEMxNDAuNSAxNy42OTg0IDEzOC40IDE1LjI5ODQgMTM4LjQgMTEuNTk4NEMxMzguNCA3Ljc5ODQ0IDE0MC40IDUuMzk4NDQgMTQ0LjIgNS4zOTg0NEMxNDYuMyA1LjM5ODQ0IDE0OC4yIDYuNTk4NDQgMTQ5IDguNTk4NDRWNS41OTg0NEgxNDkuMVoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTY1LjYgMTIuMjAwOEwxNjguNiAxMi45MDA4QzE2Ny45IDE2LjAwMDggMTY1LjYgMTcuNjAwOCAxNjEuOSAxNy42MDA4QzE1OS43IDE3LjYwMDggMTU4IDE3LjAwMDggMTU2LjcgMTUuOTAwOEMxNTUuNCAxNC44MDA4IDE1NC44IDEzLjMwMDggMTU0LjggMTEuNTAwOEMxNTQuOCA5LjYwMDc4IDE1NS40IDguMTAwNzggMTU2LjcgNy4wMDA3OEMxNTggNS45MDA3OCAxNTkuNyA1LjMwMDc4IDE2MS45IDUuMzAwNzhDMTY1LjYgNS4zMDA3OCAxNjcuOSA2LjkwMDc4IDE2OC42IDEwLjAwMDhMMTY1LjYgMTAuNzAwOEMxNjUuMiA4LjgwMDc4IDE2NC4yIDguMTAwNzggMTYxLjggOC4xMDA3OEMxNTguOSA4LjEwMDc4IDE1Ny44IDkuMjAwNzggMTU3LjggMTEuNTAwOEMxNTcuOCAxMy44MDA4IDE1OC45IDE0LjkwMDggMTYxLjggMTQuOTAwOEMxNjQuMSAxNC45MDA4IDE2NS4xIDE0LjIwMDggMTY1LjYgMTIuMjAwOFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTgwLjI1NyAxNy40TDE3Ni42NTcgMTIuM0wxNzQuMTU3IDE0LjZWMTcuNEgxNzEuMTU3VjAuNUgxNzQuMTU3VjExLjFMMTc5Ljg1NyA1LjZIMTgzLjc1N0wxNzguNTU3IDEwLjVMMTgzLjk1NyAxNy40SDE4MC4yNTdaIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTAgMjEuNTAwOEg3LjNWMTYuMzAwOEgwVjIxLjUwMDhaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNy4yOTk4IDIxLjUwMDhIMTQuNTk5OFYxNi4zMDA4SDcuMjk5OFYyMS41MDA4WiIgZmlsbD0iIzZFQkUwMCIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTE0LjYwMDEgMTYuM0gyOS4yMDAxVjAuNUgxNC42MDAxVjE2LjNaIiBmaWxsPSIjQUZFNjE0Ii8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMCAxMUgxNC42VjAuNUgwVjExWiIgZmlsbD0iIzZFQkUwMCIvPgo8L3N2Zz4K" alt="Patchstack Logo">
4737
+ </a>
4738
+ </div>
4739
+ </div>
4740
+ </div>
4741
+ </body>
4742
+ <script>
4743
+ function copyText(id) {
4744
+ var copyText = document.getElementById(id)
4745
+ copyText.select();
4746
+ copyText.setSelectionRange(0, 99999);
4747
+ navigator.clipboard.writeText(copyText.value);
4259
4748
  }
4260
- }
4261
- return null;
4262
- }
4263
- function rulePropertyProblem(rule) {
4264
- if (!rule || typeof rule !== "object") return null;
4265
- for (const [property, shape] of Object.entries(RULE_PROPERTY_SHAPES)) {
4266
- if (rule[property] === void 0 || rule[property] === null) continue;
4267
- const problem = operandShapeProblem(shape, rule[property]);
4268
- if (problem) return `"${property}" ${problem}`;
4269
- }
4270
- return null;
4271
- }
4272
- function actionProblem(action, rule) {
4273
- if (action === void 0 || action === null) return null;
4274
- if (!ACTIONS.includes(action)) return `unknown action "${action}"`;
4275
- const spec = ACTION_PROPERTIES[action];
4276
- for (const required of spec?.required ?? []) {
4277
- if (rule?.[required] === void 0 || rule?.[required] === null) {
4278
- return `action "${action}" needs "${required}"`;
4749
+
4750
+ function hover(element) {
4751
+ element.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkiIGhlaWdodD0iMjMiIHZpZXdCb3g9IjAgMCAxOSAyMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0IDAuMDIzNDM3NUgwVjE2LjAyMzRIMlYyLjAyMzQ0SDE0VjAuMDIzNDM3NVpNMTkgNC4wMjM0NEg0VjIyLjAyMzRIMTlWNC4wMjM0NFpNMTcgMjAuMDIzNEg2VjYuMDIzNDRIMTdWMjAuMDIzNFoiIGZpbGw9IiNBRkU2MTQiLz4KPC9zdmc+Cg==');
4279
4752
  }
4280
- }
4281
- for (const [property, shape] of Object.entries(spec?.shapes ?? {})) {
4282
- if (rule?.[property] === void 0 || rule?.[property] === null) continue;
4283
- const problem = operandShapeProblem(shape, rule[property]);
4284
- if (problem) return `action "${action}" property "${property}" ${problem}`;
4285
- }
4286
- const phase = rule?.phase ?? "request";
4287
- if (spec !== void 0 && !spec.phases.includes(phase)) {
4288
- return `action "${action}" is carried out on the ${spec.phases.join("/")} phase; on "${phase}" the rule blocks instead`;
4289
- }
4290
- return null;
4291
- }
4292
4753
 
4293
- // src/protect/rules/validate.js
4294
- var LIMITS2 = LIMITS;
4295
- var PHASES2 = new Set(PHASES);
4296
- var ACTIONS2 = new Set(ACTIONS);
4297
- function validateBundle(bundle, opts = {}) {
4298
- const rejected = [];
4299
- const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
4300
- const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
4301
- const firewall = [];
4302
- for (const rule of inFirewall) {
4303
- if (firewall.length >= LIMITS2.maxRules) {
4304
- rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS2.maxRules})` });
4305
- continue;
4754
+ function unhover(element) {
4755
+ element.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkiIGhlaWdodD0iMjMiIHZpZXdCb3g9IjAgMCAxOSAyMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0IDAuMDIzNDM3NUgwVjE2LjAyMzRIMlYyLjAyMzQ0SDE0VjAuMDIzNDM3NVpNMTkgNC4wMjM0NEg0VjIyLjAyMzRIMTlWNC4wMjM0NFpNMTcgMjAuMDIzNEg2VjYuMDIzNDRIMTdWMjAuMDIzNFoiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=');
4306
4756
  }
4307
- const reason = ruleProblem(rule);
4308
- if (reason) rejected.push({ id: idOf(rule), reason });
4309
- else firewall.push(rule);
4757
+ </script>
4758
+ </html>`;
4759
+ }
4760
+
4761
+ // src/protect/rules/store.js
4762
+ var fsMod;
4763
+ async function loadFs() {
4764
+ if (fsMod !== void 0) return fsMod;
4765
+ try {
4766
+ const [fs, path7] = await Promise.all([import("fs"), import("path")]);
4767
+ fsMod = {
4768
+ readFileSync: fs.readFileSync,
4769
+ writeFileSync: fs.writeFileSync,
4770
+ mkdirSync: fs.mkdirSync,
4771
+ join: path7.join
4772
+ };
4773
+ } catch {
4774
+ fsMod = null;
4310
4775
  }
4311
- const whitelists = [];
4312
- for (const wl of inWhitelists) {
4313
- if (whitelists.length >= LIMITS2.maxWhitelists) {
4314
- rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS2.maxWhitelists})` });
4315
- continue;
4316
- }
4317
- if (!opts.allowGlobalWhitelists && wl && Array.isArray(wl.rule_v2) && !wl.rule_id) {
4318
- rejected.push({ id: idOf(wl), reason: "whitelist has no rule_id (would suppress every rule); set allowGlobalWhitelists to permit" });
4319
- continue;
4776
+ return fsMod;
4777
+ }
4778
+ function makeStore(options = {}) {
4779
+ let mem = null;
4780
+ const durable = durableTier(options);
4781
+ return {
4782
+ async read() {
4783
+ if (mem) return mem;
4784
+ const env = await durable.read();
4785
+ if (env) mem = env;
4786
+ return env;
4787
+ },
4788
+ async write(env) {
4789
+ if (env) mem = env;
4790
+ await durable.write(env);
4320
4791
  }
4321
- const reason = conditionsProblem(wl?.rule_v2);
4322
- if (reason) rejected.push({ id: idOf(wl), reason: `whitelist: ${reason}` });
4323
- else whitelists.push(wl);
4792
+ };
4793
+ }
4794
+ function durableTier(options) {
4795
+ const adapter = options.ruleCache;
4796
+ if (adapter && typeof adapter.read === "function" && typeof adapter.write === "function") {
4797
+ return {
4798
+ read: async () => {
4799
+ try {
4800
+ return toEnvelope(await adapter.read());
4801
+ } catch {
4802
+ return null;
4803
+ }
4804
+ },
4805
+ write: async (env) => {
4806
+ try {
4807
+ await adapter.write(env);
4808
+ } catch {
4809
+ }
4810
+ }
4811
+ };
4324
4812
  }
4813
+ const dir = options.cacheDir;
4325
4814
  return {
4326
- bundle: { ...bundle, firewall, whitelists },
4327
- rejected
4815
+ read: async () => cacheRead(dir),
4816
+ write: async (env) => cacheWrite(dir, env)
4328
4817
  };
4329
4818
  }
4330
- function idOf(rule) {
4331
- const id = rule?.id ?? rule?.rule_id;
4332
- return id === void 0 || id === null ? "(unidentified)" : String(id);
4333
- }
4334
- function ruleProblem(rule) {
4335
- if (!rule || typeof rule !== "object") return "not an object";
4336
- const nullReason = nullPropertyProblem(rule);
4337
- if (nullReason) return nullReason;
4338
- if (rule.phase !== void 0 && !PHASES2.has(rule.phase)) return `unknown phase "${rule.phase}"`;
4339
- const actionReason = actionProblem(rule.action, rule);
4340
- if (actionReason) return actionReason;
4341
- const scopeReason = whenProblem(rule.when);
4342
- if (scopeReason) return scopeReason;
4343
- const propertyReason = rulePropertyProblem(rule);
4344
- if (propertyReason) return propertyReason;
4345
- return conditionsProblem(rule.rule_v2);
4819
+ async function cacheWrite(dir, env) {
4820
+ if (!dir) return;
4821
+ const fs = await loadFs();
4822
+ if (!fs) return;
4823
+ try {
4824
+ fs.mkdirSync(dir, { recursive: true });
4825
+ fs.writeFileSync(fs.join(dir, "patchstack-rules.json"), JSON.stringify(env));
4826
+ } catch {
4827
+ }
4346
4828
  }
4347
- function conditionsProblem(conditions, depth = 0) {
4348
- if (!Array.isArray(conditions)) return "rule_v2 must be an array of conditions";
4349
- if (conditions.length === 0) return "rule_v2 is empty (would never match)";
4350
- if (depth > LIMITS2.maxNestingDepth) return `nesting deeper than ${LIMITS2.maxNestingDepth}`;
4351
- if (conditions.length > LIMITS2.maxConditionsPerRule) {
4352
- return `more than ${LIMITS2.maxConditionsPerRule} conditions`;
4829
+ async function cacheRead(dir) {
4830
+ if (!dir) return null;
4831
+ const fs = await loadFs();
4832
+ if (!fs) return null;
4833
+ try {
4834
+ return toEnvelope(JSON.parse(fs.readFileSync(fs.join(dir, "patchstack-rules.json"), "utf8")));
4835
+ } catch {
4836
+ return null;
4353
4837
  }
4354
- for (const c of conditions) {
4355
- const shapeReason = conditionShapeProblem(c);
4356
- if (shapeReason) return shapeReason;
4357
- if (isGroup(c)) {
4358
- const nested = conditionsProblem(c.rules, depth + 1);
4359
- if (nested) return nested;
4360
- continue;
4361
- }
4362
- const parameterReason = parameterProblem(c.parameter);
4363
- if (parameterReason) return parameterReason;
4364
- const mutationReason = mutationsProblem(c.mutations);
4365
- if (mutationReason) return mutationReason;
4366
- const matchReason = matchProblem(c.match, c.parameter);
4367
- if (matchReason) return matchReason;
4368
- const m = c.match;
4369
- if (m.type === "regex") {
4370
- if (typeof m.value !== "string") return "regex match.value must be a string";
4371
- if (m.value.length > LIMITS2.maxRegexLength) return `regex longer than ${LIMITS2.maxRegexLength} chars`;
4372
- } else if (typeof m.value === "string" && m.value.length > LIMITS2.maxValueLength) {
4373
- return `match.value longer than ${LIMITS2.maxValueLength} chars`;
4374
- }
4375
- if (m.match) {
4376
- const nested = conditionsProblem([{ parameter: c.parameter, match: m.match }], depth + 1);
4377
- if (nested) return nested;
4378
- }
4838
+ }
4839
+ function toEnvelope(value) {
4840
+ if (!value || typeof value !== "object") return null;
4841
+ if (value.bundle && typeof value.bundle === "object") {
4842
+ return { bundle: value.bundle, etag: value.etag ?? null };
4843
+ }
4844
+ if (Array.isArray(value.firewall) || Array.isArray(value.whitelists)) {
4845
+ return { bundle: value, etag: null };
4379
4846
  }
4380
4847
  return null;
4381
4848
  }
@@ -4401,67 +4868,70 @@ function reportRejections(rejected, options, label) {
4401
4868
  `${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" : ""}`
4402
4869
  ), "onError");
4403
4870
  }
4404
- function fromSource(bundle, reason) {
4405
- return reason === void 0 ? { ...bundle, source: { ok: true } } : { ...bundle, source: { ok: false, reason } };
4871
+ function fromSource(bundle, origin, reason) {
4872
+ return {
4873
+ ...bundle,
4874
+ source: reason === void 0 ? { ok: true, origin } : { ok: false, origin, reason }
4875
+ };
4406
4876
  }
4407
4877
  async function resolveRules(options, store, ctx = {}) {
4408
4878
  const timeoutMs = ctx.timeoutMs;
4409
4879
  if (options.siteUuid) {
4410
4880
  const prior = await store.read();
4411
- const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, reportsDetections: options.reportDetections === true });
4881
+ const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, detectionState: ctx.detectionState });
4412
4882
  const res = await client.getRules();
4413
- if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
4883
+ if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache");
4414
4884
  if (res.success && !res.notModified) {
4415
4885
  const rejected = liveUpdateRejections(res, options);
4416
4886
  if (rejected.length > 0) {
4417
4887
  reportRejections(rejected, options, "rule update rejected");
4418
- if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
4419
- if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
4420
- return fromSource(emptyBundle(), "update rejected");
4888
+ if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache", "update rejected");
4889
+ if (options.rules) return fromSource(normalizeBundle(options.rules, options), "bundled", "update rejected");
4890
+ return fromSource(emptyBundle(), "empty", "update rejected");
4421
4891
  }
4422
4892
  const bundle = normalizeBundle(res, options);
4423
4893
  await store.write({ bundle, etag: res.etag ?? null });
4424
- return fromSource(bundle);
4894
+ return fromSource(bundle, "api");
4425
4895
  }
4426
4896
  if (prior?.bundle) {
4427
4897
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
4428
- return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
4898
+ return fromSource(normalizeBundle(prior.bundle, options), "cache", res.error ?? "no usable response");
4429
4899
  }
4430
4900
  if (options.rules) {
4431
4901
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`), "onError");
4432
- return fromSource(normalizeBundle(options.rules, options), res.error ?? "no usable response");
4902
+ return fromSource(normalizeBundle(options.rules, options), "bundled", res.error ?? "no usable response");
4433
4903
  }
4434
4904
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
4435
- return fromSource(emptyBundle(), res.error ?? "no usable response");
4905
+ return fromSource(emptyBundle(), "empty", res.error ?? "no usable response");
4436
4906
  }
4437
4907
  if (options.token) {
4438
4908
  const prior = await store.read();
4439
4909
  const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs });
4440
4910
  const res = await client.getRules();
4441
- if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
4911
+ if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache");
4442
4912
  if (res.success && !res.notModified) {
4443
4913
  const rejected = liveUpdateRejections(res, options);
4444
4914
  if (rejected.length > 0) {
4445
4915
  reportRejections(rejected, options, "rule update rejected");
4446
- if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
4447
- if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
4448
- return fromSource(emptyBundle(), "update rejected");
4916
+ if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "cache", "update rejected");
4917
+ if (options.rules) return fromSource(normalizeBundle(options.rules, options), "bundled", "update rejected");
4918
+ return fromSource(emptyBundle(), "empty", "update rejected");
4449
4919
  }
4450
4920
  const bundle = normalizeBundle(res, options);
4451
4921
  await store.write({ bundle, etag: res.etag ?? null });
4452
- return fromSource(bundle);
4922
+ return fromSource(bundle, "api");
4453
4923
  }
4454
4924
  if (prior?.bundle) {
4455
4925
  notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
4456
- return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
4926
+ return fromSource(normalizeBundle(prior.bundle, options), "cache", res.error ?? "no usable response");
4457
4927
  }
4458
4928
  notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
4459
- return fromSource(emptyBundle(), res.error ?? "no usable response");
4929
+ return fromSource(emptyBundle(), "empty", res.error ?? "no usable response");
4460
4930
  }
4461
4931
  if (options.rules) {
4462
- return fromSource(normalizeBundle(options.rules, options));
4932
+ return fromSource(normalizeBundle(options.rules, options), "bundled");
4463
4933
  }
4464
- return fromSource(emptyBundle());
4934
+ return fromSource(emptyBundle(), "empty");
4465
4935
  }
4466
4936
  function normalizeBundle(b, options = {}) {
4467
4937
  const enforcement = b?.enforcement ?? b?.mode;
@@ -4558,6 +5028,164 @@ var DEFAULT_BASE_URL3 = "https://api.patchstack.com/monitor/pulse";
4558
5028
  var DEFAULT_FLUSH_MS = 5e3;
4559
5029
  var MAX_BATCH = 50;
4560
5030
  var MAX_QUEUE = 500;
5031
+ var MAX_ATTEMPTS = 4;
5032
+ var RETRY_BASE_MS = 1e3;
5033
+ var RETRY_CAP_MS = 3e4;
5034
+ var RETRYABLE_EXPLICIT = /* @__PURE__ */ new Set([408, 425, 429]);
5035
+ function worthRetrying(status) {
5036
+ if (status === null) return true;
5037
+ return RETRYABLE_EXPLICIT.has(status) || status >= 500 && status <= 599;
5038
+ }
5039
+ var ATTEMPT_TIMEOUT_MS = 1e4;
5040
+ var STOP_BUDGET_MS = 5e3;
5041
+ var MAX_ROUTE_CHARS = 256;
5042
+ var MAX_PARAMETERS = 25;
5043
+ var MAX_PARAMETER_CHARS = 64;
5044
+ var MAX_IDENTIFIER_CHARS = 256;
5045
+ var MAX_CAPTURED_VALUES = 10;
5046
+ var MAX_CAPTURED_VALUE_CHARS = 512;
5047
+ var MAX_QUERY_KEYS = 10;
5048
+ var MAX_METHOD_CHARS = 16;
5049
+ var MAX_BODY_BYTES2 = 64 * 1024;
5050
+ function queryKeysOf(path7) {
5051
+ if (typeof path7 !== "string") return { keys: [], total: 0 };
5052
+ const start = path7.indexOf("?");
5053
+ if (start === -1) return { keys: [], total: 0 };
5054
+ let names;
5055
+ try {
5056
+ names = [...new URLSearchParams(path7.slice(start + 1)).keys()];
5057
+ } catch {
5058
+ return { keys: [], total: 0 };
5059
+ }
5060
+ const seen = /* @__PURE__ */ new Set();
5061
+ const keys = [];
5062
+ for (const name of names) {
5063
+ if (name === "") continue;
5064
+ if (seen.has(name)) continue;
5065
+ seen.add(name);
5066
+ if (keys.length < MAX_QUERY_KEYS) keys.push(name);
5067
+ }
5068
+ return { keys, total: seen.size };
5069
+ }
5070
+ function boundCapture(capture) {
5071
+ if (!capture || typeof capture !== "object" || Array.isArray(capture)) return null;
5072
+ const truncated = [];
5073
+ const own = (object, key) => Object.hasOwn(object, key) ? object[key] : void 0;
5074
+ const declaredPlan = own(capture, "plan");
5075
+ const plan = typeof declaredPlan === "string" ? capText(declaredPlan, MAX_IDENTIFIER_CHARS) : null;
5076
+ if (plan === null) return null;
5077
+ if (plan.truncated) truncated.push("plan");
5078
+ const out = { plan: plan.value };
5079
+ let overCap = 0;
5080
+ let rejected = 0;
5081
+ const declaredValues = own(capture, "values");
5082
+ if (Array.isArray(declaredValues) && declaredValues.length > 0) {
5083
+ const values = [];
5084
+ for (const entry of declaredValues) {
5085
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
5086
+ rejected += 1;
5087
+ continue;
5088
+ }
5089
+ const declaredParameter = own(entry, "parameter");
5090
+ const text = scalarText(own(entry, "value"));
5091
+ if (typeof declaredParameter !== "string" || text === null) {
5092
+ rejected += 1;
5093
+ continue;
5094
+ }
5095
+ if (values.length >= MAX_CAPTURED_VALUES) {
5096
+ overCap += 1;
5097
+ continue;
5098
+ }
5099
+ const label = capText(declaredParameter, MAX_PARAMETER_CHARS);
5100
+ const value = capText(text, MAX_CAPTURED_VALUE_CHARS);
5101
+ if (label.truncated && !truncated.includes("parameter")) truncated.push("parameter");
5102
+ if (value.truncated && !truncated.includes("value")) truncated.push("value");
5103
+ values.push({
5104
+ parameter: label.value,
5105
+ value: value.value,
5106
+ ...own(entry, "truncated") === true || value.truncated ? { truncated: true } : {}
5107
+ });
5108
+ }
5109
+ if (overCap > 0) truncated.push("values");
5110
+ if (values.length > 0) out.values = values;
5111
+ }
5112
+ const declaredRaw = own(capture, "raw");
5113
+ if (declaredRaw !== void 0 && declaredRaw !== null) {
5114
+ const rawValue = declaredRaw && typeof declaredRaw === "object" ? own(declaredRaw, "value") : void 0;
5115
+ if (typeof rawValue === "string") {
5116
+ const raw = capText(rawValue, MAX_CAPTURED_VALUE_CHARS);
5117
+ out.raw = {
5118
+ value: raw.value,
5119
+ ...own(declaredRaw, "truncated") === true || raw.truncated ? { truncated: true } : {}
5120
+ };
5121
+ } else {
5122
+ rejected += 1;
5123
+ }
5124
+ }
5125
+ const declaredCount = (key) => {
5126
+ const value = own(capture, key);
5127
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
5128
+ };
5129
+ const totals = {
5130
+ omitted: declaredCount("omitted") + overCap,
5131
+ unsupported: declaredCount("unsupported") + rejected,
5132
+ failed: declaredCount("failed")
5133
+ };
5134
+ for (const [key, total] of Object.entries(totals)) {
5135
+ if (total > 0) out[key] = total;
5136
+ }
5137
+ if (own(capture, "unavailable") === true) out.unavailable = true;
5138
+ if (truncated.length > 0) out.truncated = truncated;
5139
+ return out;
5140
+ }
5141
+ function scalarText(value) {
5142
+ if (typeof value === "string") return value;
5143
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
5144
+ if (typeof value === "boolean") return String(value);
5145
+ return null;
5146
+ }
5147
+ function makeInstanceId() {
5148
+ try {
5149
+ const uuid = globalThis.crypto?.randomUUID?.();
5150
+ if (typeof uuid === "string" && uuid !== "") return uuid;
5151
+ } catch {
5152
+ }
5153
+ return `${Date.now().toString(36)}-${Math.floor(Math.random() * 16777215).toString(36)}`;
5154
+ }
5155
+ function capText(value, limit) {
5156
+ const text = typeof value === "string" ? value : "";
5157
+ return text.length > limit ? { value: text.slice(0, limit), truncated: true } : { value: text, truncated: false };
5158
+ }
5159
+ function retryDelayMs(attempts, retryAfter, random = Math.random) {
5160
+ const advertised = parseRetryAfter(retryAfter);
5161
+ if (advertised !== null) return Math.min(advertised, RETRY_CAP_MS);
5162
+ const backoff = Math.min(RETRY_BASE_MS * 2 ** (attempts - 1), RETRY_CAP_MS);
5163
+ return Math.min(RETRY_CAP_MS, Math.round(backoff * (0.75 + random() * 0.5)));
5164
+ }
5165
+ function parseRetryAfter(value) {
5166
+ if (typeof value !== "string" || value.trim() === "") return null;
5167
+ const seconds = Number(value.trim());
5168
+ if (Number.isFinite(seconds)) return seconds >= 0 ? seconds * 1e3 : null;
5169
+ const at = Date.parse(value);
5170
+ if (Number.isNaN(at)) return null;
5171
+ return Math.max(0, at - Date.now());
5172
+ }
5173
+ var encoder = new TextEncoder();
5174
+ function byteLength(text) {
5175
+ return encoder.encode(text).length;
5176
+ }
5177
+ function splitToFit(events, maxBytes, wrap = (batch) => ({ detections: batch })) {
5178
+ const batch = events.slice();
5179
+ const rest = [];
5180
+ while (batch.length > 1 && byteLength(JSON.stringify(wrap(batch))) > maxBytes) {
5181
+ rest.unshift(batch.pop());
5182
+ }
5183
+ return [batch, rest];
5184
+ }
5185
+ function unattended(timer) {
5186
+ if (timer && typeof timer.unref === "function") timer.unref();
5187
+ return timer;
5188
+ }
4561
5189
  function ruleParameters(rule) {
4562
5190
  const out = /* @__PURE__ */ new Set();
4563
5191
  const walk2 = (conditions) => {
@@ -4592,12 +5220,22 @@ function createDetectionReporter(opts) {
4592
5220
  },
4593
5221
  flush() {
4594
5222
  },
4595
- stop() {
4596
- },
5223
+ stop: () => Promise.resolve(),
4597
5224
  setRulesEtag() {
4598
5225
  },
5226
+ announce() {
5227
+ },
4599
5228
  dropped: () => 0,
4600
- health: () => ({ sent: 0, delivered: 0, failed: 0, dropped: 0, lastDeliveredAt: null })
5229
+ health: () => ({
5230
+ sent: 0,
5231
+ delivered: 0,
5232
+ failed: 0,
5233
+ dropped: 0,
5234
+ retried: 0,
5235
+ reauthorized: 0,
5236
+ lastDeliveredAt: null,
5237
+ capability: { announced: 0, acknowledged: 0, failed: 0, retried: 0, lastAcknowledgedAt: null }
5238
+ })
4601
5239
  };
4602
5240
  }
4603
5241
  const configured = opts.baseUrl ?? process.env?.PATCHSTACK_PULSE_RULES_URL;
@@ -4611,47 +5249,233 @@ function createDetectionReporter(opts) {
4611
5249
  let failed = 0;
4612
5250
  let droppedTotal = 0;
4613
5251
  let lastDeliveredAt = null;
5252
+ let capabilityAnnounced = 0;
5253
+ let capabilityAcknowledged = 0;
5254
+ let capabilityFailed = 0;
5255
+ let lastCapabilityAckAt = null;
4614
5256
  let queue = [];
4615
5257
  let timer = null;
5258
+ let retryTimer = null;
4616
5259
  let stopped = false;
4617
5260
  let dropped = 0;
5261
+ let retried = 0;
5262
+ let capabilityRetried = 0;
5263
+ let flushRequested = false;
5264
+ let draining = false;
5265
+ let drainResolve = null;
5266
+ let drainPromise = null;
5267
+ let budgetTimer = null;
5268
+ let epoch = 0;
5269
+ let reauthorized = 0;
5270
+ let sending = false;
5271
+ let inFlight = null;
5272
+ let pendingState = null;
5273
+ const instanceId = makeInstanceId();
5274
+ let sequence = 0;
5275
+ const bodyFor = (events, droppedWith, state) => ({
5276
+ detections: events,
5277
+ // The count of what never made it, sent WITH the batch rather than inferred from a gap: a consumer
5278
+ // computing a false-positive rate needs to know its denominator is short, and silence about that
5279
+ // would make a truncated sample look like a complete one.
5280
+ dropped: droppedWith,
5281
+ ...state !== null ? { reporting_state: state } : {}
5282
+ });
5283
+ const takeBatch = (droppedWith, state) => {
5284
+ const [batch, rest] = splitToFit(
5285
+ queue.splice(0, MAX_BATCH),
5286
+ MAX_BODY_BYTES2,
5287
+ (events) => bodyFor(events, droppedWith, state)
5288
+ );
5289
+ if (rest.length > 0) queue.unshift(...rest);
5290
+ return batch;
5291
+ };
5292
+ const authConfig = { pulseAuth: opts.pulseAuth, endpoint: baseUrl, timeoutMs: ATTEMPT_TIMEOUT_MS };
5293
+ const detectionsUrl = `${baseUrl}/detections/${encodeURIComponent(siteUuid)}`;
5294
+ const post = async (body, key, signal, transport) => (
5295
+ // Through the shared Pulse path, which attaches the token and — on a 401 — discards it and retries
5296
+ // once with a fresh one. A cached token can stop being valid before it expires, and the server's
5297
+ // refusal is authoritative over our own clock; the batch's own headers, this key included, are
5298
+ // carried through both sends, so the redelivery is still recognisable as the same batch.
5299
+ pulseFetch(
5300
+ authConfig,
5301
+ detectionsUrl,
5302
+ {
5303
+ method: "POST",
5304
+ ...signal ? { signal } : {},
5305
+ headers: {
5306
+ "Content-Type": "application/json",
5307
+ Accept: "application/json",
5308
+ "User-Agent": "@patchstack/connect",
5309
+ // The same key on every attempt of one batch: an acknowledgement can be lost after the server
5310
+ // has already taken the batch, and a redelivery has to be identifiable as the same one.
5311
+ "Idempotency-Key": key
5312
+ },
5313
+ body: JSON.stringify(body)
5314
+ },
5315
+ transport
5316
+ )
5317
+ );
5318
+ const abandon = () => {
5319
+ if (!inFlight) return;
5320
+ failed += inFlight.events.length;
5321
+ if (inFlight.state !== null) capabilityFailed += 1;
5322
+ inFlight = null;
5323
+ };
5324
+ const settle = () => {
5325
+ if (!inFlight) return;
5326
+ if (inFlight.events.length > 0) {
5327
+ delivered += inFlight.events.length;
5328
+ lastDeliveredAt = (/* @__PURE__ */ new Date()).toISOString();
5329
+ }
5330
+ if (inFlight.state !== null) {
5331
+ capabilityAcknowledged += 1;
5332
+ lastCapabilityAckAt = (/* @__PURE__ */ new Date()).toISOString();
5333
+ }
5334
+ inFlight = null;
5335
+ };
5336
+ const attempt = async () => {
5337
+ if (!inFlight) return;
5338
+ const mine = epoch;
5339
+ sending = true;
5340
+ inFlight.attempts += 1;
5341
+ if (inFlight.attempts > 1) {
5342
+ if (inFlight.events.length > 0) retried += 1;
5343
+ if (inFlight.state !== null) capabilityRetried += 1;
5344
+ }
5345
+ const body = bodyFor(inFlight.events, inFlight.dropped, inFlight.state);
5346
+ let status = null;
5347
+ let retryAfter = null;
5348
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
5349
+ inFlight.controller = controller;
5350
+ const timeout = controller ? unattended(setTimeout(() => controller.abort(), ATTEMPT_TIMEOUT_MS)) : null;
5351
+ inFlight.timeout = timeout;
5352
+ let sends = 0;
5353
+ const counted = (url, init) => {
5354
+ if (String(url) === detectionsUrl) {
5355
+ sends += 1;
5356
+ if (sends > 1 && mine === epoch) reauthorized += 1;
5357
+ }
5358
+ return fetchImpl(url, init);
5359
+ };
5360
+ try {
5361
+ const res = await post(body, inFlight.key, controller?.signal, counted);
5362
+ if (mine !== epoch) return;
5363
+ if (res && res.ok) {
5364
+ settle();
5365
+ if (timeout) clearTimeout(timeout);
5366
+ finish();
5367
+ return;
5368
+ }
5369
+ status = typeof res?.status === "number" ? res.status : 0;
5370
+ retryAfter = res?.headers?.get?.("retry-after") ?? null;
5371
+ } catch {
5372
+ status = null;
5373
+ } finally {
5374
+ if (timeout) clearTimeout(timeout);
5375
+ if (inFlight && inFlight.controller === controller) {
5376
+ inFlight.controller = null;
5377
+ inFlight.timeout = null;
5378
+ }
5379
+ }
5380
+ if (mine !== epoch) return;
5381
+ const retryable = worthRetrying(status);
5382
+ if (retryable && inFlight.attempts < MAX_ATTEMPTS && !stopped) {
5383
+ const delay = retryDelayMs(inFlight.attempts, retryAfter);
5384
+ sending = false;
5385
+ retryTimer = unattended(
5386
+ setTimeout(() => {
5387
+ retryTimer = null;
5388
+ void attempt();
5389
+ }, delay)
5390
+ );
5391
+ return;
5392
+ }
5393
+ abandon();
5394
+ finish();
5395
+ };
5396
+ const finish = () => {
5397
+ sending = false;
5398
+ kick();
5399
+ };
5400
+ const arm = () => {
5401
+ if (!timer) {
5402
+ timer = unattended(
5403
+ setTimeout(() => {
5404
+ timer = null;
5405
+ flushRequested = true;
5406
+ kick();
5407
+ }, flushMs)
5408
+ );
5409
+ }
5410
+ };
5411
+ const due = () => draining || pendingState !== null || queue.length >= MAX_BATCH || flushRequested;
5412
+ const kick = () => {
5413
+ if (sending || inFlight) return;
5414
+ if (typeof fetchImpl !== "function") {
5415
+ if (draining) drained();
5416
+ return;
5417
+ }
5418
+ if (stopped && !draining) return;
5419
+ if (queue.length === 0 && pendingState === null) {
5420
+ flushRequested = false;
5421
+ if (draining) drained();
5422
+ return;
5423
+ }
5424
+ if (!due()) {
5425
+ arm();
5426
+ return;
5427
+ }
5428
+ flushRequested = false;
5429
+ const droppedWith = dropped;
5430
+ dropped = 0;
5431
+ droppedTotal += droppedWith;
5432
+ const state = pendingState;
5433
+ pendingState = null;
5434
+ const events = takeBatch(droppedWith, state);
5435
+ sent += events.length;
5436
+ if (state !== null) capabilityAnnounced += 1;
5437
+ sequence += 1;
5438
+ inFlight = { key: `${instanceId}-${sequence}`, events, dropped: droppedWith, state, attempts: 0 };
5439
+ void attempt();
5440
+ };
5441
+ const terminate = () => {
5442
+ epoch += 1;
5443
+ if (inFlight) {
5444
+ inFlight.controller?.abort();
5445
+ if (inFlight.timeout) clearTimeout(inFlight.timeout);
5446
+ failed += inFlight.events.length;
5447
+ if (inFlight.state !== null) capabilityFailed += 1;
5448
+ inFlight = null;
5449
+ }
5450
+ if (pendingState !== null) {
5451
+ pendingState = null;
5452
+ capabilityAnnounced += 1;
5453
+ capabilityFailed += 1;
5454
+ }
5455
+ sending = false;
5456
+ drained();
5457
+ };
5458
+ const drained = () => {
5459
+ draining = false;
5460
+ if (queue.length > 0) {
5461
+ droppedTotal += queue.length;
5462
+ queue = [];
5463
+ }
5464
+ if (budgetTimer) {
5465
+ clearTimeout(budgetTimer);
5466
+ budgetTimer = null;
5467
+ }
5468
+ const resolve = drainResolve;
5469
+ drainResolve = null;
5470
+ if (resolve) resolve();
5471
+ };
4618
5472
  const flush = () => {
4619
5473
  if (timer) {
4620
5474
  clearTimeout(timer);
4621
5475
  timer = null;
4622
5476
  }
4623
- if (queue.length === 0 || typeof fetchImpl !== "function") return;
4624
- const batch = queue.splice(0, MAX_BATCH);
4625
- const droppedWith = dropped;
4626
- dropped = 0;
4627
- droppedTotal += droppedWith;
4628
- sent += batch.length;
4629
- void (async () => {
4630
- try {
4631
- const res = await fetchImpl(`${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, {
4632
- method: "POST",
4633
- headers: {
4634
- "Content-Type": "application/json",
4635
- Accept: "application/json",
4636
- "User-Agent": "@patchstack/connect",
4637
- // Same credential path as the rules fetch. The detections endpoint is site-addressed and
4638
- // requires a verified, site-bound token, so a batch sent without one is refused — which is
4639
- // why the runtime does not build a reporter when no credential resolves, rather than
4640
- // posting into a 401.
4641
- ...await pulseAuthHeader({ pulseAuth: opts.pulseAuth, endpoint: baseUrl }, fetchImpl)
4642
- },
4643
- body: JSON.stringify({ detections: batch, dropped: droppedWith })
4644
- });
4645
- if (res && res.ok) {
4646
- delivered += batch.length;
4647
- lastDeliveredAt = (/* @__PURE__ */ new Date()).toISOString();
4648
- } else {
4649
- failed += batch.length;
4650
- }
4651
- } catch {
4652
- failed += batch.length;
4653
- }
4654
- })();
5477
+ flushRequested = true;
5478
+ kick();
4655
5479
  };
4656
5480
  return {
4657
5481
  /**
@@ -4670,32 +5494,144 @@ function createDetectionReporter(opts) {
4670
5494
  queue.shift();
4671
5495
  dropped++;
4672
5496
  }
5497
+ const query = queryKeysOf(detection.path);
5498
+ const method = typeof detection.method === "string" ? capText(detection.method, MAX_METHOD_CHARS) : null;
5499
+ const userAgent = typeof detection.userAgent === "string" && detection.userAgent !== "" ? capText(detection.userAgent, MAX_IDENTIFIER_CHARS) : null;
5500
+ const queryKeys = query.keys.map((name) => capText(name, MAX_PARAMETER_CHARS));
5501
+ const capture = boundCapture(detection.capture);
5502
+ const rawRoute = routeOf(detection.path);
5503
+ const route = typeof rawRoute === "string" ? capText(rawRoute, MAX_ROUTE_CHARS) : { value: rawRoute, truncated: false };
5504
+ const allParameters = ruleParameters(detection.rule);
5505
+ const parameters = allParameters.slice(0, MAX_PARAMETERS).map((name) => capText(name, MAX_PARAMETER_CHARS));
5506
+ const truncated = [];
5507
+ if (route.truncated) truncated.push("route");
5508
+ if (method?.truncated) truncated.push("method");
5509
+ if (userAgent?.truncated) truncated.push("user_agent");
5510
+ if (query.total > queryKeys.length || queryKeys.some((entry) => entry.truncated)) {
5511
+ truncated.push("query_keys");
5512
+ }
5513
+ if (allParameters.length > MAX_PARAMETERS || parameters.some((entry) => entry.truncated)) {
5514
+ truncated.push("parameters");
5515
+ }
5516
+ const id = capText(String(ruleId), MAX_IDENTIFIER_CHARS);
5517
+ const revision = capText(revisionOf(detection.rule) ?? "", MAX_IDENTIFIER_CHARS);
5518
+ const etag = capText(rulesEtag ?? "", MAX_IDENTIFIER_CHARS);
5519
+ for (const [name, field] of [
5520
+ ["rule_id", id],
5521
+ ["rule_revision", revision],
5522
+ ["rules_etag", etag]
5523
+ ]) {
5524
+ if (field.truncated) truncated.push(name);
5525
+ }
4673
5526
  queue.push({
4674
- rule_id: ruleId,
4675
- route: routeOf(detection.path),
4676
- parameters: ruleParameters(detection.rule),
5527
+ rule_id: id.value,
5528
+ route: route.value,
5529
+ parameters: parameters.map((entry) => entry.value),
5530
+ // Present only when something was shortened, so its absence is not a claim of its own.
5531
+ ...truncated.length > 0 ? { truncated } : {},
5532
+ // Only when parameters were actually left out. Reporting a total because some OTHER field was
5533
+ // shortened states that parameters were omitted when none were.
5534
+ ...truncated.includes("parameters") ? { parameters_total: allParameters.length } : {},
5535
+ method: method === null ? null : method.value,
5536
+ // The rest of the URL, as names only. `route` is the path; together they say what was requested
5537
+ // without saying what was in it.
5538
+ query_keys: queryKeys.map((entry) => entry.value),
5539
+ // Present only when the list is short, so its absence is not a claim of its own.
5540
+ ...query.total > queryKeys.length ? { query_keys_total: query.total } : {},
5541
+ // Who asked. Capped, since it is client-supplied text and this is an event with a size bound.
5542
+ user_agent: userAgent === null ? null : userAgent.value,
4677
5543
  phase: detection.phase ?? null,
4678
5544
  // The state this detection was handled under, which is the whole point: `false` is a rule that
4679
5545
  // saw traffic it would have stopped.
4680
5546
  enforced: detection.mode === "block",
4681
- rules_etag: rulesEtag,
5547
+ rules_etag: rulesEtag === null ? null : etag.value,
4682
5548
  // The revision of THIS rule, as the bundle delivered it. The bundle identity above answers "which
4683
5549
  // bundle", which changes whenever anything in it changes — so it cannot say whether the counts for
4684
5550
  // one rule describe the document that rule has now. Passed through untouched, and null when the
4685
5551
  // bundle carried none.
4686
- rule_revision: revisionOf(detection.rule),
5552
+ rule_revision: revisionOf(detection.rule) === null ? null : revision.value,
5553
+ // What the rule was permitted to show, and what it showed. Present only when a plan was derived,
5554
+ // which is to say only for a phase that has a reading to derive from.
5555
+ ...capture ? { capture } : {},
5556
+ // The client address and where it came from. `client_ip` is omitted entirely when there is none,
5557
+ // so a present-but-empty field cannot read as a failed lookup of a real address; the provenance is
5558
+ // always present, because "this could not be established" is the part a reader needs.
5559
+ ...clientIpFields({ ip: detection.ip ?? null, source: detection.clientIpSource ?? "unavailable" }),
4687
5560
  detected_at: (/* @__PURE__ */ new Date()).toISOString()
4688
5561
  });
4689
- if (queue.length >= MAX_BATCH) {
4690
- flush();
4691
- return;
4692
- }
4693
- if (!timer) timer = setTimeout(flush, flushMs);
5562
+ kick();
4694
5563
  },
4695
5564
  flush,
5565
+ /**
5566
+ * Tell the platform which reporting state this guard settled on.
5567
+ *
5568
+ * The state also travels on the rules fetch, but that request is made BEFORE the fetch decides
5569
+ * whether the rules are the platform's — so a site booting with an empty cache declares that it holds
5570
+ * no managed rules, then receives them. Without this, the corrected state would wait for the next
5571
+ * refresh, and a guard with refreshing switched off has none.
5572
+ *
5573
+ * Goes through the same transport as events, so it inherits the retry, the idempotency key and the
5574
+ * single-flight bound. Only the newest state is kept: calling this twice before a send declares the
5575
+ * second, because what the platform needs is the state now, not how it got there. A declaration that
5576
+ * exhausts its retries is dropped rather than held — the state travels on every rules fetch too, so
5577
+ * the next one corrects it.
5578
+ *
5579
+ * @param {string} state
5580
+ */
5581
+ announce(state) {
5582
+ if (stopped || typeof fetchImpl !== "function" || typeof state !== "string") return;
5583
+ pendingState = state;
5584
+ queueMicrotask(() => {
5585
+ if (!stopped) kick();
5586
+ });
5587
+ },
5588
+ /**
5589
+ * Stop reporting, leaving nothing stranded and nothing scheduled.
5590
+ *
5591
+ * Three things can be outstanding, and each needs an answer:
5592
+ *
5593
+ * - a batch waiting on a retry timer — it holds the only send slot, so clearing the timer alone would
5594
+ * leave it neither delivered nor counted. It gets one final attempt, with no retry behind it.
5595
+ * - a request in flight — it is aborted, and its completion drains what is left rather than starting
5596
+ * open-ended work.
5597
+ * - events still queued — drained a batch at a time, each attempted once.
5598
+ *
5599
+ * Whatever remains unsent when the drain runs out is counted as dropped, so every recorded event ends
5600
+ * up delivered, refused or dropped, and none simply disappears.
5601
+ *
5602
+ * The drain is asynchronous, so this returns a promise that settles when nothing is outstanding —
5603
+ * which a host shutting down can await instead of racing the last batch against process exit. It is
5604
+ * best-effort and bounded: a runtime that terminates the process regardless, or a drain slower than
5605
+ * `STOP_BUDGET_MS`, still ends the wait. Ignoring the promise behaves exactly as before.
5606
+ */
4696
5607
  stop() {
5608
+ if (stopped) return drainPromise ?? Promise.resolve();
4697
5609
  stopped = true;
4698
- flush();
5610
+ drainPromise = new Promise((resolve) => {
5611
+ drainResolve = resolve;
5612
+ });
5613
+ budgetTimer = unattended(setTimeout(() => {
5614
+ budgetTimer = null;
5615
+ terminate();
5616
+ }, STOP_BUDGET_MS));
5617
+ if (timer) {
5618
+ clearTimeout(timer);
5619
+ timer = null;
5620
+ }
5621
+ draining = true;
5622
+ if (retryTimer) {
5623
+ clearTimeout(retryTimer);
5624
+ retryTimer = null;
5625
+ void attempt();
5626
+ return drainPromise;
5627
+ }
5628
+ if (sending) {
5629
+ inFlight?.controller?.abort();
5630
+ return drainPromise;
5631
+ }
5632
+ flushRequested = true;
5633
+ kick();
5634
+ return drainPromise;
4699
5635
  },
4700
5636
  /**
4701
5637
  * Point later events at the bundle now running. Called after an ACCEPTED swap only — a rejected
@@ -4714,12 +5650,70 @@ function createDetectionReporter(opts) {
4714
5650
  * Delivery health, counted in events: attempted, acknowledged, refused or unreachable, and dropped
4715
5651
  * for queue pressure — plus when a batch was last acknowledged. No request data of any kind.
4716
5652
  */
4717
- health: () => ({ sent, delivered, failed, dropped: droppedTotal + dropped, lastDeliveredAt })
5653
+ health: () => ({
5654
+ sent,
5655
+ delivered,
5656
+ failed,
5657
+ dropped: droppedTotal + dropped,
5658
+ // Attempts beyond the first, counted in ATTEMPTS rather than events: a path that only ever
5659
+ // succeeds on a second try is working, and is worth telling apart from one that never retries.
5660
+ retried,
5661
+ // Redeliveries the credential path made after a refused token, which are not backoff retries and
5662
+ // would otherwise appear nowhere: a rotated or revoked credential is worth seeing as itself.
5663
+ reauthorized,
5664
+ lastDeliveredAt,
5665
+ // Separate, because a capability announcement delivers no events. Reading zero here alongside a
5666
+ // non-zero `delivered` is a normal state, and so is the reverse.
5667
+ capability: {
5668
+ announced: capabilityAnnounced,
5669
+ acknowledged: capabilityAcknowledged,
5670
+ failed: capabilityFailed,
5671
+ retried: capabilityRetried,
5672
+ lastAcknowledgedAt: lastCapabilityAckAt
5673
+ }
5674
+ })
4718
5675
  };
4719
5676
  }
4720
5677
 
5678
+ // src/protect/reporting-state.js
5679
+ var REPORTING_STATES = Object.freeze([
5680
+ /** Enrolled, managed rules, credential present, not opted out. Events are sent. */
5681
+ "on",
5682
+ /** `PATCHSTACK_REPORT_DETECTIONS=0`. An explicit, per-deployment opt-out. */
5683
+ "disabled-by-config",
5684
+ /** `PATCHSTACK_TELEMETRY=0`. The broader switch, which covers this along with everything else. */
5685
+ "disabled-by-telemetry-opt-out",
5686
+ /** No site identity: a local or unenrolled install. Nothing to report against. */
5687
+ "not-enrolled",
5688
+ /**
5689
+ * A site identity, but the rules running are not the platform's — the caller's own bundle, or none.
5690
+ * There is no managed rule document to attribute a detection to, so a report would name a rule id the
5691
+ * platform never issued.
5692
+ */
5693
+ "no-managed-rules",
5694
+ /** Enrolled with managed rules, but no credential resolved, so a report would be refused. */
5695
+ "unavailable-no-credential"
5696
+ ]);
5697
+ function optedOut(value) {
5698
+ if (value === void 0 || value === null || value === "") return false;
5699
+ return /^(0|false|off|no)$/i.test(String(value));
5700
+ }
5701
+ function reportingState(input) {
5702
+ const env = input.env ?? (typeof process !== "undefined" ? process.env : void 0) ?? {};
5703
+ if (input.configOptOut === true) return { state: "disabled-by-config", reports: false };
5704
+ if (optedOut(env.PATCHSTACK_REPORT_DETECTIONS)) return { state: "disabled-by-config", reports: false };
5705
+ if (optedOut(env.PATCHSTACK_TELEMETRY)) return { state: "disabled-by-telemetry-opt-out", reports: false };
5706
+ const siteUuid = input.siteUuid;
5707
+ if (typeof siteUuid !== "string" || siteUuid === "") return { state: "not-enrolled", reports: false };
5708
+ if (input.hasCredential !== true) return { state: "unavailable-no-credential", reports: false };
5709
+ const managed = input.ruleOrigin === "api" || input.ruleOrigin === "cache";
5710
+ if (!managed) return { state: "no-managed-rules", reports: false };
5711
+ return { state: "on", reports: true };
5712
+ }
5713
+
4721
5714
  // src/protect/firewall-log.js
4722
5715
  var DEFAULT_API_BASE = "https://api.patchstack.com";
5716
+ var STOP_BUDGET_MS2 = 5e3;
4723
5717
  var DEFAULT_FLUSH_MS2 = 1e3;
4724
5718
  var MAX_BATCH2 = 50;
4725
5719
  var TOKEN_SKEW_MS2 = 6e4;
@@ -4760,9 +5754,7 @@ function createFirewallLogReporter(opts) {
4760
5754
  const creds = parseApiKey(opts.apiKey);
4761
5755
  if (!creds) {
4762
5756
  return { record() {
4763
- }, flush() {
4764
- }, stop() {
4765
- } };
5757
+ }, flush: () => Promise.resolve(), stop: () => Promise.resolve() };
4766
5758
  }
4767
5759
  const apiBase = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
4768
5760
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
@@ -4771,9 +5763,13 @@ function createFirewallLogReporter(opts) {
4771
5763
  let queue = [];
4772
5764
  let timer = null;
4773
5765
  let stopped = false;
5766
+ const outstanding = /* @__PURE__ */ new Set();
5767
+ let drainPromise = null;
5768
+ const lifetime = typeof AbortController === "function" ? new AbortController() : null;
5769
+ let ended = false;
4774
5770
  let cachedToken = null;
4775
5771
  let tokenInflight = null;
4776
- const fetchAccessToken = async () => {
5772
+ const fetchAccessToken = async (signal) => {
4777
5773
  if (cachedToken && Date.now() < cachedToken.expiresAt - TOKEN_SKEW_MS2) {
4778
5774
  return cachedToken.token;
4779
5775
  }
@@ -4782,6 +5778,7 @@ function createFirewallLogReporter(opts) {
4782
5778
  try {
4783
5779
  const res = await fetchImpl(`${apiBase}/oauth/token`, {
4784
5780
  method: "POST",
5781
+ ...signal ? { signal } : {},
4785
5782
  headers: {
4786
5783
  "Content-Type": "application/json",
4787
5784
  Accept: "application/json",
@@ -4814,15 +5811,16 @@ function createFirewallLogReporter(opts) {
4814
5811
  clearTimeout(timer);
4815
5812
  timer = null;
4816
5813
  }
4817
- if (queue.length === 0 || typeof fetchImpl !== "function") return;
5814
+ if (ended || queue.length === 0 || typeof fetchImpl !== "function") return Promise.resolve();
4818
5815
  const batch = queue.splice(0, MAX_BATCH2);
4819
- void (async () => {
4820
- const token = await fetchAccessToken();
4821
- if (!token) return;
4822
- const body = new URLSearchParams();
4823
- body.set("type", "firewall");
4824
- body.set("logs", JSON.stringify(batch));
5816
+ let entry;
5817
+ const send = (async () => {
4825
5818
  try {
5819
+ const token = await fetchAccessToken(lifetime?.signal);
5820
+ if (!token || ended) return;
5821
+ const body = new URLSearchParams();
5822
+ body.set("type", "firewall");
5823
+ body.set("logs", JSON.stringify(batch));
4826
5824
  const p = fetchImpl(`${apiBase}/api/logs/log`, {
4827
5825
  method: "POST",
4828
5826
  headers: {
@@ -4832,13 +5830,20 @@ function createFirewallLogReporter(opts) {
4832
5830
  "User-Agent": "@patchstack/connect",
4833
5831
  ...sourceHost ? { "Source-Host": sourceHost } : {}
4834
5832
  },
4835
- body
5833
+ body,
5834
+ // Both phases carry it, so a shutdown that runs out of time can end either one.
5835
+ ...lifetime ? { signal: lifetime.signal } : {}
4836
5836
  });
4837
- if (p && typeof p.then === "function") p.catch(() => {
5837
+ if (p && typeof p.then === "function") await p.catch(() => {
4838
5838
  });
4839
5839
  } catch {
5840
+ } finally {
5841
+ outstanding.delete(entry);
4840
5842
  }
4841
5843
  })();
5844
+ entry = send;
5845
+ outstanding.add(send);
5846
+ return send;
4842
5847
  };
4843
5848
  return {
4844
5849
  /**
@@ -4869,9 +5874,50 @@ function createFirewallLogReporter(opts) {
4869
5874
  if (!timer) timer = setTimeout(flush, flushMs);
4870
5875
  },
4871
5876
  flush,
5877
+ /**
5878
+ * Stop, and hand back a wait for what was outstanding.
5879
+ *
5880
+ * One flush sends at most a batch, so the queue is drained a batch at a time. The loop stops as soon
5881
+ * as a pass cannot shrink the queue — with no usable transport there is nothing to wait for, and
5882
+ * spinning would be worse than leaving the records where they are.
5883
+ */
4872
5884
  stop() {
5885
+ if (drainPromise) return drainPromise;
4873
5886
  stopped = true;
4874
- flush();
5887
+ if (timer) {
5888
+ clearTimeout(timer);
5889
+ timer = null;
5890
+ }
5891
+ const terminate = () => {
5892
+ ended = true;
5893
+ lifetime?.abort();
5894
+ queue = [];
5895
+ outstanding.clear();
5896
+ };
5897
+ let budget = null;
5898
+ const spent = new Promise((resolve) => {
5899
+ budget = setTimeout(() => {
5900
+ terminate();
5901
+ resolve();
5902
+ }, STOP_BUDGET_MS2);
5903
+ if (typeof budget.unref === "function") budget.unref();
5904
+ });
5905
+ const work = (async () => {
5906
+ while (!ended) {
5907
+ if (outstanding.size > 0) {
5908
+ await Promise.all([...outstanding]);
5909
+ continue;
5910
+ }
5911
+ if (queue.length === 0) break;
5912
+ const before = queue.length;
5913
+ await flush();
5914
+ if (queue.length >= before) break;
5915
+ }
5916
+ })();
5917
+ drainPromise = Promise.race([work, spent]).then(() => {
5918
+ if (budget) clearTimeout(budget);
5919
+ });
5920
+ return drainPromise;
4875
5921
  }
4876
5922
  };
4877
5923
  }
@@ -4962,8 +6008,8 @@ async function createProtection(options = {}) {
4962
6008
  fetchImpl: options.fetchImpl
4963
6009
  }) : null;
4964
6010
  let detections = null;
6011
+ const ruleIdentity = (rule) => ({ id: rule?.id, category: rule?.category });
4965
6012
  const onDetect = (detection) => {
4966
- notify(userOnDetect, detection, "onDetect");
4967
6013
  if (detections) detections.record(detection);
4968
6014
  if (firewallLog && detection?.mode === "block") {
4969
6015
  firewallLog.record({
@@ -4974,6 +6020,12 @@ async function createProtection(options = {}) {
4974
6020
  userAgent: detection.userAgent
4975
6021
  });
4976
6022
  }
6023
+ const { capture: _evidence, ...forCallback } = detection ?? {};
6024
+ notify(
6025
+ userOnDetect,
6026
+ { ...forCallback, ...detection?.rule ? { rule: ruleIdentity(detection.rule) } : {} },
6027
+ "onDetect"
6028
+ );
4977
6029
  };
4978
6030
  const store = makeStore(options);
4979
6031
  const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
@@ -4983,29 +6035,58 @@ async function createProtection(options = {}) {
4983
6035
  notify(onError, new Error(message), "onError");
4984
6036
  console.warn(message);
4985
6037
  }
4986
- const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth });
4987
- let detectionReporting = "off";
4988
- if (options.reportDetections === true && options.siteUuid && telemetryEnabled()) {
4989
- if (!pulseAuth) {
4990
- detectionReporting = "unavailable-no-credential";
4991
- 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.";
4992
- notify(onError, new Error(message), "onError");
4993
- console.warn(message);
4994
- } else {
4995
- detectionReporting = "on";
6038
+ const cachedOrigin = async () => {
6039
+ const prior = await store.read();
6040
+ if (prior?.bundle) return "cache";
6041
+ return options.rules ? "bundled" : "empty";
6042
+ };
6043
+ const stateFor = (origin) => reportingState({
6044
+ siteUuid: options.siteUuid,
6045
+ ruleOrigin: origin,
6046
+ hasCredential: Boolean(pulseAuth),
6047
+ configOptOut: options.reportDetections === false
6048
+ });
6049
+ const preFetchState = stateFor(await cachedOrigin()).state;
6050
+ const bundle = await resolveRules(options, store, {
6051
+ timeoutMs: bootTimeoutMs,
6052
+ pulseAuth,
6053
+ detectionState: preFetchState
6054
+ });
6055
+ let detectionReporting = "not-enrolled";
6056
+ let currentOrigin;
6057
+ const applyReportingState = async (origin) => {
6058
+ currentOrigin = origin;
6059
+ const next = stateFor(origin);
6060
+ const changed = next.state !== detectionReporting;
6061
+ detectionReporting = next.state;
6062
+ if (next.reports && !detections) {
4996
6063
  detections = createDetectionReporter({
4997
6064
  siteUuid: options.siteUuid,
4998
6065
  baseUrl: options.pulseRulesUrl,
4999
6066
  pulseAuth,
5000
- // The bundle the guard is actually running, so a hit can be attributed to the rules that produced
5001
- // it rather than to whatever is current when the report is read. Kept current across refreshes —
5002
- // see the refresh tick below.
6067
+ // The bundle the guard is actually running, so a hit can be attributed to the rules that
6068
+ // produced it rather than to whatever is current when the report is read.
5003
6069
  rulesEtag: (await store.read())?.etag ?? null,
5004
6070
  fetchImpl: options.fetchImpl,
5005
6071
  flushMs: options.detectionFlushMs
5006
6072
  });
6073
+ } else if (!next.reports && detections) {
6074
+ detections.stop();
6075
+ detections = void 0;
5007
6076
  }
5008
- }
6077
+ if (changed && next.state === "unavailable-no-credential") {
6078
+ 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.";
6079
+ notify(onError, new Error(message), "onError");
6080
+ console.warn(message);
6081
+ }
6082
+ return next;
6083
+ };
6084
+ const applyAndAcknowledge = async (origin, declared) => {
6085
+ const settled = await applyReportingState(origin);
6086
+ if (settled.state !== declared && detections) detections.announce(settled.state);
6087
+ return settled;
6088
+ };
6089
+ await applyAndAcknowledge(bundle.source?.origin, preFetchState);
5009
6090
  let mode = resolveMode(options, bundle);
5010
6091
  let requestRules;
5011
6092
  let responseRules;
@@ -5052,6 +6133,19 @@ async function createProtection(options = {}) {
5052
6133
  };
5053
6134
  const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
5054
6135
  const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
6136
+ const planCache = createPlanCache();
6137
+ const evidenceFrom = (result) => {
6138
+ if (!detections || !result?.rule) return void 0;
6139
+ let entry;
6140
+ try {
6141
+ entry = planCache.for(result.rule);
6142
+ } catch (err) {
6143
+ notify(onError, err, "onError");
6144
+ return void 0;
6145
+ }
6146
+ if (!permitsAnything(entry.plan)) return { plan: entry.reference };
6147
+ return { plan: entry.reference, ...captureValues(entry.plan, result.resolver) };
6148
+ };
5055
6149
  const decide = (phase, result, block, allow2, ctx = {}) => {
5056
6150
  if (!result || !result.blocked) return allow2();
5057
6151
  const effectiveMode = ruleMode(result.rule);
@@ -5066,7 +6160,12 @@ async function createProtection(options = {}) {
5066
6160
  method: ctx.method,
5067
6161
  path: ctx.path,
5068
6162
  ip: ctx.ip,
5069
- userAgent: ctx.userAgent
6163
+ // Provenance travels with the address. Without it a consumer cannot tell an observed peer from a
6164
+ // value read out of a forwarded header, and `null` from "there was no address to establish".
6165
+ clientIpSource: ctx.clientIpSource,
6166
+ userAgent: ctx.userAgent,
6167
+ // Derived from the reading this decision was made on, before that reading goes out of scope.
6168
+ capture: evidenceFrom(result)
5070
6169
  });
5071
6170
  return effectiveMode === "block" ? block() : allow2();
5072
6171
  };
@@ -5089,7 +6188,18 @@ async function createProtection(options = {}) {
5089
6188
  }
5090
6189
  if (!result.blocked) continue;
5091
6190
  const responseMode = ruleMode(rule);
5092
- onDetect({ phase: "response", mode: responseMode, category: rule.category, rule, message: result.message });
6191
+ onDetect({
6192
+ phase: "response",
6193
+ mode: responseMode,
6194
+ category: rule.category,
6195
+ rule,
6196
+ message: result.message,
6197
+ ...requestMetaFromContext(reqCtx),
6198
+ // A response detection names its capture policy like any other. Response sources are never
6199
+ // capturable, so a response-only rule reports a plan that permitted nothing — which is the
6200
+ // policy, and a different fact from a rule that found nothing.
6201
+ capture: evidenceFrom(result)
6202
+ });
5093
6203
  if (responseMode !== "block") continue;
5094
6204
  if (redactors && redactors.length) {
5095
6205
  const spanRedactors = redactors.filter((r) => !r.jsonPath);
@@ -5126,17 +6236,88 @@ async function createProtection(options = {}) {
5126
6236
  for (const rule of headerMutations) applyHeaderMutation(headers, rule);
5127
6237
  return { verdict: "redact", body, headers };
5128
6238
  };
5129
- const reqContextFromFetch = (request) => {
6239
+ const shapeExpressRequest = (req) => {
6240
+ const client = resolveClientIp({
6241
+ peer: req?.socket?.remoteAddress,
6242
+ headers: req?.headers ?? {},
6243
+ trustedProxy: options.trustedProxy
6244
+ });
6245
+ return {
6246
+ client,
6247
+ shaped: {
6248
+ // Own copies of everything a rule can address. Listed rather than spread, so a field the engine
6249
+ // gains has to be added here deliberately instead of appearing to work by accident.
6250
+ // Through the same gate as the engine's own normalisation: this projection turns whatever it reads
6251
+ // into an OWN property, so reading a polluted prototype here would launder it into evidence that
6252
+ // no later own-property check could tell from the real thing.
6253
+ method: requestField(req, "method"),
6254
+ url: requestField(req, "url"),
6255
+ originalUrl: requestField(req, "originalUrl"),
6256
+ headers: requestField(req, "headers"),
6257
+ query: requestField(req, "query"),
6258
+ body: requestField(req, "body"),
6259
+ files: requestField(req, "files"),
6260
+ cookies: requestField(req, "cookies"),
6261
+ socket: requestField(req, "socket"),
6262
+ // The verbatim body, when a caller kept one. A `raw` rule reads it directly, and the engine
6263
+ // otherwise reconstructs raw by re-serialising the parsed body — which cannot carry what parsing
6264
+ // did not keep: a body that failed to parse at all, a duplicate key where only the last value
6265
+ // survives, or the exact bytes a signature was written against.
6266
+ //
6267
+ // Own property only. Evidence is what this request actually carried: a value reachable through a
6268
+ // polluted prototype is not, and materialising it here would turn it into evidence indistinguish-
6269
+ // able from real bytes — firing every raw rule that matches it. The adapters that capture real
6270
+ // bytes define `_rawBody` on the request object itself.
6271
+ ...Object.hasOwn(req ?? {}, "_rawBody") ? { _rawBody: req._rawBody } : {},
6272
+ // The resolved address, and the resolution itself for the consumers downstream.
6273
+ ip: client.ip ?? "",
6274
+ _clientIp: client
6275
+ }
6276
+ };
6277
+ };
6278
+ const screenFetchRequest = async (request) => {
6279
+ let result;
6280
+ let shaped;
6281
+ try {
6282
+ shaped = await fromFetchRequest(request, { trustedProxy: options.trustedProxy });
6283
+ result = engine.evaluate(shaped);
6284
+ } catch (err) {
6285
+ notify(onError, err, "onError");
6286
+ return { blocked: null, client: void 0 };
6287
+ }
6288
+ const blocked = decide(
6289
+ "request",
6290
+ result,
6291
+ () => blockResponse(result, request),
6292
+ () => null,
6293
+ requestMeta(shaped, request)
6294
+ );
6295
+ return { blocked, client: shaped?._clientIp };
6296
+ };
6297
+ const reqContextFromFetch = (request, client) => {
5130
6298
  try {
5131
6299
  const u = new URL(request.url);
5132
6300
  const headers = headerObject(request.headers);
5133
6301
  if (!headers.host) headers.host = u.host;
5134
- return { method: request.method, originalUrl: u.pathname + u.search, headers };
6302
+ const resolved = client ?? { ip: null, source: "unavailable" };
6303
+ return {
6304
+ method: request.method,
6305
+ originalUrl: u.pathname + u.search,
6306
+ headers,
6307
+ ip: resolved.ip ?? "",
6308
+ _clientIp: resolved
6309
+ };
5135
6310
  } catch {
5136
6311
  return void 0;
5137
6312
  }
5138
6313
  };
5139
- const reqContextFromNode = (req) => req ? { method: req.method, originalUrl: req.url, headers: req.headers || {} } : void 0;
6314
+ const reqContextFromNode = (req, client) => req ? {
6315
+ method: req.method,
6316
+ originalUrl: req.url,
6317
+ headers: req.headers || {},
6318
+ ip: client?.ip ?? "",
6319
+ _clientIp: client ?? { ip: null, source: "unavailable" }
6320
+ } : void 0;
5140
6321
  const screenResp = async (response, reqCtx) => {
5141
6322
  const read = await readTextResponse(response, screenCap);
5142
6323
  if (read.skip) {
@@ -5263,7 +6444,23 @@ async function createProtection(options = {}) {
5263
6444
  }
5264
6445
  if (!result.blocked) return false;
5265
6446
  const egressMode = ruleMode(result.rule);
5266
- onDetect({ phase: "egress", mode: egressMode, category: result.rule?.category, rule: result.rule, message: result.message });
6447
+ let egressPath = null;
6448
+ try {
6449
+ const u = new URL(url);
6450
+ egressPath = u.pathname + u.search;
6451
+ } catch {
6452
+ egressPath = typeof url === "string" ? url : null;
6453
+ }
6454
+ onDetect({
6455
+ phase: "egress",
6456
+ mode: egressMode,
6457
+ category: result.rule?.category,
6458
+ rule: result.rule,
6459
+ message: result.message,
6460
+ method: typeof method === "string" ? method : null,
6461
+ path: egressPath,
6462
+ capture: evidenceFrom(result)
6463
+ });
5267
6464
  return egressMode === "block";
5268
6465
  };
5269
6466
  const protection = {
@@ -5284,28 +6481,26 @@ async function createProtection(options = {}) {
5284
6481
  },
5285
6482
  // Screen a fetch Response through the response-phase rules (redact/block). Used by
5286
6483
  // .fetch(), and by the Supabase guard on its forwarded upstream response.
5287
- screenResponse: (response, request) => screenResp(response, request ? reqContextFromFetch(request) : void 0),
6484
+ // A standalone response screen with no request phase of its own — the Supabase guard's forwarded
6485
+ // upstream response. It resolves once here, which is the only resolution for this call.
6486
+ screenResponse: (response, request) => screenResp(
6487
+ response,
6488
+ request ? reqContextFromFetch(
6489
+ request,
6490
+ resolveClientIp({ headers: headerObject(request.headers), trustedProxy: options.trustedProxy })
6491
+ ) : void 0
6492
+ ),
5288
6493
  // (request) => Response | null (null = allow, caller proceeds). Request phase only.
5289
6494
  fetchGuard() {
5290
- return async (request) => {
5291
- let result;
5292
- try {
5293
- result = engine.evaluate(await fromFetchRequest(request));
5294
- } catch (err) {
5295
- notify(onError, err, "onError");
5296
- return null;
5297
- }
5298
- return decide("request", result, () => blockResponse(result, request), () => null, fetchRequestMeta(request));
5299
- };
6495
+ return async (request) => (await screenFetchRequest(request)).blocked;
5300
6496
  },
5301
6497
  // Wrap a fetch handler: screens the request, then the response (redact/block).
5302
6498
  fetch(handler) {
5303
- const guard = protection.fetchGuard();
5304
6499
  return async (request, ...rest) => {
5305
- const blocked = await guard(request);
6500
+ const { blocked, client } = await screenFetchRequest(request);
5306
6501
  if (blocked) return blocked;
5307
6502
  const response = await handler(request, ...rest);
5308
- return screenResp(response, reqContextFromFetch(request));
6503
+ return screenResp(response, reqContextFromFetch(request, client));
5309
6504
  };
5310
6505
  },
5311
6506
  // Express middleware (request phase; expects express-parsed req.query/req.body).
@@ -5313,11 +6508,12 @@ async function createProtection(options = {}) {
5313
6508
  express(exprOptions = {}) {
5314
6509
  return (req, res, next) => {
5315
6510
  let result;
6511
+ const { shaped, client } = shapeExpressRequest(req);
5316
6512
  try {
5317
- result = engine.evaluate(req);
6513
+ result = engine.evaluate(shaped);
5318
6514
  } catch (err) {
5319
6515
  notify(onError, err, "onError");
5320
- if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
6516
+ if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
5321
6517
  return next();
5322
6518
  }
5323
6519
  decide(
@@ -5331,10 +6527,10 @@ async function createProtection(options = {}) {
5331
6527
  }
5332
6528
  },
5333
6529
  () => {
5334
- if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
6530
+ if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
5335
6531
  next();
5336
6532
  },
5337
- nodeRequestMeta(req)
6533
+ nodeRequestMeta(req, client)
5338
6534
  );
5339
6535
  };
5340
6536
  },
@@ -5373,7 +6569,7 @@ async function createProtection(options = {}) {
5373
6569
  let shaped;
5374
6570
  let result;
5375
6571
  try {
5376
- shaped = fromNodeRequest(req, rawBody);
6572
+ shaped = fromNodeRequest(req, rawBody, { trustedProxy: options.trustedProxy });
5377
6573
  if (parsedBody !== void 0 && parsedBody !== null) shaped.body = parsedBody;
5378
6574
  result = engine.evaluate(shaped);
5379
6575
  } catch (err) {
@@ -5395,10 +6591,10 @@ async function createProtection(options = {}) {
5395
6591
  },
5396
6592
  () => {
5397
6593
  if (req.body === void 0) req.body = shaped.body;
5398
- if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
6594
+ if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, shaped?._clientIp));
5399
6595
  next();
5400
6596
  },
5401
- nodeRequestMeta(req)
6597
+ nodeRequestMeta(req, shaped?._clientIp)
5402
6598
  );
5403
6599
  }
5404
6600
  }
@@ -5434,9 +6630,15 @@ async function createProtection(options = {}) {
5434
6630
  notify(onError, err, "onError");
5435
6631
  }
5436
6632
  }
5437
- const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
6633
+ const declaredState = stateFor(currentOrigin).state;
6634
+ const next = await resolveRules(options, store, {
6635
+ timeoutMs: options.refreshTimeoutMs,
6636
+ pulseAuth,
6637
+ detectionState: declaredState
6638
+ });
5438
6639
  mode = resolveMode(options, next);
5439
6640
  applyBundle(next);
6641
+ await applyAndAcknowledge(next.source?.origin, declaredState);
5440
6642
  if (detections) detections.setRulesEtag((await store.read())?.etag ?? null);
5441
6643
  return next.source ?? { ok: true };
5442
6644
  };
@@ -5447,12 +6649,20 @@ async function createProtection(options = {}) {
5447
6649
  const loop = options.refreshMs > 0 && live ? startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError }) : null;
5448
6650
  protection.stop = () => {
5449
6651
  loop?.stop();
5450
- firewallLog?.stop();
5451
- detections?.stop();
6652
+ const outstanding = [firewallLog?.stop(), detections?.stop()].filter(
6653
+ (wait) => wait && typeof wait.then === "function"
6654
+ );
6655
+ return Promise.all(outstanding).then(() => void 0);
5452
6656
  };
5453
6657
  protection.stopRefresh = protection.stop;
5454
- protection.detectionReporting = detectionReporting;
5455
- if (detections) protection.detectionHealth = () => detections.health();
6658
+ Object.defineProperty(protection, "detectionReporting", {
6659
+ get: () => detectionReporting,
6660
+ enumerable: true
6661
+ });
6662
+ Object.defineProperty(protection, "detectionHealth", {
6663
+ get: () => detections ? () => detections.health() : void 0,
6664
+ enumerable: true
6665
+ });
5456
6666
  return protection;
5457
6667
  }
5458
6668
  function resolveMode(options, bundle) {
@@ -5858,30 +7068,48 @@ function defaultOnDetect({ phase, mode, category, rule, message }) {
5858
7068
  const tag = mode === "block" ? "BLOCK" : "DETECT (dry-run)";
5859
7069
  console.warn(`[patchstack] ${tag} phase=${phase ?? "request"} category=${category ?? "?"} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
5860
7070
  }
5861
- function fetchRequestMeta(request) {
5862
- if (!request) return {};
5863
- let path7 = null;
5864
- try {
5865
- path7 = new URL(request.url).pathname;
5866
- } catch {
5867
- path7 = typeof request.url === "string" ? request.url : null;
5868
- }
7071
+ function requestMetaFromContext(reqCtx) {
7072
+ if (!reqCtx) return {};
7073
+ const client = reqCtx._clientIp ?? { ip: null, source: "unavailable" };
5869
7074
  return {
5870
- method: request.method ?? null,
5871
- path: path7,
5872
- ip: request.headers?.get?.("x-forwarded-for") ?? null,
5873
- userAgent: request.headers?.get?.("user-agent") ?? null
7075
+ method: reqCtx.method ?? null,
7076
+ // Path AND query. The reporter is what drops the query's VALUES, keeping its parameter names, so
7077
+ // trimming it here would leave a Fetch or response detection unable to say what was requested.
7078
+ path: typeof reqCtx.originalUrl === "string" ? reqCtx.originalUrl : null,
7079
+ ip: client.ip,
7080
+ clientIpSource: client.source,
7081
+ userAgent: reqCtx.headers?.["user-agent"] ?? null
5874
7082
  };
5875
7083
  }
5876
- function nodeRequestMeta(req) {
7084
+ function requestMeta(shaped, request) {
7085
+ const client = shaped?._clientIp ?? { ip: null, source: "unavailable" };
7086
+ let path7 = null;
7087
+ let method = null;
7088
+ let userAgent = null;
7089
+ if (request) {
7090
+ try {
7091
+ const u = new URL(request.url);
7092
+ path7 = u.pathname + u.search;
7093
+ } catch {
7094
+ path7 = typeof request.url === "string" ? request.url : null;
7095
+ }
7096
+ method = request.method ?? null;
7097
+ userAgent = request.headers?.get?.("user-agent") ?? null;
7098
+ }
7099
+ return { method, path: path7, ip: client.ip, clientIpSource: client.source, userAgent };
7100
+ }
7101
+ function nodeRequestMeta(req, client) {
5877
7102
  if (!req) return {};
5878
7103
  const headers = req.headers ?? {};
5879
7104
  const ua = headers["user-agent"] ?? headers["User-Agent"];
5880
- const fwd = headers["x-forwarded-for"] ?? headers["X-Forwarded-For"];
7105
+ const resolved = client ?? { ip: null, source: "unavailable" };
5881
7106
  return {
5882
7107
  method: req.method ?? null,
5883
7108
  path: req.originalUrl || req.url || null,
5884
- ip: req.ip ?? (typeof fwd === "string" ? fwd : null),
7109
+ // Never `req.ip`: under Express's `trust proxy` that is header-derived by a policy this guard has not
7110
+ // verified, and a second derivation could disagree with the one the engine evaluated.
7111
+ ip: resolved.ip,
7112
+ clientIpSource: resolved.source,
5885
7113
  userAgent: typeof ua === "string" ? ua : Array.isArray(ua) ? ua[0] : null
5886
7114
  };
5887
7115
  }