@patchstack/connect 0.5.15 → 0.5.16

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
@@ -2033,7 +2033,7 @@ async function scanLockfile(cwd) {
2033
2033
  await detectLockfile(cwd);
2034
2034
  }
2035
2035
  const declared = await readDeclaredDependencyNames(cwd);
2036
- const warnings = [];
2036
+ const warnings2 = [];
2037
2037
  let firstParsed = null;
2038
2038
  let walked = null;
2039
2039
  let walkWasCandidate = false;
@@ -2047,7 +2047,7 @@ async function scanLockfile(cwd) {
2047
2047
  continue;
2048
2048
  }
2049
2049
  const unreadable = unreadableWarning(candidate.filename, report);
2050
- if (unreadable !== null) warnings.push(unreadable);
2050
+ if (unreadable !== null) warnings2.push(unreadable);
2051
2051
  if (candidate.strategy === "node-modules-walk") {
2052
2052
  walked = packages;
2053
2053
  walkWasCandidate = true;
@@ -2058,16 +2058,16 @@ async function scanLockfile(cwd) {
2058
2058
  for (const source of parsedSources) {
2059
2059
  const missing = missingDependencies(declared, source.packages);
2060
2060
  if (missing.length > 0) {
2061
- warnings.push(staleWarning(source.filename, missing));
2061
+ warnings2.push(staleWarning(source.filename, missing));
2062
2062
  continue;
2063
2063
  }
2064
2064
  const conflicts = otherCompleteSources(parsedSources, source, declared).flatMap(
2065
2065
  (other) => disagreements(source.packages, other.packages).map((conflict) => ({ other: other.filename, conflict }))
2066
2066
  );
2067
2067
  if (conflicts.length === 0) {
2068
- return manifestWith(source.packages, warnings);
2068
+ return manifestWith(source.packages, warnings2);
2069
2069
  }
2070
- warnings.push(conflictWarning(source.filename, conflicts));
2070
+ warnings2.push(conflictWarning(source.filename, conflicts));
2071
2071
  break;
2072
2072
  }
2073
2073
  if (walked === null) {
@@ -2080,12 +2080,12 @@ async function scanLockfile(cwd) {
2080
2080
  if (walked !== null) {
2081
2081
  const missing = missingDependencies(declared, walked);
2082
2082
  if (missing.length > 0) {
2083
- warnings.push(staleWarning("node_modules/", missing));
2083
+ warnings2.push(staleWarning("node_modules/", missing));
2084
2084
  }
2085
- warnings.push(
2085
+ warnings2.push(
2086
2086
  walkWasCandidate ? "Checked the installed packages in node_modules/, which is what the build loads." : "Checked the installed packages in node_modules/ instead. Delete the out-of-date package list file to stop this warning."
2087
2087
  );
2088
- return manifestWith(walked, warnings);
2088
+ return manifestWith(walked, warnings2);
2089
2089
  }
2090
2090
  if (firstParsed === null) {
2091
2091
  const expected = LOCKFILE_CANDIDATES.map((candidate) => candidate.filename).join(", ");
@@ -2094,13 +2094,13 @@ async function scanLockfile(cwd) {
2094
2094
  "LOCKFILE_NOT_FOUND"
2095
2095
  );
2096
2096
  }
2097
- warnings.push(
2097
+ warnings2.push(
2098
2098
  `No package list matches package.json; checked ${firstParsed.filename}, so some packages may be missing. Reinstall your packages to fix this.`
2099
2099
  );
2100
- return manifestWith(firstParsed.packages, warnings);
2100
+ return manifestWith(firstParsed.packages, warnings2);
2101
2101
  }
2102
- function manifestWith(packages, warnings) {
2103
- return warnings.length > 0 ? { ecosystem: "npm", packages, warnings } : { ecosystem: "npm", packages };
2102
+ function manifestWith(packages, warnings2) {
2103
+ return warnings2.length > 0 ? { ecosystem: "npm", packages, warnings: warnings2 } : { ecosystem: "npm", packages };
2104
2104
  }
2105
2105
  function otherCompleteSources(all, current, declared) {
2106
2106
  return all.filter(
@@ -2725,18 +2725,124 @@ function appendOwn(target, key, value) {
2725
2725
  return setOwn(target, key, Array.isArray(previous) ? [...previous, value] : [previous, value]);
2726
2726
  }
2727
2727
 
2728
- // src/protect/engine/request.js
2729
- var FILE_ATTRS = /* @__PURE__ */ new Set(["content", "filename", "type"]);
2730
- var fileFilename = (f) => f && typeof f === "object" ? f.filename : f;
2731
- var fileAttribute = (f, attr) => f && typeof f === "object" ? f[attr] : attr === "filename" ? f : void 0;
2732
- function base64DecodeUtf8(value) {
2733
- const str = String(value);
2734
- if (typeof Buffer !== "undefined") {
2735
- return Buffer.from(str, "base64").toString("utf-8");
2728
+ // src/protect/engine/cookies.js
2729
+ function parseCookieHeader(header) {
2730
+ const cookies = {};
2731
+ if (typeof header !== "string" || header === "") return cookies;
2732
+ const repeated = /* @__PURE__ */ new Map();
2733
+ for (const pair of header.split(";")) {
2734
+ const eq = pair.indexOf("=");
2735
+ if (eq === -1) continue;
2736
+ const name = pair.slice(0, eq).trim();
2737
+ if (name === "") continue;
2738
+ let value = pair.slice(eq + 1).trim();
2739
+ if (value.length >= 2 && value[0] === '"' && value[value.length - 1] === '"') value = value.slice(1, -1);
2740
+ if (!Object.hasOwn(cookies, name)) {
2741
+ setOwn(cookies, name, value);
2742
+ } else {
2743
+ let values = repeated.get(name);
2744
+ if (values === void 0) {
2745
+ values = [cookies[name]];
2746
+ repeated.set(name, values);
2747
+ setOwn(cookies, name, values);
2748
+ }
2749
+ values.push(value);
2750
+ }
2736
2751
  }
2737
- const binary = atob(str);
2738
- const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
2739
- return new TextDecoder().decode(bytes);
2752
+ return cookies;
2753
+ }
2754
+
2755
+ // src/protect/engine/normalizer.js
2756
+ var HTML_ENTITIES = {
2757
+ "&": "&",
2758
+ "&lt;": "<",
2759
+ "&gt;": ">",
2760
+ "&quot;": '"',
2761
+ "&#39;": "'",
2762
+ "&apos;": "'",
2763
+ "&nbsp;": " ",
2764
+ "&#x27;": "'",
2765
+ "&#x22;": '"',
2766
+ "&#x3C;": "<",
2767
+ "&#x3E;": ">",
2768
+ "&#x26;": "&",
2769
+ "&#34;": '"',
2770
+ "&#60;": "<",
2771
+ "&#62;": ">",
2772
+ "&#38;": "&"
2773
+ };
2774
+ var MAX_DECODE_ITERATIONS = 5;
2775
+ function normalize(value, options = {}) {
2776
+ if (typeof value !== "string") {
2777
+ return value;
2778
+ }
2779
+ const opts = {
2780
+ urlDecode: true,
2781
+ htmlDecode: true,
2782
+ sqlComments: true,
2783
+ nullBytes: true,
2784
+ whitespace: true,
2785
+ ...options
2786
+ };
2787
+ let result = value;
2788
+ if (opts.nullBytes) {
2789
+ result = removeNullBytes(result);
2790
+ }
2791
+ if (opts.urlDecode) {
2792
+ result = urlDecode(result);
2793
+ }
2794
+ if (opts.htmlDecode) {
2795
+ result = htmlEntityDecode(result);
2796
+ }
2797
+ if (opts.sqlComments) {
2798
+ result = removeSqlComments(result);
2799
+ }
2800
+ if (opts.whitespace) {
2801
+ result = normalizeWhitespace(result);
2802
+ }
2803
+ return result;
2804
+ }
2805
+ function urlDecode(value) {
2806
+ if (typeof value !== "string") {
2807
+ return value;
2808
+ }
2809
+ let result = value;
2810
+ let previous = "";
2811
+ let iterations = 0;
2812
+ while (result !== previous && iterations < MAX_DECODE_ITERATIONS) {
2813
+ previous = result;
2814
+ iterations++;
2815
+ result = safeUrlDecode(result);
2816
+ }
2817
+ return result;
2818
+ }
2819
+ var utf8 = new TextDecoder();
2820
+ function safeUrlDecode(value) {
2821
+ return value.replace(/(?:%[0-9A-Fa-f]{2})+/g, (run) => {
2822
+ try {
2823
+ return decodeURIComponent(run);
2824
+ } catch {
2825
+ const bytes = new Uint8Array(run.length / 3);
2826
+ for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(run.slice(i * 3 + 1, i * 3 + 3), 16);
2827
+ return utf8.decode(bytes);
2828
+ }
2829
+ });
2830
+ }
2831
+ function decodeQueryPlus(target) {
2832
+ if (typeof target !== "string") return target;
2833
+ const query = target.indexOf("?");
2834
+ if (query === -1) return target;
2835
+ return target.slice(0, query + 1) + target.slice(query + 1).replace(/\+/g, " ");
2836
+ }
2837
+ function htmlEntityDecode(value) {
2838
+ if (typeof value !== "string") {
2839
+ return value;
2840
+ }
2841
+ let result = value;
2842
+ for (const [entity, char] of Object.entries(HTML_ENTITIES)) {
2843
+ result = result.split(entity).join(char);
2844
+ }
2845
+ return decodeHtmlEntities(result);
2740
2846
  }
2741
2847
  var NAMED_ENTITIES = {
2742
2848
  lt: "<",
@@ -2744,7 +2850,6 @@ var NAMED_ENTITIES = {
2744
2850
  amp: "&",
2745
2851
  quot: '"',
2746
2852
  apos: "'",
2747
- "#39": "'",
2748
2853
  nbsp: "\xA0",
2749
2854
  sol: "/",
2750
2855
  bsol: "\\",
@@ -2763,7 +2868,7 @@ var NAMED_ENTITIES = {
2763
2868
  ast: "*"
2764
2869
  };
2765
2870
  function decodeHtmlEntities(input) {
2766
- return input.replace(/&(#[xX]?[0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);?/g, (whole, body) => {
2871
+ return input.replace(/&(#[xX][0-9a-fA-F]+|#\d+|[A-Za-z][A-Za-z0-9]*);?/g, (whole, body) => {
2767
2872
  if (body[0] === "#") {
2768
2873
  const hex = body[1] === "x" || body[1] === "X";
2769
2874
  const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
@@ -2777,74 +2882,359 @@ function decodeHtmlEntities(input) {
2777
2882
  return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, body) ? NAMED_ENTITIES[body] : whole;
2778
2883
  });
2779
2884
  }
2780
- var RequestResolver = class {
2781
- #req;
2782
- #cookies;
2783
- constructor(req) {
2784
- this.#req = req;
2785
- this.#cookies = null;
2885
+ function removeSqlComments(value) {
2886
+ if (typeof value !== "string") {
2887
+ return value;
2786
2888
  }
2787
- resolve(parameter) {
2788
- if (!parameter || parameter === "false") {
2789
- return [null];
2889
+ let result = value;
2890
+ result = result.replace(/\/\*[\s\S]*?\*\//g, " ");
2891
+ result = result.replace(/\/\*![\s\S]*?\*\//g, " ");
2892
+ return result;
2893
+ }
2894
+ function removeNullBytes(value) {
2895
+ if (typeof value !== "string") {
2896
+ return value;
2897
+ }
2898
+ let result = value.replace(/\x00/g, "");
2899
+ result = result.replace(/[\x01-\x08\x0B\x0C\x0E-\x1F]/g, "");
2900
+ return result;
2901
+ }
2902
+ function normalizeWhitespace(value) {
2903
+ if (typeof value !== "string") {
2904
+ return value;
2905
+ }
2906
+ let result = value.replace(/[\t\r\n\f\v]+/g, " ");
2907
+ result = result.replace(/ {2,}/g, " ");
2908
+ return result;
2909
+ }
2910
+ var MAX_RAW_CHARS = 1024 * 1024;
2911
+ var MAX_RAW_NODES = 25e3;
2912
+ var MAX_RAW_DEPTH = 200;
2913
+ var MAX_RAW_ENTRIES = 2e3;
2914
+ function serializeForRawDetection(body) {
2915
+ if (body === null || body === void 0) return "";
2916
+ if (typeof body === "string") return body.slice(0, MAX_RAW_CHARS);
2917
+ const visited = /* @__PURE__ */ new Set();
2918
+ const output = [];
2919
+ const stack = [{ kind: "value", value: body, depth: 0, root: true }];
2920
+ let chars = 0;
2921
+ let nodes = 0;
2922
+ const append = (text) => {
2923
+ if (chars >= MAX_RAW_CHARS) return;
2924
+ const piece = String(text).slice(0, MAX_RAW_CHARS - chars);
2925
+ output.push(piece);
2926
+ chars += piece.length;
2927
+ };
2928
+ while (stack.length > 0 && chars < MAX_RAW_CHARS) {
2929
+ const task = stack.pop();
2930
+ if (task.kind === "text") {
2931
+ append(task.text);
2932
+ continue;
2790
2933
  }
2791
- if (parameter === "rules") {
2792
- return [null];
2934
+ if (task.kind === "key") {
2935
+ append(boundedJsonString(task.value));
2936
+ append(":");
2937
+ continue;
2793
2938
  }
2794
- if (parameter === "raw") {
2795
- return this.#resolveRaw();
2939
+ nodes++;
2940
+ if (nodes > MAX_RAW_NODES) {
2941
+ append('"[NodeLimit]"');
2942
+ continue;
2796
2943
  }
2797
- if (parameter === "all") {
2798
- return this.#resolveAll();
2944
+ const value = task.value;
2945
+ if (value === null || value === void 0) {
2946
+ append(task.root ? "" : "null");
2947
+ continue;
2799
2948
  }
2800
- const dotIndex = parameter.indexOf(".");
2801
- if (dotIndex === -1) {
2802
- return [];
2949
+ if (typeof value === "string") {
2950
+ append(task.root ? value.slice(0, MAX_RAW_CHARS) : boundedJsonString(value));
2951
+ continue;
2803
2952
  }
2804
- const source = parameter.substring(0, dotIndex);
2805
- const key = parameter.substring(dotIndex + 1);
2806
- switch (source) {
2807
- case "get":
2808
- return this.#resolveGet(key);
2809
- case "post":
2810
- return this.#resolvePost(key);
2811
- case "request":
2812
- return this.#resolveRequest(key);
2813
- case "cookie":
2814
- return this.#resolveCookie(key);
2815
- case "server":
2816
- return this.#resolveServer(key);
2817
- case "files":
2818
- return this.#resolveFiles(key);
2819
- case "response":
2820
- return this.#resolveResponse(key);
2821
- case "egress":
2822
- return this.#resolveEgress(key);
2823
- default:
2824
- return [];
2953
+ if (typeof value !== "object") {
2954
+ append(String(value).slice(0, MAX_RAW_CHARS));
2955
+ continue;
2825
2956
  }
2826
- }
2827
- // Response-phase sources (req._response = { status, headers, body }). Lets rules inspect
2828
- // what the app is about to SEND — e.g. a leaked secret in the body — regardless of route.
2829
- #resolveResponse(key) {
2830
- const resp = this.#req._response;
2831
- if (!resp) {
2832
- return [];
2957
+ if (visited.has(value)) {
2958
+ append("[Circular]");
2959
+ continue;
2833
2960
  }
2834
- if (key === "status") {
2835
- return resp.status !== void 0 ? [String(resp.status)] : [];
2961
+ if (task.depth >= MAX_RAW_DEPTH) {
2962
+ append('"[DepthLimit]"');
2963
+ continue;
2836
2964
  }
2837
- if (key === "body") {
2838
- return resp.body != null && resp.body !== "" ? [resp.body] : [];
2965
+ visited.add(value);
2966
+ if (Array.isArray(value)) {
2967
+ const length2 = Math.min(value.length, MAX_RAW_ENTRIES);
2968
+ const items = [];
2969
+ for (let i = 0; i < length2; i++) {
2970
+ if (i > 0) items.push({ kind: "text", text: "," });
2971
+ items.push({ kind: "value", value: readOwnValue(value, String(i)), depth: task.depth + 1, root: false });
2972
+ }
2973
+ if (value.length > length2) {
2974
+ if (length2 > 0) items.push({ kind: "text", text: "," });
2975
+ items.push({ kind: "text", text: '"[EntryLimit]"' });
2976
+ }
2977
+ stack.push({ kind: "text", text: "]" });
2978
+ for (let i = items.length - 1; i >= 0; i--) stack.push(items[i]);
2979
+ stack.push({ kind: "text", text: "[" });
2980
+ continue;
2839
2981
  }
2840
- if (key === "headers") {
2841
- const headers = resp.headers ?? {};
2842
- return [Object.entries(headers).map(([k, v]) => `${k}: ${v}`).join("\n")];
2982
+ let keys;
2983
+ try {
2984
+ keys = Object.getOwnPropertyNames(value);
2985
+ } catch {
2986
+ append('"[Unreadable]"');
2987
+ continue;
2843
2988
  }
2844
- if (key.startsWith("header.")) {
2845
- const name = key.slice("header.".length).toLowerCase();
2846
- const headers = resp.headers ?? {};
2847
- return Object.hasOwn(headers, name) ? [headers[name]] : [];
2989
+ const length = Math.min(keys.length, MAX_RAW_ENTRIES);
2990
+ const entries = [];
2991
+ for (let i = 0; i < length; i++) {
2992
+ const key = keys[i];
2993
+ if (i > 0) entries.push({ kind: "text", text: "," });
2994
+ entries.push({ kind: "key", value: key });
2995
+ entries.push({ kind: "value", value: readOwnValue(value, key), depth: task.depth + 1, root: false });
2996
+ }
2997
+ if (keys.length > length) {
2998
+ if (length > 0) entries.push({ kind: "text", text: "," });
2999
+ entries.push({ kind: "text", text: '"[EntryLimit]":null' });
3000
+ }
3001
+ stack.push({ kind: "text", text: "}" });
3002
+ for (let i = entries.length - 1; i >= 0; i--) stack.push(entries[i]);
3003
+ stack.push({ kind: "text", text: "{" });
3004
+ }
3005
+ return output.join("");
3006
+ }
3007
+ function boundedJsonString(value) {
3008
+ return JSON.stringify(String(value).slice(0, MAX_RAW_CHARS));
3009
+ }
3010
+ function readOwnValue(value, key) {
3011
+ try {
3012
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
3013
+ return descriptor && Object.hasOwn(descriptor, "value") ? descriptor.value : "[Accessor]";
3014
+ } catch {
3015
+ return "[Unreadable]";
3016
+ }
3017
+ }
3018
+ var INHERITED_ACCESSORS = /* @__PURE__ */ new Set(["headers", "query"]);
3019
+ function requestField(req, key) {
3020
+ if (req === null || typeof req !== "object") return void 0;
3021
+ if (Object.hasOwn(req, key)) return req[key];
3022
+ if (!INHERITED_ACCESSORS.has(key)) return void 0;
3023
+ let holder = Object.getPrototypeOf(req);
3024
+ while (holder !== null && !Object.hasOwn(holder, key)) holder = Object.getPrototypeOf(holder);
3025
+ if (holder === null || holder === Object.prototype) return void 0;
3026
+ const descriptor = Object.getOwnPropertyDescriptor(holder, key);
3027
+ if (typeof descriptor?.get !== "function") return void 0;
3028
+ return req[key];
3029
+ }
3030
+ function normalizeRequest(req, options = {}) {
3031
+ const ownRaw = Object.hasOwn(req ?? {}, "_rawBody") && typeof req._rawBody === "string";
3032
+ const body = requestField(req, "body");
3033
+ const url = requestField(req, "url");
3034
+ const rawBody = ownRaw ? req._rawBody : serializeForRawDetection(body ?? null);
3035
+ const headers = requestField(req, "headers") || {};
3036
+ return {
3037
+ query: normalizeObject(requestField(req, "query") || {}, options),
3038
+ body: normalizeObject(body || {}, options),
3039
+ headers: normalizeObject(headers, options),
3040
+ // Cookies are split into name/value pairs BEFORE their values are normalised, and normalised the
3041
+ // same way whether a framework parsed them or they come from the header — so a rule on a cookie
3042
+ // sees one value, independent of which cookie parser (if any) ran first.
3043
+ cookies: normalizeObject(cookieValues(requestField(req, "cookies"), headers), options),
3044
+ url: normalize(decodeQueryPlus(url || ""), options),
3045
+ originalUrl: normalize(decodeQueryPlus(requestField(req, "originalUrl") || url || ""), options),
3046
+ _rawBody: rawBody
3047
+ };
3048
+ }
3049
+ function cookieValues(parsed, headers) {
3050
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
3051
+ return parseCookieHeader(headers?.cookie);
3052
+ }
3053
+ var MAX_NORMALIZE_DEPTH = 1e3;
3054
+ function normalizeObject(value, options = {}, depth = 0) {
3055
+ if (typeof value === "string") {
3056
+ return normalize(value, options);
3057
+ }
3058
+ if (value === null || typeof value !== "object") {
3059
+ return value;
3060
+ }
3061
+ if (depth >= MAX_NORMALIZE_DEPTH) {
3062
+ options.onLimit?.();
3063
+ return value;
3064
+ }
3065
+ const copies = /* @__PURE__ */ new Map();
3066
+ const copyOf = (node) => {
3067
+ const copy = Array.isArray(node) ? new Array(node.length) : {};
3068
+ copies.set(node, copy);
3069
+ return copy;
3070
+ };
3071
+ const top = copyOf(value);
3072
+ const stack = [[value, top, depth]];
3073
+ while (stack.length > 0) {
3074
+ const [node, copy, level] = stack.pop();
3075
+ for (const key of Object.keys(node)) {
3076
+ const child = node[key];
3077
+ if (typeof child === "string") {
3078
+ setOwn(copy, key, normalize(child, options));
3079
+ } else if (child === null || typeof child !== "object") {
3080
+ setOwn(copy, key, child);
3081
+ } else if (copies.has(child)) {
3082
+ setOwn(copy, key, copies.get(child));
3083
+ } else if (level + 1 >= MAX_NORMALIZE_DEPTH) {
3084
+ setOwn(copy, key, child);
3085
+ options.onLimit?.();
3086
+ } else {
3087
+ const childCopy = copyOf(child);
3088
+ setOwn(copy, key, childCopy);
3089
+ stack.push([child, childCopy, level + 1]);
3090
+ }
3091
+ }
3092
+ }
3093
+ return top;
3094
+ }
3095
+
3096
+ // src/protect/engine/request.js
3097
+ var FILE_ATTRS = /* @__PURE__ */ new Set(["content", "filename", "type"]);
3098
+ var ownValue = (obj, key) => obj !== null && typeof obj === "object" && Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : void 0;
3099
+ function fieldSpellings(key) {
3100
+ const spellings = [{ key, nested: true }];
3101
+ if (key.includes("[")) {
3102
+ const dotted = key.replace(/\[\]$/, "").replace(/\[([^\][]*)\]/g, ".$1");
3103
+ if (dotted !== key && dotted !== "" && !dotted.includes("[") && !dotted.includes("]")) {
3104
+ spellings.push({ key: dotted, nested: true });
3105
+ }
3106
+ return spellings;
3107
+ }
3108
+ const [head, ...rest] = key.split(".");
3109
+ const bracketed = head + rest.map((part) => `[${part}]`).join("");
3110
+ if (bracketed !== key) spellings.push({ key: bracketed, nested: false });
3111
+ spellings.push({ key: `${bracketed}[]`, nested: false });
3112
+ return spellings;
3113
+ }
3114
+ var fileFilename = (f) => f && typeof f === "object" ? f.filename : f;
3115
+ var fileAttribute = (f, attr) => f && typeof f === "object" ? f[attr] : attr === "filename" ? f : void 0;
3116
+ function base64DecodeUtf8(value) {
3117
+ const str = String(value);
3118
+ if (typeof Buffer !== "undefined") {
3119
+ return Buffer.from(str, "base64").toString("utf-8");
3120
+ }
3121
+ const binary = atob(str);
3122
+ const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
3123
+ return new TextDecoder().decode(bytes);
3124
+ }
3125
+ var TEXT_MUTATIONS = /* @__PURE__ */ new Set(["base64_decode", "urldecode", "htmlentitydecode"]);
3126
+ var MAX_MAPPED_NODES = 2e4;
3127
+ var MAX_MAPPED_DEPTH = 1e3;
3128
+ function mapStringLeaves(root, fn, onLimit) {
3129
+ const copies = /* @__PURE__ */ new Map();
3130
+ const copyOf = (node) => {
3131
+ const copy = Array.isArray(node) ? [] : {};
3132
+ copies.set(node, copy);
3133
+ return copy;
3134
+ };
3135
+ const top = copyOf(root);
3136
+ const stack = [[root, top, 0]];
3137
+ let visited = 0;
3138
+ while (stack.length) {
3139
+ const [node, copy, depth] = stack.pop();
3140
+ visited++;
3141
+ for (const key of Object.keys(node)) {
3142
+ const child = node[key];
3143
+ if (typeof child === "string") {
3144
+ setOwn(copy, key, fn(child));
3145
+ } else if (child === null || typeof child !== "object") {
3146
+ setOwn(copy, key, child);
3147
+ } else if (copies.has(child)) {
3148
+ setOwn(copy, key, copies.get(child));
3149
+ } else if (depth + 1 >= MAX_MAPPED_DEPTH || visited + stack.length >= MAX_MAPPED_NODES) {
3150
+ setOwn(copy, key, child);
3151
+ onLimit();
3152
+ } else {
3153
+ const childCopy = copyOf(child);
3154
+ setOwn(copy, key, childCopy);
3155
+ stack.push([child, childCopy, depth + 1]);
3156
+ }
3157
+ }
3158
+ }
3159
+ return top;
3160
+ }
3161
+ var RequestResolver = class {
3162
+ #req;
3163
+ #cookies;
3164
+ #skips = /* @__PURE__ */ new Set();
3165
+ constructor(req) {
3166
+ this.#req = req;
3167
+ this.#cookies = null;
3168
+ }
3169
+ /** Record an inspection limit this evaluation reached (reported once per reason). */
3170
+ noteSkip(reason) {
3171
+ this.#skips.add(reason);
3172
+ }
3173
+ /** The inspection limits recorded so far, in the order first reached. */
3174
+ get skips() {
3175
+ return [...this.#skips];
3176
+ }
3177
+ resolve(parameter) {
3178
+ if (!parameter || parameter === "false") {
3179
+ return [null];
3180
+ }
3181
+ if (parameter === "rules") {
3182
+ return [null];
3183
+ }
3184
+ if (parameter === "raw") {
3185
+ return this.#resolveRaw();
3186
+ }
3187
+ if (parameter === "all") {
3188
+ return this.#resolveAll();
3189
+ }
3190
+ const dotIndex = parameter.indexOf(".");
3191
+ if (dotIndex === -1) {
3192
+ return [];
3193
+ }
3194
+ const source = parameter.substring(0, dotIndex);
3195
+ const key = parameter.substring(dotIndex + 1);
3196
+ switch (source) {
3197
+ case "get":
3198
+ return this.#resolveGet(key);
3199
+ case "post":
3200
+ return this.#resolvePost(key);
3201
+ case "request":
3202
+ return this.#resolveRequest(key);
3203
+ case "cookie":
3204
+ return this.#resolveCookie(key);
3205
+ case "server":
3206
+ return this.#resolveServer(key);
3207
+ case "files":
3208
+ return this.#resolveFiles(key);
3209
+ case "response":
3210
+ return this.#resolveResponse(key);
3211
+ case "egress":
3212
+ return this.#resolveEgress(key);
3213
+ default:
3214
+ return [];
3215
+ }
3216
+ }
3217
+ // Response-phase sources (req._response = { status, headers, body }). Lets rules inspect
3218
+ // what the app is about to SEND — e.g. a leaked secret in the body — regardless of route.
3219
+ #resolveResponse(key) {
3220
+ const resp = this.#req._response;
3221
+ if (!resp) {
3222
+ return [];
3223
+ }
3224
+ if (key === "status") {
3225
+ return resp.status !== void 0 ? [String(resp.status)] : [];
3226
+ }
3227
+ if (key === "body") {
3228
+ return resp.body != null && resp.body !== "" ? [resp.body] : [];
3229
+ }
3230
+ if (key === "headers") {
3231
+ const headers = resp.headers ?? {};
3232
+ return [Object.entries(headers).map(([k, v]) => `${k}: ${v}`).join("\n")];
3233
+ }
3234
+ if (key.startsWith("header.")) {
3235
+ const name = key.slice("header.".length).toLowerCase();
3236
+ const headers = resp.headers ?? {};
3237
+ return Object.hasOwn(headers, name) ? [headers[name]] : [];
2848
3238
  }
2849
3239
  return [];
2850
3240
  }
@@ -2874,6 +3264,9 @@ var RequestResolver = class {
2874
3264
  if (value === null || value === void 0) {
2875
3265
  return value;
2876
3266
  }
3267
+ if (typeof value === "object" && TEXT_MUTATIONS.has(mutation)) {
3268
+ return mapStringLeaves(value, (leaf) => this.#applyMutation(mutation, leaf), () => this.noteSkip("container-cap"));
3269
+ }
2877
3270
  switch (mutation) {
2878
3271
  case "base64_decode":
2879
3272
  try {
@@ -2894,11 +3287,7 @@ var RequestResolver = class {
2894
3287
  return value;
2895
3288
  }
2896
3289
  case "urldecode":
2897
- try {
2898
- return decodeURIComponent(String(value));
2899
- } catch {
2900
- return value;
2901
- }
3290
+ return safeUrlDecode(String(value).replace(/\+/g, " "));
2902
3291
  case "htmlentitydecode":
2903
3292
  return decodeHtmlEntities(String(value));
2904
3293
  case "intval":
@@ -2917,16 +3306,30 @@ var RequestResolver = class {
2917
3306
  if (key.endsWith("*")) {
2918
3307
  return this.#resolveWildcard(query, key);
2919
3308
  }
2920
- const value = this.#getNestedValue(query, key);
2921
- return value !== void 0 ? [value] : [];
3309
+ return this.#lookup(query, key);
2922
3310
  }
2923
3311
  #resolvePost(key) {
2924
3312
  const body = this.#req.body ?? {};
2925
3313
  if (key.endsWith("*")) {
2926
3314
  return this.#resolveWildcard(body, key);
2927
3315
  }
2928
- const value = this.#getNestedValue(body, key);
2929
- return value !== void 0 ? [value] : [];
3316
+ return this.#lookup(body, key);
3317
+ }
3318
+ /**
3319
+ * Every value a field path names in `obj`, in either spelling a form or query field can take.
3320
+ *
3321
+ * A parser that expands brackets (`qs`, as Express uses) turns `user[name]=x` into `{ user: { name } }`
3322
+ * and `id[]=x` into `{ id: [x] }`; a flat parser (`URLSearchParams`, as the Fetch and Node adapters use)
3323
+ * keeps `user[name]` and `id[]` as literal keys. The rule names the field once, so both shapes answer
3324
+ * to both spellings: `user.name` and `user[name]`, `id` and `id[]`.
3325
+ */
3326
+ #lookup(obj, key) {
3327
+ const values = [];
3328
+ for (const candidate of fieldSpellings(key)) {
3329
+ const value = candidate.nested ? this.#getNestedValue(obj, candidate.key) : ownValue(obj, candidate.key);
3330
+ if (value !== void 0) values.push(value);
3331
+ }
3332
+ return values;
2930
3333
  }
2931
3334
  #resolveRequest(key) {
2932
3335
  const query = this.#req.query ?? {};
@@ -2939,8 +3342,11 @@ var RequestResolver = class {
2939
3342
  ...this.#resolveWildcard(cookies, key)
2940
3343
  ];
2941
3344
  }
2942
- const value = this.#getNestedValue(query, key) ?? this.#getNestedValue(body, key) ?? (Object.hasOwn(cookies, key) ? cookies[key] : void 0);
2943
- return value !== void 0 ? [value] : [];
3345
+ const fromQuery = this.#lookup(query, key);
3346
+ if (fromQuery.length > 0) return fromQuery;
3347
+ const fromBody = this.#lookup(body, key);
3348
+ if (fromBody.length > 0) return fromBody;
3349
+ return Object.hasOwn(cookies, key) ? [cookies[key]] : [];
2944
3350
  }
2945
3351
  #resolveCookie(key) {
2946
3352
  const cookies = this.#parseCookies();
@@ -3110,329 +3516,51 @@ var RequestResolver = class {
3110
3516
  this.#cookies = this.#req.cookies;
3111
3517
  return this.#cookies;
3112
3518
  }
3113
- const header = this.#req.headers?.cookie;
3114
- if (!header) {
3115
- this.#cookies = {};
3116
- return this.#cookies;
3117
- }
3118
- const cookies = {};
3119
- for (const pair of header.split(";")) {
3120
- const eqIndex = pair.indexOf("=");
3121
- if (eqIndex === -1) {
3122
- continue;
3123
- }
3124
- const name = pair.substring(0, eqIndex).trim();
3125
- const value = pair.substring(eqIndex + 1).trim();
3126
- setOwn(cookies, name, value);
3127
- }
3128
- this.#cookies = cookies;
3519
+ this.#cookies = parseCookieHeader(this.#req.headers?.cookie);
3129
3520
  return this.#cookies;
3130
3521
  }
3131
3522
  };
3132
3523
 
3133
3524
  // src/protect/notify.js
3134
- var reported = /* @__PURE__ */ new Set();
3525
+ var reported = /* @__PURE__ */ new WeakMap();
3526
+ var warnings = 0;
3527
+ var MAX_WARNINGS = 20;
3135
3528
  function notify(fn, arg, label) {
3136
3529
  if (typeof fn !== "function") return false;
3137
3530
  try {
3138
3531
  const result = fn(arg);
3139
3532
  if (result !== null && typeof result === "object" && typeof result.then === "function") {
3140
3533
  try {
3141
- result.then(void 0, (err) => warnOnce2(label, err));
3534
+ result.then(void 0, (err) => warnOnce2(fn, label, err));
3142
3535
  } catch {
3143
- }
3144
- }
3145
- return true;
3146
- } catch (err) {
3147
- warnOnce2(label, err);
3148
- return false;
3149
- }
3150
- }
3151
- function warnOnce2(label, err) {
3152
- if (reported.has(label)) return;
3153
- reported.add(label);
3154
- try {
3155
- console.warn(
3156
- `Patchstack: the ${label} callback passed to createProtection failed and was ignored. Protection is unaffected; this is reported once per process. Cause: ${err && err.message ? err.message : String(err)}`
3157
- );
3158
- } catch {
3159
- }
3160
- }
3161
-
3162
- // src/protect/engine/normalizer.js
3163
- var HTML_ENTITIES = {
3164
- "&amp;": "&",
3165
- "&lt;": "<",
3166
- "&gt;": ">",
3167
- "&quot;": '"',
3168
- "&#39;": "'",
3169
- "&apos;": "'",
3170
- "&nbsp;": " ",
3171
- "&#x27;": "'",
3172
- "&#x22;": '"',
3173
- "&#x3C;": "<",
3174
- "&#x3E;": ">",
3175
- "&#x26;": "&",
3176
- "&#34;": '"',
3177
- "&#60;": "<",
3178
- "&#62;": ">",
3179
- "&#38;": "&"
3180
- };
3181
- var MAX_DECODE_ITERATIONS = 5;
3182
- function normalize(value, options = {}) {
3183
- if (typeof value !== "string") {
3184
- return value;
3185
- }
3186
- const opts = {
3187
- urlDecode: true,
3188
- htmlDecode: true,
3189
- sqlComments: true,
3190
- nullBytes: true,
3191
- whitespace: true,
3192
- ...options
3193
- };
3194
- let result = value;
3195
- if (opts.nullBytes) {
3196
- result = removeNullBytes(result);
3197
- }
3198
- if (opts.urlDecode) {
3199
- result = urlDecode(result);
3200
- }
3201
- if (opts.htmlDecode) {
3202
- result = htmlEntityDecode(result);
3203
- }
3204
- if (opts.sqlComments) {
3205
- result = removeSqlComments(result);
3206
- }
3207
- if (opts.whitespace) {
3208
- result = normalizeWhitespace(result);
3209
- }
3210
- return result;
3211
- }
3212
- function urlDecode(value) {
3213
- if (typeof value !== "string") {
3214
- return value;
3215
- }
3216
- let result = value;
3217
- let previous = "";
3218
- let iterations = 0;
3219
- while (result !== previous && iterations < MAX_DECODE_ITERATIONS) {
3220
- previous = result;
3221
- iterations++;
3222
- try {
3223
- result = decodeURIComponent(result);
3224
- } catch {
3225
- result = safeUrlDecode(result);
3226
- break;
3227
- }
3228
- }
3229
- return result;
3230
- }
3231
- function safeUrlDecode(value) {
3232
- return value.replace(/%([0-9A-Fa-f]{2})/g, (match, hex) => {
3233
- try {
3234
- return String.fromCharCode(parseInt(hex, 16));
3235
- } catch {
3236
- return match;
3237
- }
3238
- });
3239
- }
3240
- function htmlEntityDecode(value) {
3241
- if (typeof value !== "string") {
3242
- return value;
3243
- }
3244
- let result = value;
3245
- for (const [entity, char] of Object.entries(HTML_ENTITIES)) {
3246
- result = result.split(entity).join(char);
3247
- }
3248
- result = result.replace(/&#(\d+);/g, (match, code) => {
3249
- const num = parseInt(code, 10);
3250
- return num > 0 && num < 65536 ? String.fromCharCode(num) : match;
3251
- });
3252
- result = result.replace(/&#x([0-9A-Fa-f]+);/g, (match, hex) => {
3253
- const num = parseInt(hex, 16);
3254
- return num > 0 && num < 65536 ? String.fromCharCode(num) : match;
3255
- });
3256
- return result;
3257
- }
3258
- function removeSqlComments(value) {
3259
- if (typeof value !== "string") {
3260
- return value;
3261
- }
3262
- let result = value;
3263
- result = result.replace(/\/\*[\s\S]*?\*\//g, " ");
3264
- result = result.replace(/\/\*![\s\S]*?\*\//g, " ");
3265
- return result;
3266
- }
3267
- function removeNullBytes(value) {
3268
- if (typeof value !== "string") {
3269
- return value;
3270
- }
3271
- let result = value.replace(/\x00/g, "");
3272
- result = result.replace(/[\x01-\x08\x0B\x0C\x0E-\x1F]/g, "");
3273
- return result;
3274
- }
3275
- function normalizeWhitespace(value) {
3276
- if (typeof value !== "string") {
3277
- return value;
3278
- }
3279
- let result = value.replace(/[\t\r\n\f\v]+/g, " ");
3280
- result = result.replace(/ {2,}/g, " ");
3281
- return result;
3282
- }
3283
- var MAX_RAW_CHARS = 1024 * 1024;
3284
- var MAX_RAW_NODES = 25e3;
3285
- var MAX_RAW_DEPTH = 200;
3286
- var MAX_RAW_ENTRIES = 2e3;
3287
- function serializeForRawDetection(body) {
3288
- if (body === null || body === void 0) return "";
3289
- if (typeof body === "string") return body.slice(0, MAX_RAW_CHARS);
3290
- const visited = /* @__PURE__ */ new Set();
3291
- const output = [];
3292
- const stack = [{ kind: "value", value: body, depth: 0, root: true }];
3293
- let chars = 0;
3294
- let nodes = 0;
3295
- const append = (text) => {
3296
- if (chars >= MAX_RAW_CHARS) return;
3297
- const piece = String(text).slice(0, MAX_RAW_CHARS - chars);
3298
- output.push(piece);
3299
- chars += piece.length;
3300
- };
3301
- while (stack.length > 0 && chars < MAX_RAW_CHARS) {
3302
- const task = stack.pop();
3303
- if (task.kind === "text") {
3304
- append(task.text);
3305
- continue;
3306
- }
3307
- if (task.kind === "key") {
3308
- append(boundedJsonString(task.value));
3309
- append(":");
3310
- continue;
3311
- }
3312
- nodes++;
3313
- if (nodes > MAX_RAW_NODES) {
3314
- append('"[NodeLimit]"');
3315
- continue;
3316
- }
3317
- const value = task.value;
3318
- if (value === null || value === void 0) {
3319
- append(task.root ? "" : "null");
3320
- continue;
3321
- }
3322
- if (typeof value === "string") {
3323
- append(task.root ? value.slice(0, MAX_RAW_CHARS) : boundedJsonString(value));
3324
- continue;
3325
- }
3326
- if (typeof value !== "object") {
3327
- append(String(value).slice(0, MAX_RAW_CHARS));
3328
- continue;
3329
- }
3330
- if (visited.has(value)) {
3331
- append("[Circular]");
3332
- continue;
3333
- }
3334
- if (task.depth >= MAX_RAW_DEPTH) {
3335
- append('"[DepthLimit]"');
3336
- continue;
3337
- }
3338
- visited.add(value);
3339
- if (Array.isArray(value)) {
3340
- const length2 = Math.min(value.length, MAX_RAW_ENTRIES);
3341
- const items = [];
3342
- for (let i = 0; i < length2; i++) {
3343
- if (i > 0) items.push({ kind: "text", text: "," });
3344
- items.push({ kind: "value", value: readOwnValue(value, String(i)), depth: task.depth + 1, root: false });
3345
- }
3346
- if (value.length > length2) {
3347
- if (length2 > 0) items.push({ kind: "text", text: "," });
3348
- items.push({ kind: "text", text: '"[EntryLimit]"' });
3349
- }
3350
- stack.push({ kind: "text", text: "]" });
3351
- for (let i = items.length - 1; i >= 0; i--) stack.push(items[i]);
3352
- stack.push({ kind: "text", text: "[" });
3353
- continue;
3354
- }
3355
- let keys;
3356
- try {
3357
- keys = Object.getOwnPropertyNames(value);
3358
- } catch {
3359
- append('"[Unreadable]"');
3360
- continue;
3361
- }
3362
- const length = Math.min(keys.length, MAX_RAW_ENTRIES);
3363
- const entries = [];
3364
- for (let i = 0; i < length; i++) {
3365
- const key = keys[i];
3366
- if (i > 0) entries.push({ kind: "text", text: "," });
3367
- entries.push({ kind: "key", value: key });
3368
- entries.push({ kind: "value", value: readOwnValue(value, key), depth: task.depth + 1, root: false });
3369
- }
3370
- if (keys.length > length) {
3371
- if (length > 0) entries.push({ kind: "text", text: "," });
3372
- entries.push({ kind: "text", text: '"[EntryLimit]":null' });
3373
- }
3374
- stack.push({ kind: "text", text: "}" });
3375
- for (let i = entries.length - 1; i >= 0; i--) stack.push(entries[i]);
3376
- stack.push({ kind: "text", text: "{" });
3377
- }
3378
- return output.join("");
3379
- }
3380
- function boundedJsonString(value) {
3381
- return JSON.stringify(String(value).slice(0, MAX_RAW_CHARS));
3382
- }
3383
- function readOwnValue(value, key) {
3384
- try {
3385
- const descriptor = Object.getOwnPropertyDescriptor(value, key);
3386
- return descriptor && Object.hasOwn(descriptor, "value") ? descriptor.value : "[Accessor]";
3387
- } catch {
3388
- return "[Unreadable]";
3389
- }
3390
- }
3391
- var INHERITED_ACCESSORS = /* @__PURE__ */ new Set(["headers", "query"]);
3392
- function requestField(req, key) {
3393
- if (req === null || typeof req !== "object") return void 0;
3394
- if (Object.hasOwn(req, key)) return req[key];
3395
- if (!INHERITED_ACCESSORS.has(key)) return void 0;
3396
- let holder = Object.getPrototypeOf(req);
3397
- while (holder !== null && !Object.hasOwn(holder, key)) holder = Object.getPrototypeOf(holder);
3398
- if (holder === null || holder === Object.prototype) return void 0;
3399
- const descriptor = Object.getOwnPropertyDescriptor(holder, key);
3400
- if (typeof descriptor?.get !== "function") return void 0;
3401
- return req[key];
3402
- }
3403
- function normalizeRequest(req, options = {}) {
3404
- const ownRaw = Object.hasOwn(req ?? {}, "_rawBody") && typeof req._rawBody === "string";
3405
- const body = requestField(req, "body");
3406
- const url = requestField(req, "url");
3407
- const rawBody = ownRaw ? req._rawBody : serializeForRawDetection(body ?? null);
3408
- return {
3409
- query: normalizeObject(requestField(req, "query") || {}, options),
3410
- body: normalizeObject(body || {}, options),
3411
- headers: normalizeObject(requestField(req, "headers") || {}, options),
3412
- url: normalize(url || "", options),
3413
- originalUrl: normalize(requestField(req, "originalUrl") || url || "", options),
3414
- _rawBody: rawBody
3415
- };
3416
- }
3417
- var MAX_NORMALIZE_DEPTH = 200;
3418
- function normalizeObject(value, options = {}, depth = 0) {
3419
- if (typeof value === "string") {
3420
- return normalize(value, options);
3421
- }
3422
- if (depth >= MAX_NORMALIZE_DEPTH) {
3423
- return value;
3536
+ }
3537
+ }
3538
+ return true;
3539
+ } catch (err) {
3540
+ warnOnce2(fn, label, err);
3541
+ return false;
3424
3542
  }
3425
- if (Array.isArray(value)) {
3426
- return value.map((item) => normalizeObject(item, options, depth + 1));
3543
+ }
3544
+ function warnOnce2(fn, label, err) {
3545
+ let labels = reported.get(fn);
3546
+ if (labels?.has(label)) return;
3547
+ if (!labels) {
3548
+ labels = /* @__PURE__ */ new Set();
3549
+ reported.set(fn, labels);
3427
3550
  }
3428
- if (typeof value === "object" && value !== null) {
3429
- const result = {};
3430
- for (const [key, val] of Object.entries(value)) {
3431
- setOwn(result, key, normalizeObject(val, options, depth + 1));
3551
+ labels.add(label);
3552
+ if (warnings > MAX_WARNINGS) return;
3553
+ warnings++;
3554
+ try {
3555
+ if (warnings > MAX_WARNINGS) {
3556
+ console.warn("Patchstack: further failing callbacks passed to createProtection are not reported in this process.");
3557
+ return;
3432
3558
  }
3433
- return result;
3559
+ console.warn(
3560
+ `Patchstack: the ${label} callback passed to createProtection failed and was ignored. Protection is unaffected; this callback's failures are reported once. Cause: ${err && err.message ? err.message : String(err)}`
3561
+ );
3562
+ } catch {
3434
3563
  }
3435
- return value;
3436
3564
  }
3437
3565
 
3438
3566
  // src/protect/engine/engine.js
@@ -3820,17 +3948,21 @@ function arrayKeyValue(value, matchObj) {
3820
3948
  }
3821
3949
  var WHOLE_VALUE_MATCH_TYPES = /* @__PURE__ */ new Set(["isset", "array_in_array", "array_key_value"]);
3822
3950
  function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
3823
- const out = [];
3951
+ const leaves = [];
3824
3952
  const stack = [[root, 0]];
3825
3953
  let visited = 0;
3954
+ let truncated = false;
3826
3955
  while (stack.length) {
3827
3956
  const [node, depth] = stack.pop();
3828
3957
  if (node === null || node === void 0) continue;
3829
3958
  if (typeof node !== "object") {
3830
- out.push(node);
3959
+ leaves.push(node);
3960
+ continue;
3961
+ }
3962
+ if (depth >= maxDepth || visited >= nodeCap) {
3963
+ truncated = true;
3831
3964
  continue;
3832
3965
  }
3833
- if (depth >= maxDepth || visited >= nodeCap) continue;
3834
3966
  visited++;
3835
3967
  if (Array.isArray(node)) {
3836
3968
  for (let i = node.length - 1; i >= 0; i--) stack.push([node[i], depth + 1]);
@@ -3838,7 +3970,18 @@ function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
3838
3970
  for (const k of Object.keys(node)) stack.push([node[k], depth + 1]);
3839
3971
  }
3840
3972
  }
3841
- return out;
3973
+ return { leaves, truncated };
3974
+ }
3975
+ function skipsOf(resolver) {
3976
+ const skips = resolver?.skips;
3977
+ return skips && skips.length > 0 ? { skips } : {};
3978
+ }
3979
+ function serialisedValue(value) {
3980
+ try {
3981
+ return JSON.stringify(value);
3982
+ } catch {
3983
+ return void 0;
3984
+ }
3842
3985
  }
3843
3986
  var warnedKeys = /* @__PURE__ */ new Set();
3844
3987
  function warnOnce3(key, message) {
@@ -3856,12 +3999,22 @@ function warnUnsupportedMatchType(type) {
3856
3999
  `[patchstack] Unsupported rule_v2 match type "${type}" \u2014 condition treated as no-match. Rules relying on it are not enforced by this engine.`
3857
4000
  );
3858
4001
  }
4002
+ var SPECIAL_SCHEMES = /* @__PURE__ */ new Set(["http", "https", "ws", "wss", "ftp", "file"]);
4003
+ function trimControls(text) {
4004
+ let start = 0;
4005
+ let end = text.length;
4006
+ while (start < end && text.charCodeAt(start) <= 32) start++;
4007
+ while (end > start && text.charCodeAt(end - 1) <= 32) end--;
4008
+ return text.slice(start, end);
4009
+ }
3859
4010
  function hostFromValue(value) {
3860
- const raw = String(value ?? "").trim();
4011
+ const raw = trimControls(String(value ?? "").replace(/[\t\n\r]/g, "")).trim();
3861
4012
  if (raw === "") return "";
3862
- if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || raw.startsWith("//")) {
4013
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(raw)?.[1]?.toLowerCase();
4014
+ const relative = /^[\\/]{2}/.test(raw);
4015
+ if (relative || /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || scheme !== void 0 && SPECIAL_SCHEMES.has(scheme)) {
3863
4016
  try {
3864
- return new URL(raw.startsWith("//") ? `http:${raw}` : raw).hostname;
4017
+ return new URL(relative ? `http:${raw}` : raw).hostname;
3865
4018
  } catch {
3866
4019
  return raw;
3867
4020
  }
@@ -3889,10 +4042,18 @@ function isInternalHost(hostname) {
3889
4042
  const allZeroHi = g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0;
3890
4043
  if (allZeroHi && g[5] === 0 && g[6] === 0 && (g[7] === 0 || g[7] === 1)) return true;
3891
4044
  if ((g[0] & 65472) === 65152) return true;
4045
+ if ((g[0] & 65472) === 65216) return true;
3892
4046
  if ((g[0] & 65024) === 64512) return true;
4047
+ if ((g[0] & 65280) === 65280) return true;
4048
+ const low32 = (g[6] << 16 >>> 0 | g[7]) >>> 0;
3893
4049
  if (allZeroHi && (g[5] === 65535 || g[5] === 0)) {
3894
- return isPrivateV4Int((g[6] << 16 >>> 0 | g[7]) >>> 0);
4050
+ return isPrivateV4Int(low32);
4051
+ }
4052
+ if (g[0] === 100 && g[1] === 65435 && g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0) {
4053
+ return isPrivateV4Int(low32);
3895
4054
  }
4055
+ if (g[0] === 100 && g[1] === 65435 && g[2] === 1) return true;
4056
+ if (g[0] === 8194) return isPrivateV4Int((g[1] << 16 >>> 0 | g[2]) >>> 0);
3896
4057
  return false;
3897
4058
  }
3898
4059
  const v4 = parseIPv4ToInt(host);
@@ -3902,6 +4063,10 @@ function isInternalHost(hostname) {
3902
4063
  function isPrivateV4Int(n) {
3903
4064
  const a = n >>> 24 & 255;
3904
4065
  const b = n >>> 16 & 255;
4066
+ const c = n >>> 8 & 255;
4067
+ if (a >= 224) return true;
4068
+ if (a === 192 && b === 0 && c === 0) return true;
4069
+ if (a === 198 && (b === 18 || b === 19)) return true;
3905
4070
  if (a === 127 || a === 10 || a === 0) return true;
3906
4071
  if (a === 169 && b === 254) return true;
3907
4072
  if (a === 172 && b >= 16 && b <= 31) return true;
@@ -4250,8 +4415,12 @@ var RuleEngine = class {
4250
4415
  let normalizedReq;
4251
4416
  let resolver;
4252
4417
  try {
4253
- normalizedReq = { ...req, ...normalizeRequest(req) };
4418
+ let limited = false;
4419
+ normalizedReq = { ...req, ...normalizeRequest(req, { onLimit: () => {
4420
+ limited = true;
4421
+ } }) };
4254
4422
  resolver = new RequestResolver(normalizedReq);
4423
+ if (limited) resolver.noteSkip("container-cap");
4255
4424
  } catch (err) {
4256
4425
  this.#reportError(err);
4257
4426
  return { blocked: false, rule: null, message: null };
@@ -4277,14 +4446,15 @@ var RuleEngine = class {
4277
4446
  // that re-read the request instead would be reading it a second time: a getter, a stream or
4278
4447
  // anything else that answers once can give a different value, and evidence that disagrees
4279
4448
  // with the match it belongs to is worse than none.
4280
- resolver
4449
+ resolver,
4450
+ ...skipsOf(resolver)
4281
4451
  };
4282
4452
  }
4283
4453
  } catch (err) {
4284
4454
  this.#reportError(err);
4285
4455
  }
4286
4456
  }
4287
- return { blocked: false, rule: null, message: null };
4457
+ return { blocked: false, rule: null, message: null, ...skipsOf(resolver) };
4288
4458
  }
4289
4459
  #evaluateRule(conditions, resolver) {
4290
4460
  const inclusiveConditions = conditions.filter((c) => c.inclusive);
@@ -4340,9 +4510,15 @@ var RuleEngine = class {
4340
4510
  if (WHOLE_VALUE_MATCH_TYPES.has(match.type)) {
4341
4511
  if (matchValue(match.type, value, match.value, match)) return true;
4342
4512
  } else {
4343
- for (const leaf of collectLeafValues(value)) {
4513
+ const { leaves, truncated } = collectLeafValues(value);
4514
+ for (const leaf of leaves) {
4344
4515
  if (matchValue(match.type, leaf, match.value, match)) return true;
4345
4516
  }
4517
+ if (truncated) {
4518
+ resolver.noteSkip("container-cap");
4519
+ const text = serialisedValue(value);
4520
+ if (text !== void 0 && matchValue(match.type, text, match.value, match)) return true;
4521
+ }
4346
4522
  }
4347
4523
  continue;
4348
4524
  }
@@ -4729,6 +4905,9 @@ function conditionShapeProblem(condition) {
4729
4905
  if (nested && !named) {
4730
4906
  return `condition carries nested rules but its parameter is ${JSON.stringify(condition.parameter)}; a group must be {"parameter":"${GROUP_PARAMETER}"}`;
4731
4907
  }
4908
+ if (condition.inclusive !== void 0 && typeof condition.inclusive !== "boolean") {
4909
+ return "inclusive must be true or false";
4910
+ }
4732
4911
  if (named && nested) {
4733
4912
  if (condition.match !== void 0) return "a group carries no match of its own; the engine ignores it";
4734
4913
  if (condition.mutations !== void 0) return "a group carries no mutations of its own; the engine ignores them";
@@ -5438,7 +5617,7 @@ async function fromFetchRequest(request, options = {}) {
5438
5617
  // that guessed differently would attribute one request to two addresses.
5439
5618
  ip: client.ip ?? "",
5440
5619
  _clientIp: client,
5441
- cookies: parseCookies(headers.cookie),
5620
+ cookies: parseCookieHeader(headers.cookie),
5442
5621
  // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify
5443
5622
  // drops, so prototype-pollution rules on `raw` are robust.
5444
5623
  _rawBody: rawBody,
@@ -5592,22 +5771,53 @@ function decodeExtendedValue(value) {
5592
5771
  return match[2];
5593
5772
  }
5594
5773
  }
5595
- function parseCookies(header) {
5596
- const cookies = {};
5597
- if (!header) {
5598
- return cookies;
5599
- }
5600
- for (const pair of header.split(";")) {
5601
- const idx = pair.indexOf("=");
5602
- if (idx === -1) {
5603
- continue;
5604
- }
5605
- setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
5606
- }
5607
- return cookies;
5608
- }
5609
5774
 
5610
5775
  // src/protect/engine/node.js
5776
+ function readBodyPrefix(req, maxBytes, done) {
5777
+ const chunks = [];
5778
+ let retained = 0;
5779
+ let size = 0;
5780
+ let failed = false;
5781
+ let finished = false;
5782
+ const finish = (error, read) => {
5783
+ if (finished) return;
5784
+ finished = true;
5785
+ done(error, read);
5786
+ };
5787
+ req.on("data", (chunk) => {
5788
+ if (failed) return;
5789
+ try {
5790
+ const bytes = typeof chunk === "string" ? Buffer.from(chunk, req.readableEncoding || "utf8") : chunk;
5791
+ size += bytes.length;
5792
+ const take = Math.min(bytes.length, maxBytes - retained);
5793
+ if (take <= 0) return;
5794
+ chunks.push(take === bytes.length ? bytes : bytes.subarray(0, take));
5795
+ retained += take;
5796
+ } catch {
5797
+ failed = true;
5798
+ chunks.length = 0;
5799
+ }
5800
+ });
5801
+ req.on("error", (error) => finish(error));
5802
+ req.on("end", () => {
5803
+ if (failed) {
5804
+ finish(null, { text: "", overflow: false, size, failed: true });
5805
+ return;
5806
+ }
5807
+ const prefix = Buffer.concat(chunks);
5808
+ const overflow = size > maxBytes;
5809
+ const text = (overflow ? prefix.subarray(0, completeUtf8Length(prefix)) : prefix).toString("utf8");
5810
+ finish(null, { text, overflow, size, failed: false });
5811
+ });
5812
+ }
5813
+ function completeUtf8Length(bytes) {
5814
+ let start = bytes.length - 1;
5815
+ while (start >= 0 && bytes.length - start <= 3 && (bytes[start] & 192) === 128) start--;
5816
+ if (start < 0) return bytes.length;
5817
+ const lead = bytes[start];
5818
+ const needed = lead >= 240 ? 4 : lead >= 224 ? 3 : lead >= 192 ? 2 : 1;
5819
+ return bytes.length - start < needed ? start : bytes.length;
5820
+ }
5611
5821
  function fromNodeRequest(req, rawBody = "", options = {}) {
5612
5822
  const method = (req.method || "GET").toUpperCase();
5613
5823
  const headers = {};
@@ -5653,29 +5863,41 @@ function fromNodeRequest(req, rawBody = "", options = {}) {
5653
5863
  headers,
5654
5864
  ip: client.ip ?? "",
5655
5865
  _clientIp: client,
5656
- cookies: parseCookies2(headers.cookie),
5866
+ cookies: parseCookieHeader(headers.cookie),
5657
5867
  // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify drops.
5658
5868
  _rawBody: rawBody
5659
5869
  };
5660
5870
  }
5661
- function parseCookies2(header) {
5662
- const cookies = {};
5663
- if (!header) {
5664
- return cookies;
5871
+
5872
+ // src/protect/egress.js
5873
+ var REGISTRY = /* @__PURE__ */ Symbol.for("patchstack.connect.egress-guard");
5874
+ function egressRegistry() {
5875
+ const existing = globalThis[REGISTRY];
5876
+ if (existing && existing.screens instanceof Set && existing.surfaces instanceof Map) return existing;
5877
+ const created = { screens: /* @__PURE__ */ new Set(), surfaces: /* @__PURE__ */ new Map() };
5878
+ Object.defineProperty(globalThis, REGISTRY, { value: created, configurable: true, writable: true });
5879
+ return created;
5880
+ }
5881
+ var refusal = (host) => new Error(`Patchstack blocked an outbound request to a disallowed address: ${host}`);
5882
+ function readableDestination(input, init) {
5883
+ try {
5884
+ const raw = typeof input === "string" || input instanceof URL ? String(input) : input?.url ?? input?.href;
5885
+ if (typeof raw !== "string" && !(raw instanceof URL)) return null;
5886
+ const url = new URL(String(raw)).href;
5887
+ const method = String(init?.method ?? input?.method ?? "GET").toUpperCase();
5888
+ return { url, method };
5889
+ } catch {
5890
+ return null;
5665
5891
  }
5666
- for (const pair of header.split(";")) {
5667
- const idx = pair.indexOf("=");
5668
- if (idx === -1) {
5669
- continue;
5670
- }
5671
- setOwn(cookies, pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
5892
+ }
5893
+ function anyRefuses(screens, url, host, method) {
5894
+ let refused = false;
5895
+ for (const screen of screens) {
5896
+ if (screen.block(url, host, method)) refused = true;
5672
5897
  }
5673
- return cookies;
5898
+ return refused;
5674
5899
  }
5675
-
5676
- // src/protect/egress.js
5677
5900
  async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = true, lookup, allowHosts } = {}) {
5678
- const restores = [];
5679
5901
  if (typeof shouldBlock !== "function") return () => {
5680
5902
  };
5681
5903
  const exempt = new Set((allowHosts ?? []).map((h) => String(h).toLowerCase()));
@@ -5720,6 +5942,9 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
5720
5942
  resolve(false);
5721
5943
  }
5722
5944
  });
5945
+ const registry = egressRegistry();
5946
+ const own = { block, prescreen: resolvesToDisallowed, dns: screen, skip };
5947
+ registry.screens.add(own);
5723
5948
  const originalFetch = globalThis.fetch;
5724
5949
  if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
5725
5950
  const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
@@ -5792,15 +6017,19 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
5792
6017
  } catch {
5793
6018
  host = null;
5794
6019
  }
5795
- if (block(u, host, method) || await resolvesToDisallowed(u, host, method)) {
5796
- throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? u}`);
5797
- }
6020
+ const screens = [...registry.screens];
6021
+ if (anyRefuses(screens, u, host, method)) throw refusal(host ?? u);
6022
+ const resolved = await Promise.all(screens.map((each) => each.prescreen(u, host, method)));
6023
+ if (resolved.includes(true)) throw refusal(host ?? u);
5798
6024
  };
5799
6025
  const guarded = async (input, init) => {
5800
6026
  let cur;
5801
6027
  try {
5802
6028
  cur = new Request(input, { ...init || {}, redirect: "manual" });
5803
6029
  } catch {
6030
+ const destination = readableDestination(input, init);
6031
+ if (destination) await screenUrl(destination.url, destination.method);
6032
+ else for (const each of registry.screens) each.skip("unrecognised-request", {});
5804
6033
  return originalFetch(input, init);
5805
6034
  }
5806
6035
  const callerRedirect = init && init.redirect || input && input.redirect || "follow";
@@ -5811,13 +6040,12 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
5811
6040
  const headers = new Headers(cur.headers);
5812
6041
  const signal = cur.signal;
5813
6042
  const replay = captureReplayBody(cur);
6043
+ const { body: _callerBody, ...callerOptions } = init || {};
5814
6044
  let body;
5815
6045
  for (let hop = 0; ; hop++) {
5816
6046
  let resp;
5817
6047
  try {
5818
- resp = await originalFetch(
5819
- hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
5820
- );
6048
+ resp = hop === 0 ? await originalFetch(cur, { ...callerOptions, redirect: "manual" }) : await originalFetch(url, { ...callerOptions, method, headers, body, redirect: "manual", signal });
5821
6049
  } catch (error) {
5822
6050
  replay.cancel();
5823
6051
  throw error;
@@ -5825,7 +6053,7 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
5825
6053
  const location = REDIRECT_STATUSES.has(resp.status) ? resp.headers.get("location") : null;
5826
6054
  if (!location) {
5827
6055
  replay.cancel();
5828
- return resp;
6056
+ return hop === 0 ? resp : markRedirected(resp);
5829
6057
  }
5830
6058
  await discardResponseBody(resp);
5831
6059
  if (hop >= MAX_REDIRECTS) {
@@ -5858,8 +6086,12 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
5858
6086
  };
5859
6087
  guarded.__patchstackGuarded = true;
5860
6088
  globalThis.fetch = guarded;
5861
- restores.push(() => {
5862
- if (globalThis.fetch === guarded) globalThis.fetch = originalFetch;
6089
+ registry.surfaces.set(guarded, {
6090
+ release() {
6091
+ if (globalThis.fetch !== guarded) return false;
6092
+ globalThis.fetch = originalFetch;
6093
+ return true;
6094
+ }
5863
6095
  });
5864
6096
  }
5865
6097
  let syncBuiltins = () => {
@@ -5873,12 +6105,16 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
5873
6105
  for (const moduleName of ["node:http", "node:https"]) {
5874
6106
  try {
5875
6107
  const mod = await import(moduleName);
5876
- const restore = patchHttpModule(mod.default ?? mod, block, screen, skip);
5877
- if (restore) {
6108
+ if (registry.surfaces.has(moduleName)) continue;
6109
+ const release = patchHttpModule(mod.default ?? mod, registry);
6110
+ if (release) {
5878
6111
  patchedAny = true;
5879
- restores.push(() => {
5880
- restore();
5881
- syncBuiltins();
6112
+ registry.surfaces.set(moduleName, {
6113
+ release() {
6114
+ if (!release()) return false;
6115
+ syncBuiltins();
6116
+ return true;
6117
+ }
5882
6118
  });
5883
6119
  }
5884
6120
  } catch {
@@ -5886,27 +6122,41 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
5886
6122
  }
5887
6123
  if (patchedAny) syncBuiltins();
5888
6124
  return () => {
5889
- for (const restore of restores) {
6125
+ registry.screens.delete(own);
6126
+ if (registry.screens.size > 0) return;
6127
+ for (const [name, surface] of registry.surfaces) {
5890
6128
  try {
5891
- restore();
6129
+ if (surface.release()) registry.surfaces.delete(name);
5892
6130
  } catch {
5893
6131
  }
5894
6132
  }
5895
6133
  };
5896
6134
  }
5897
- function patchHttpModule(http, block, screen, skip) {
6135
+ function markRedirected(response) {
6136
+ try {
6137
+ Object.defineProperty(response, "redirected", { value: true, configurable: true });
6138
+ } catch {
6139
+ }
6140
+ return response;
6141
+ }
6142
+ function patchHttpModule(http, registry) {
5898
6143
  if (!http || typeof http.request !== "function" || http.__patchstackGuarded) return null;
5899
6144
  const originalRequest = http.request;
5900
6145
  const originalGet = http.get;
5901
6146
  const OriginalClientRequest = typeof http.ClientRequest === "function" ? http.ClientRequest : null;
5902
6147
  const guardArgs = (args) => {
5903
6148
  const target = extractHttpTarget(args);
5904
- if (target && block(target.url, target.host, target.method)) {
5905
- throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host ?? target.url}`);
5906
- }
5907
- if (target && screen && target.host && screen.isIP(target.host) === 0 && !screen.isExempt(target.host)) {
6149
+ if (!target) return args;
6150
+ const screens = [...registry.screens];
6151
+ if (anyRefuses(screens, target.url, target.host, target.method)) throw refusal(target.host ?? target.url);
6152
+ const resolving = target.host ? screens.filter((each) => each.dns && each.dns.isIP(target.host) === 0 && !each.dns.isExempt(target.host)) : [];
6153
+ if (resolving.length > 0) {
6154
+ const block = (url, host, method) => anyRefuses(resolving, url, host, method);
6155
+ const skip = (reason, detail) => {
6156
+ for (const each of resolving) each.skip(reason, detail);
6157
+ };
5908
6158
  try {
5909
- return withScreeningLookup(args, target, block, screen.lookup, skip);
6159
+ return withScreeningLookup(args, target, block, resolving[0].dns.lookup, skip);
5910
6160
  } catch {
5911
6161
  }
5912
6162
  }
@@ -5934,10 +6184,14 @@ function patchHttpModule(http, block, screen, skip) {
5934
6184
  }
5935
6185
  http.__patchstackGuarded = true;
5936
6186
  return () => {
5937
- if (http.request === guardedRequest) http.request = originalRequest;
5938
- if (guardedGet && http.get === guardedGet) http.get = originalGet;
5939
- if (GuardedClientRequest && http.ClientRequest === GuardedClientRequest) http.ClientRequest = OriginalClientRequest;
6187
+ if (http.request !== guardedRequest) return false;
6188
+ if (guardedGet && http.get !== guardedGet) return false;
6189
+ if (GuardedClientRequest && http.ClientRequest !== GuardedClientRequest) return false;
6190
+ http.request = originalRequest;
6191
+ if (guardedGet) http.get = originalGet;
6192
+ if (GuardedClientRequest) http.ClientRequest = OriginalClientRequest;
5940
6193
  delete http.__patchstackGuarded;
6194
+ return true;
5941
6195
  };
5942
6196
  }
5943
6197
  function extractHttpTarget(args) {
@@ -6838,7 +7092,7 @@ function makeRefreshHandler(tick, secret) {
6838
7092
  return async (request) => {
6839
7093
  if (!secret) return new Response("not found", { status: 404 });
6840
7094
  const provided = request?.headers?.get?.("x-patchstack-refresh") ?? null;
6841
- if (provided !== secret) return new Response("forbidden", { status: 403 });
7095
+ if (!await sameSecret(provided, secret)) return new Response("forbidden", { status: 403 });
6842
7096
  let refreshed = true;
6843
7097
  try {
6844
7098
  const status = await runOnce();
@@ -6849,6 +7103,33 @@ function makeRefreshHandler(tick, secret) {
6849
7103
  return new Response(JSON.stringify({ refreshed }), { status: 200, headers: { "content-type": "application/json" } });
6850
7104
  };
6851
7105
  }
7106
+ async function sameSecret(provided, secret) {
7107
+ if (typeof provided !== "string" || typeof secret !== "string") return false;
7108
+ const subtle = globalThis.crypto?.subtle;
7109
+ if (subtle && typeof TextEncoder === "function") {
7110
+ try {
7111
+ const encoder2 = new TextEncoder();
7112
+ const [a, b] = await Promise.all([
7113
+ subtle.digest("SHA-256", encoder2.encode(provided)),
7114
+ subtle.digest("SHA-256", encoder2.encode(secret))
7115
+ ]);
7116
+ return equalBytes(new Uint8Array(a), new Uint8Array(b));
7117
+ } catch {
7118
+ }
7119
+ }
7120
+ let diff = provided.length ^ secret.length;
7121
+ const length = Math.max(provided.length, secret.length);
7122
+ for (let i = 0; i < length; i++) {
7123
+ diff |= (provided.charCodeAt(i) || 0) ^ (secret.charCodeAt(i) || 0);
7124
+ }
7125
+ return diff === 0;
7126
+ }
7127
+ function equalBytes(a, b) {
7128
+ if (a.length !== b.length) return false;
7129
+ let diff = 0;
7130
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
7131
+ return diff === 0;
7132
+ }
6852
7133
 
6853
7134
  // src/protect/detections.js
6854
7135
  init_pulse_token();
@@ -7584,9 +7865,180 @@ function hardensWithoutBody(rule) {
7584
7865
  if (!rule || !HEADER_ACTIONS.includes(rule.action)) return false;
7585
7866
  const { parameters, complete } = readRuleParameters(rule);
7586
7867
  if (!complete) return false;
7587
- return parameters.every(
7588
- (parameter) => BODY_INDEPENDENT.includes(parameter) || parameter.startsWith(BODY_INDEPENDENT_PREFIX)
7868
+ return parameters.every(bodyIndependent);
7869
+ }
7870
+ function readsOnlyResponseHeaders(rule) {
7871
+ if (!rule) return false;
7872
+ const { parameters, complete } = readRuleParameters(rule);
7873
+ if (!complete || !parameters.some(isHeaderParameter)) return false;
7874
+ return parameters.every(bodyIndependent);
7875
+ }
7876
+ function isHeaderParameter(parameter) {
7877
+ return parameter === "response.headers" || parameter.startsWith(BODY_INDEPENDENT_PREFIX);
7878
+ }
7879
+ function bodyIndependent(parameter) {
7880
+ return BODY_INDEPENDENT.includes(parameter) || parameter.startsWith(BODY_INDEPENDENT_PREFIX);
7881
+ }
7882
+
7883
+ // src/protect/supabase-guard.js
7884
+ var GUARD_PATH = "/_patchstack/guard";
7885
+ var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
7886
+ var DEFAULT_TIMEOUT_MS = 3e4;
7887
+ var REQUEST_HEADER_DENYLIST = /* @__PURE__ */ new Set([
7888
+ "connection",
7889
+ "cookie",
7890
+ "cookie2",
7891
+ "forwarded",
7892
+ "host",
7893
+ "keep-alive",
7894
+ "proxy-authenticate",
7895
+ "proxy-authorization",
7896
+ "te",
7897
+ "trailer",
7898
+ "transfer-encoding",
7899
+ "upgrade",
7900
+ "x-forwarded-for",
7901
+ "x-forwarded-host",
7902
+ "x-forwarded-port",
7903
+ "x-forwarded-proto",
7904
+ "x-ps-target",
7905
+ "x-real-ip"
7906
+ ]);
7907
+ var RESPONSE_HEADER_DENYLIST = /* @__PURE__ */ new Set([
7908
+ "connection",
7909
+ "content-encoding",
7910
+ "content-length",
7911
+ "keep-alive",
7912
+ "proxy-authenticate",
7913
+ "proxy-authorization",
7914
+ "set-cookie",
7915
+ "set-cookie2",
7916
+ "te",
7917
+ "trailer",
7918
+ "transfer-encoding",
7919
+ "upgrade"
7920
+ ]);
7921
+ var BodyTooLargeError = class extends Error {
7922
+ };
7923
+ async function boundedBody(request, maxBytes) {
7924
+ const stated2 = Number(request.headers.get("content-length"));
7925
+ if (Number.isFinite(stated2) && stated2 > maxBytes) throw new BodyTooLargeError();
7926
+ if (!request.body) return void 0;
7927
+ const reader = request.body.getReader();
7928
+ const chunks = [];
7929
+ let total = 0;
7930
+ try {
7931
+ for (; ; ) {
7932
+ const { done, value } = await reader.read();
7933
+ if (done) break;
7934
+ total += value.byteLength;
7935
+ if (total > maxBytes) {
7936
+ void reader.cancel().catch(() => {
7937
+ });
7938
+ throw new BodyTooLargeError();
7939
+ }
7940
+ chunks.push(value);
7941
+ }
7942
+ } finally {
7943
+ reader.releaseLock();
7944
+ }
7945
+ const body = new Uint8Array(total);
7946
+ let offset = 0;
7947
+ for (const chunk of chunks) {
7948
+ body.set(chunk, offset);
7949
+ offset += chunk.byteLength;
7950
+ }
7951
+ return body;
7952
+ }
7953
+ function requestHeaders(input) {
7954
+ const output = new Headers();
7955
+ const connectionTokens = new Set(
7956
+ (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
7589
7957
  );
7958
+ input.forEach((value, key) => {
7959
+ const lower = key.toLowerCase();
7960
+ if (!REQUEST_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
7961
+ });
7962
+ return output;
7963
+ }
7964
+ function responseHeaders(input) {
7965
+ const output = new Headers();
7966
+ const connectionTokens = new Set(
7967
+ (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
7968
+ );
7969
+ input.forEach((value, key) => {
7970
+ const lower = key.toLowerCase();
7971
+ if (!RESPONSE_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
7972
+ });
7973
+ return output;
7974
+ }
7975
+ var SOURCE_REQUEST = /* @__PURE__ */ Symbol.for("@patchstack/connect.sourceRequest");
7976
+ function createSupabaseGuard({
7977
+ protection,
7978
+ supabaseUrl,
7979
+ fetchImpl = fetch,
7980
+ maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
7981
+ timeoutMs = DEFAULT_TIMEOUT_MS
7982
+ }) {
7983
+ const guard = protection.fetchGuard();
7984
+ const allowedOrigin = supabaseUrl ? new URL(supabaseUrl).origin : null;
7985
+ const bodyLimit = Number.isFinite(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
7986
+ const upstreamTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
7987
+ return async function handleGuardRequest(request, ...hostArgs) {
7988
+ const target = request.headers.get("x-ps-target");
7989
+ if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 });
7990
+ let targetUrl;
7991
+ try {
7992
+ targetUrl = new URL(target);
7993
+ } catch {
7994
+ return new Response("patchstack: invalid target", { status: 400 });
7995
+ }
7996
+ if (!allowedOrigin || targetUrl.protocol !== "https:" || targetUrl.origin !== allowedOrigin) {
7997
+ return new Response("patchstack: target not allowed", { status: 403 });
7998
+ }
7999
+ const hasBody = request.method !== "GET" && request.method !== "HEAD";
8000
+ let body;
8001
+ try {
8002
+ body = hasBody ? await boundedBody(request, bodyLimit) : void 0;
8003
+ } catch (error) {
8004
+ if (error instanceof BodyTooLargeError) {
8005
+ return new Response("patchstack: request body too large", { status: 413 });
8006
+ }
8007
+ throw error;
8008
+ }
8009
+ const forwardHeaders = requestHeaders(request.headers);
8010
+ const evalReq = new Request(targetUrl.toString(), {
8011
+ method: request.method,
8012
+ // Inspect what the browser sent, including app-origin cookies; only the forwarded copy is
8013
+ // sanitized at the cross-origin boundary below.
8014
+ headers: request.headers,
8015
+ body
8016
+ });
8017
+ Object.defineProperty(evalReq, SOURCE_REQUEST, { value: request });
8018
+ const blocked = await guard(evalReq, ...hostArgs);
8019
+ if (blocked) return blocked;
8020
+ let upstream;
8021
+ try {
8022
+ upstream = await fetchImpl(targetUrl.toString(), {
8023
+ method: request.method,
8024
+ headers: forwardHeaders,
8025
+ body,
8026
+ redirect: "manual",
8027
+ signal: AbortSignal.timeout(upstreamTimeout)
8028
+ });
8029
+ } catch (error) {
8030
+ if (error?.name === "TimeoutError" || error?.name === "AbortError") {
8031
+ return new Response("patchstack: upstream timed out", { status: 504 });
8032
+ }
8033
+ throw error;
8034
+ }
8035
+ const forwarded = new Response(upstream.body, {
8036
+ status: upstream.status,
8037
+ statusText: upstream.statusText,
8038
+ headers: responseHeaders(upstream.headers)
8039
+ });
8040
+ return protection.screenResponse ? protection.screenResponse(forwarded, evalReq) : forwarded;
8041
+ };
7590
8042
  }
7591
8043
 
7592
8044
  // src/protect/firewall-log.js
@@ -7637,8 +8089,13 @@ function resolveApiBase(pulseOrManifestUrl) {
7637
8089
  function createFirewallLogReporter(opts) {
7638
8090
  const creds = parseApiKey(opts.apiKey);
7639
8091
  if (!creds) {
7640
- return { record() {
7641
- }, flush: () => Promise.resolve(), stop: () => Promise.resolve() };
8092
+ return {
8093
+ record() {
8094
+ },
8095
+ flush: () => Promise.resolve(),
8096
+ stop: () => Promise.resolve(),
8097
+ health: () => ({ recorded: 0, delivered: 0, failed: 0, dropped: 0, queued: 0 })
8098
+ };
7642
8099
  }
7643
8100
  const apiBase = safeBaseUrl(opts.apiBase, DEFAULT_API_BASE, "block-log API").replace(/\/$/, "");
7644
8101
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
@@ -7651,6 +8108,19 @@ function createFirewallLogReporter(opts) {
7651
8108
  let drainPromise = null;
7652
8109
  let activeController = null;
7653
8110
  let ended = false;
8111
+ let recorded = 0;
8112
+ let delivered = 0;
8113
+ let failed = 0;
8114
+ let dropped = 0;
8115
+ let activeBatch = null;
8116
+ const settle = (sent, outcome) => {
8117
+ if (sent.counted) return;
8118
+ sent.counted = true;
8119
+ if (outcome === "delivered") delivered += sent.size;
8120
+ else if (outcome === "failed") failed += sent.size;
8121
+ else dropped += sent.size;
8122
+ if (activeBatch === sent) activeBatch = null;
8123
+ };
7654
8124
  let cachedToken = null;
7655
8125
  let tokenInflight = null;
7656
8126
  const fetchAccessToken = async (signal) => {
@@ -7704,10 +8174,16 @@ function createFirewallLogReporter(opts) {
7704
8174
  const attemptTimer = setTimeout(() => controller?.abort(), ATTEMPT_TIMEOUT_MS2);
7705
8175
  attemptTimer?.unref?.();
7706
8176
  let entry;
8177
+ const sent = { size: batch.length, counted: false };
8178
+ activeBatch = sent;
7707
8179
  entry = (async () => {
7708
8180
  try {
7709
8181
  const token = await fetchAccessToken(controller?.signal);
7710
- if (!token || ended) return;
8182
+ if (ended) return;
8183
+ if (!token) {
8184
+ settle(sent, "failed");
8185
+ return;
8186
+ }
7711
8187
  const body = new URLSearchParams();
7712
8188
  body.set("type", "firewall");
7713
8189
  body.set("logs", JSON.stringify(batch));
@@ -7724,10 +8200,11 @@ function createFirewallLogReporter(opts) {
7724
8200
  // Both phases carry the attempt controller, so slow transports cannot accumulate work.
7725
8201
  ...controller ? { signal: controller.signal } : {}
7726
8202
  });
7727
- if (p && typeof p.then === "function") await p.catch(() => {
7728
- });
8203
+ const res = p && typeof p.then === "function" ? await p.catch(() => null) : p;
8204
+ settle(sent, res && res.ok ? "delivered" : "failed");
7729
8205
  } catch {
7730
8206
  } finally {
8207
+ settle(sent, "failed");
7731
8208
  clearTimeout(attemptTimer);
7732
8209
  if (activeController === controller) activeController = null;
7733
8210
  if (inFlight === entry) inFlight = null;
@@ -7754,7 +8231,11 @@ function createFirewallLogReporter(opts) {
7754
8231
  if (stopped) return;
7755
8232
  const fid = event?.rule?.id;
7756
8233
  if (fid === void 0 || fid === null || fid === "") return;
7757
- if (queue.length >= MAX_QUEUE2) return;
8234
+ if (queue.length >= MAX_QUEUE2) {
8235
+ dropped++;
8236
+ return;
8237
+ }
8238
+ recorded++;
7758
8239
  queue.push({
7759
8240
  fid,
7760
8241
  method: event.method ?? null,
@@ -7770,6 +8251,10 @@ function createFirewallLogReporter(opts) {
7770
8251
  if (!timer) timer = setTimeout(flush, flushMs);
7771
8252
  },
7772
8253
  flush,
8254
+ /** Where the records went so far; see the counters above. `queued` is what is waiting now. */
8255
+ health() {
8256
+ return { recorded, delivered, failed, dropped, queued: queue.length };
8257
+ },
7773
8258
  /**
7774
8259
  * Stop, and hand back a wait for what was outstanding.
7775
8260
  *
@@ -7788,6 +8273,8 @@ function createFirewallLogReporter(opts) {
7788
8273
  ended = true;
7789
8274
  activeController?.abort();
7790
8275
  activeController = null;
8276
+ if (activeBatch) settle(activeBatch, "dropped");
8277
+ dropped += queue.length;
7791
8278
  queue = [];
7792
8279
  inFlight = null;
7793
8280
  };
@@ -7824,165 +8311,6 @@ function telemetryEnabled() {
7824
8311
  return !/^(0|false|off|no)$/i.test(String(v));
7825
8312
  }
7826
8313
 
7827
- // src/protect/supabase-guard.js
7828
- var GUARD_PATH = "/_patchstack/guard";
7829
- var DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
7830
- var DEFAULT_TIMEOUT_MS = 3e4;
7831
- var REQUEST_HEADER_DENYLIST = /* @__PURE__ */ new Set([
7832
- "connection",
7833
- "cookie",
7834
- "cookie2",
7835
- "forwarded",
7836
- "host",
7837
- "keep-alive",
7838
- "proxy-authenticate",
7839
- "proxy-authorization",
7840
- "te",
7841
- "trailer",
7842
- "transfer-encoding",
7843
- "upgrade",
7844
- "x-forwarded-for",
7845
- "x-forwarded-host",
7846
- "x-forwarded-port",
7847
- "x-forwarded-proto",
7848
- "x-ps-target",
7849
- "x-real-ip"
7850
- ]);
7851
- var RESPONSE_HEADER_DENYLIST = /* @__PURE__ */ new Set([
7852
- "connection",
7853
- "content-encoding",
7854
- "content-length",
7855
- "keep-alive",
7856
- "proxy-authenticate",
7857
- "proxy-authorization",
7858
- "set-cookie",
7859
- "set-cookie2",
7860
- "te",
7861
- "trailer",
7862
- "transfer-encoding",
7863
- "upgrade"
7864
- ]);
7865
- var BodyTooLargeError = class extends Error {
7866
- };
7867
- async function boundedBody(request, maxBytes) {
7868
- const stated2 = Number(request.headers.get("content-length"));
7869
- if (Number.isFinite(stated2) && stated2 > maxBytes) throw new BodyTooLargeError();
7870
- if (!request.body) return void 0;
7871
- const reader = request.body.getReader();
7872
- const chunks = [];
7873
- let total = 0;
7874
- try {
7875
- for (; ; ) {
7876
- const { done, value } = await reader.read();
7877
- if (done) break;
7878
- total += value.byteLength;
7879
- if (total > maxBytes) {
7880
- void reader.cancel().catch(() => {
7881
- });
7882
- throw new BodyTooLargeError();
7883
- }
7884
- chunks.push(value);
7885
- }
7886
- } finally {
7887
- reader.releaseLock();
7888
- }
7889
- const body = new Uint8Array(total);
7890
- let offset = 0;
7891
- for (const chunk of chunks) {
7892
- body.set(chunk, offset);
7893
- offset += chunk.byteLength;
7894
- }
7895
- return body;
7896
- }
7897
- function requestHeaders(input) {
7898
- const output = new Headers();
7899
- const connectionTokens = new Set(
7900
- (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
7901
- );
7902
- input.forEach((value, key) => {
7903
- const lower = key.toLowerCase();
7904
- if (!REQUEST_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
7905
- });
7906
- return output;
7907
- }
7908
- function responseHeaders(input) {
7909
- const output = new Headers();
7910
- const connectionTokens = new Set(
7911
- (input.get("connection") ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean)
7912
- );
7913
- input.forEach((value, key) => {
7914
- const lower = key.toLowerCase();
7915
- if (!RESPONSE_HEADER_DENYLIST.has(lower) && !connectionTokens.has(lower)) output.append(key, value);
7916
- });
7917
- return output;
7918
- }
7919
- function createSupabaseGuard({
7920
- protection,
7921
- supabaseUrl,
7922
- fetchImpl = fetch,
7923
- maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
7924
- timeoutMs = DEFAULT_TIMEOUT_MS
7925
- }) {
7926
- const guard = protection.fetchGuard();
7927
- const allowedOrigin = supabaseUrl ? new URL(supabaseUrl).origin : null;
7928
- const bodyLimit = Number.isFinite(maxBodyBytes) && maxBodyBytes > 0 ? maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
7929
- const upstreamTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
7930
- return async function handleGuardRequest(request) {
7931
- const target = request.headers.get("x-ps-target");
7932
- if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 });
7933
- let targetUrl;
7934
- try {
7935
- targetUrl = new URL(target);
7936
- } catch {
7937
- return new Response("patchstack: invalid target", { status: 400 });
7938
- }
7939
- if (!allowedOrigin || targetUrl.protocol !== "https:" || targetUrl.origin !== allowedOrigin) {
7940
- return new Response("patchstack: target not allowed", { status: 403 });
7941
- }
7942
- const hasBody = request.method !== "GET" && request.method !== "HEAD";
7943
- let body;
7944
- try {
7945
- body = hasBody ? await boundedBody(request, bodyLimit) : void 0;
7946
- } catch (error) {
7947
- if (error instanceof BodyTooLargeError) {
7948
- return new Response("patchstack: request body too large", { status: 413 });
7949
- }
7950
- throw error;
7951
- }
7952
- const forwardHeaders = requestHeaders(request.headers);
7953
- const evalReq = new Request(targetUrl.toString(), {
7954
- method: request.method,
7955
- // Inspect what the browser sent, including app-origin cookies; only the forwarded copy is
7956
- // sanitized at the cross-origin boundary below.
7957
- headers: request.headers,
7958
- body
7959
- });
7960
- const blocked = await guard(evalReq);
7961
- if (blocked) return blocked;
7962
- let upstream;
7963
- try {
7964
- upstream = await fetchImpl(targetUrl.toString(), {
7965
- method: request.method,
7966
- headers: forwardHeaders,
7967
- body,
7968
- redirect: "manual",
7969
- signal: AbortSignal.timeout(upstreamTimeout)
7970
- });
7971
- } catch (error) {
7972
- if (error?.name === "TimeoutError" || error?.name === "AbortError") {
7973
- return new Response("patchstack: upstream timed out", { status: 504 });
7974
- }
7975
- throw error;
7976
- }
7977
- const forwarded = new Response(upstream.body, {
7978
- status: upstream.status,
7979
- statusText: upstream.statusText,
7980
- headers: responseHeaders(upstream.headers)
7981
- });
7982
- return protection.screenResponse ? protection.screenResponse(forwarded, evalReq) : forwarded;
7983
- };
7984
- }
7985
-
7986
8314
  // src/protect/verify-sentinel.js
7987
8315
  var VERIFY_HEADER = "x-patchstack-verify";
7988
8316
  var CHALLENGE = "PATCHSTACK_VERIFY_CHALLENGE";
@@ -8169,20 +8497,47 @@ async function createProtection(options = {}) {
8169
8497
  whitelist_keys: delivered.whitelist_keys,
8170
8498
  onError
8171
8499
  });
8172
- responseRuleSet = responseRules.map((rule) => ({
8173
- rule,
8174
- engine: new RuleEngine({ firewall: [rule], onError }),
8175
- redactors: rule.action === "redact" || rule.action === "encode" ? extractRedactors(rule) : null,
8176
- // A redact/encode condition that carries body-transforming mutations (base64_decode, urldecode,
8177
- // json_decode, …) detects on the DECODED body but the span redactors run on the RAW body — so
8178
- // they mask nothing and the secret is served while the log says "redacted". Flag it so screenText
8179
- // fails such a rule CLOSED (block) instead of serving a no-op redaction.
8180
- mutatedSpan: (rule.action === "redact" || rule.action === "encode") && hasSpanMutations(rule),
8181
- // Optional cheap pre-filter: literal anchor(s) that MUST appear for the (expensive) regex to
8182
- // have any chance of matching. Lets screenText skip the full scan on bodies with no candidate —
8183
- // the common case — cutting CPU/latency and shrinking the regex/ReDoS surface. Case-insensitive.
8184
- prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
8185
- }));
8500
+ responseRuleSet = responseRules.map((rule) => {
8501
+ const unscoped = [];
8502
+ const rewrites = rule.action === "redact" || rule.action === "encode";
8503
+ let derived;
8504
+ try {
8505
+ const redactors = rewrites ? extractRedactors(rule, (parameter) => unscoped.push(parameter)) : null;
8506
+ const mutatedSpan = rewrites && hasSpanMutations(rule);
8507
+ derived = {
8508
+ redactors,
8509
+ mutatedSpan,
8510
+ // A redaction that reads response headers only, and masks spans in them, and an explicit block
8511
+ // that reads response headers only, are decided and carried out without the body — so they
8512
+ // still apply to a response whose body was not screened.
8513
+ redactsHeaders: rule.action === "redact" && !mutatedSpan && (redactors ?? []).some((r) => !r.jsonPath) && readsOnlyResponseHeaders(rule),
8514
+ blocksOnHeaders: rule.action === "block" && readsOnlyResponseHeaders(rule)
8515
+ };
8516
+ } catch (err) {
8517
+ notify(onError, err, "onError");
8518
+ derived = { redactors: null, mutatedSpan: false, redactsHeaders: false, blocksOnHeaders: false };
8519
+ }
8520
+ if (unscoped.length) {
8521
+ notify(
8522
+ onError,
8523
+ new Error(
8524
+ `Patchstack: response rule ${JSON.stringify(rule.id ?? null)} has a condition whose parameter names no place in the response to mask (${unscoped.map((p) => JSON.stringify(p ?? null)).join(", ")}); its matches are not masked`
8525
+ ),
8526
+ "onError"
8527
+ );
8528
+ }
8529
+ return {
8530
+ rule,
8531
+ engine: new RuleEngine({ firewall: [rule], onError }),
8532
+ ...derived,
8533
+ // Optional cheap pre-filter: literal anchor(s) that MUST appear for the (expensive) regex to
8534
+ // have any chance of matching. Lets screenText skip the full scan on responses with no candidate
8535
+ // — the common case — cutting CPU/latency and shrinking the regex/ReDoS surface.
8536
+ // Case-insensitive, and checked against the header values as well as the body, since a rule may
8537
+ // read either.
8538
+ prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
8539
+ };
8540
+ });
8186
8541
  egressRuleSet = egressRules.map((rule) => ({
8187
8542
  rule,
8188
8543
  engine: new RuleEngine({ firewall: [rule], onError })
@@ -8196,6 +8551,9 @@ async function createProtection(options = {}) {
8196
8551
  skipCounts[key] = (skipCounts[key] ?? 0) + 1;
8197
8552
  notify(onSkip, { phase, reason, detail, count: skipCounts[key] }, "onSkip");
8198
8553
  };
8554
+ const recordEvaluationSkips = (result) => {
8555
+ for (const reason of result?.skips ?? []) recordSkip("request", reason);
8556
+ };
8199
8557
  const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
8200
8558
  const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
8201
8559
  const planCache = createPlanCache();
@@ -8238,15 +8596,19 @@ async function createProtection(options = {}) {
8238
8596
  });
8239
8597
  return effectiveMode === "block" ? block() : allow2();
8240
8598
  };
8599
+ const decidedByHeaders = (_rule, entry) => Boolean(entry?.redactsHeaders || entry?.blocksOnHeaders);
8600
+ const decidedWithoutBody = (rule, entry) => hardensWithoutBody(rule) || decidedByHeaders(rule, entry);
8241
8601
  const screenText = (text, meta, reqCtx, only) => {
8242
8602
  let blockRule = null;
8243
8603
  const redactions = [];
8244
8604
  const headerMutations = [];
8245
8605
  let lowerText = null;
8246
- for (const { rule, engine: re, redactors, prefilter, mutatedSpan } of responseRuleSet) {
8247
- if (only && !only(rule)) continue;
8606
+ const responseSkips = /* @__PURE__ */ new Set();
8607
+ for (const entry of responseRuleSet) {
8608
+ const { rule, engine: re, redactors, prefilter, mutatedSpan } = entry;
8609
+ if (only && !only(rule, entry)) continue;
8248
8610
  if (prefilter) {
8249
- if (lowerText === null) lowerText = text.toLowerCase();
8611
+ if (lowerText === null) lowerText = prefilterText(text, meta.headers);
8250
8612
  if (!prefilter.some((p) => lowerText.includes(p))) continue;
8251
8613
  }
8252
8614
  let result;
@@ -8256,6 +8618,11 @@ async function createProtection(options = {}) {
8256
8618
  notify(onError, err, "onError");
8257
8619
  continue;
8258
8620
  }
8621
+ for (const reason of result.skips ?? []) {
8622
+ if (responseSkips.has(reason)) continue;
8623
+ responseSkips.add(reason);
8624
+ recordSkip("response", reason);
8625
+ }
8259
8626
  if (!result.blocked) continue;
8260
8627
  const responseMode = ruleMode(rule);
8261
8628
  onDetect({
@@ -8285,6 +8652,8 @@ async function createProtection(options = {}) {
8285
8652
  if (blockRule) return { verdict: "block" };
8286
8653
  let body = text;
8287
8654
  const headers = { ...meta.headers || {} };
8655
+ let responseIsJson;
8656
+ let structure;
8288
8657
  for (const { rule, redactors } of redactions) {
8289
8658
  const mask = maskFn(rule.category);
8290
8659
  const transform = rule.action === "encode" ? htmlEscape : null;
@@ -8293,23 +8662,32 @@ async function createProtection(options = {}) {
8293
8662
  if (pathRedactors.length) body = applyPathRedactors(body, pathRedactors, mask, screenCap, transform);
8294
8663
  if (!spanRedactors.length) continue;
8295
8664
  const beforeSpan = body;
8296
- body = applyRedactors(body, spanRedactors, mask, transform);
8297
- if (body !== beforeSpan && !preservesJsonStructure(beforeSpan, body)) return { verdict: "block" };
8665
+ const bodySpans = spanRedactors.filter((r) => r.scope.body === true);
8666
+ if (bodySpans.length) body = applyRedactors(body, bodySpans, mask, transform);
8667
+ if (body !== beforeSpan) {
8668
+ responseIsJson ??= isJson(text);
8669
+ if (responseIsJson) {
8670
+ if (structure?.text !== beforeSpan) structure = { text: beforeSpan, tokens: lexJson(beforeSpan) };
8671
+ const tokens = lexJson(body);
8672
+ if (!sameJsonStructure(structure.text, structure.tokens, body, tokens)) return { verdict: "block" };
8673
+ structure = { text: body, tokens };
8674
+ }
8675
+ }
8298
8676
  if (transform) continue;
8299
8677
  for (const name of Object.keys(headers)) {
8300
8678
  const value = headers[name];
8679
+ const headerSpans = spanRedactors.filter((r) => masksHeader(r, name));
8301
8680
  if (typeof value === "string") {
8302
- setOwn(headers, name, applyRedactors(value, spanRedactors, mask));
8681
+ setOwn(headers, name, applyRedactors(value, headerSpans, mask));
8303
8682
  } else if (Array.isArray(value)) {
8304
8683
  setOwn(
8305
8684
  headers,
8306
8685
  name,
8307
- value.map((item) => typeof item === "string" ? applyRedactors(item, spanRedactors, mask) : item)
8686
+ value.map((item) => typeof item === "string" ? applyRedactors(item, headerSpans, mask) : item)
8308
8687
  );
8309
8688
  }
8310
8689
  }
8311
8690
  }
8312
- if (body !== text && isJson(text) && !isJson(body)) return { verdict: "block" };
8313
8691
  for (const rule of headerMutations) applyHeaderMutation(headers, rule);
8314
8692
  return { verdict: "redact", body, headers };
8315
8693
  };
@@ -8352,18 +8730,53 @@ async function createProtection(options = {}) {
8352
8730
  }
8353
8731
  };
8354
8732
  };
8355
- const screenFetchRequest = async (request) => {
8733
+ const peerOf = (request, hostArgs) => {
8734
+ if (typeof options.peerAddress !== "function") return void 0;
8735
+ try {
8736
+ return options.peerAddress(request?.[SOURCE_REQUEST] ?? request, ...hostArgs);
8737
+ } catch (err) {
8738
+ notify(onError, err, "onError");
8739
+ return void 0;
8740
+ }
8741
+ };
8742
+ let warnedNoPeer = false;
8743
+ const warnNoPeer = () => {
8744
+ if (warnedNoPeer) return;
8745
+ warnedNoPeer = true;
8746
+ const message = "[patchstack] trustedProxy is set, but this Fetch runtime supplied no peer address, so forwarded headers are not used and client addresses read as unavailable. Pass { peerAddress } to supply one.";
8747
+ if (typeof onError === "function") notify(onError, new Error(message), "onError");
8748
+ else console.warn(message);
8749
+ };
8750
+ const clientByRequest = /* @__PURE__ */ new WeakMap();
8751
+ const clientForResponse = (request, hostArgs) => {
8752
+ const known = clientByRequest.get(request);
8753
+ if (known) return known;
8754
+ const client = resolveClientIp({
8755
+ peer: peerOf(request, hostArgs),
8756
+ headers: headerObject(request.headers),
8757
+ trustedProxy: options.trustedProxy
8758
+ });
8759
+ if (options.trustedProxy !== void 0 && client.source === "unavailable") warnNoPeer();
8760
+ return client;
8761
+ };
8762
+ const screenFetchRequest = async (request, hostArgs = []) => {
8356
8763
  let result;
8357
8764
  let shaped;
8358
8765
  try {
8359
8766
  shaped = await fromFetchRequest(request, {
8767
+ peer: peerOf(request, hostArgs),
8360
8768
  trustedProxy: options.trustedProxy,
8361
8769
  maxBodyBytes: options.maxBodyBytes
8362
8770
  });
8771
+ if (shaped?._clientIp) {
8772
+ clientByRequest.set(request, shaped._clientIp);
8773
+ if (options.trustedProxy !== void 0 && shaped._clientIp.source === "unavailable") warnNoPeer();
8774
+ }
8363
8775
  if (shaped?._bodyInspectionSkip) {
8364
8776
  recordSkip("request", shaped._bodyInspectionSkip, { limit: options.maxBodyBytes ?? 1024 * 1024 });
8365
8777
  }
8366
8778
  result = engine.evaluate(shaped);
8779
+ recordEvaluationSkips(result);
8367
8780
  } catch (err) {
8368
8781
  notify(onError, err, "onError");
8369
8782
  return { blocked: null, client: void 0 };
@@ -8413,15 +8826,19 @@ async function createProtection(options = {}) {
8413
8826
  const read = await readTextResponse(response, screenCap);
8414
8827
  if (read.skip) {
8415
8828
  if (read.skip !== "not-a-response") {
8416
- recordSkip("response", read.skip, { status: response?.status, ...read.encoding ? { encoding: read.encoding } : {} });
8829
+ recordSkip("response", read.skip, {
8830
+ status: response?.status,
8831
+ ...read.encoding ? { encoding: read.encoding } : {},
8832
+ ...read.charset ? { charset: read.charset } : {}
8833
+ });
8417
8834
  }
8418
8835
  if (read.skip === "not-a-response") return response;
8419
8836
  return hardenHeadersOnly(response, reqCtx);
8420
8837
  }
8421
- const text = read.text;
8838
+ const text = readJsonEscapes(read.text);
8422
8839
  const r = screenText(text, { status: response.status, headers: headerObject(response.headers) }, reqCtx);
8423
- if (r.verdict === "block") return leakResponse();
8424
- if (r.verdict === "redact") return rebuildResponse(response, r.body, r.headers);
8840
+ if (r.verdict === "block" || r.verdict === "redact" && outgrowsCharset(read.ascii, r.body)) return leakResponse();
8841
+ if (r.verdict === "redact") return rebuildResponse(response, withBom(read.bom, r.body), r.headers);
8425
8842
  return response;
8426
8843
  };
8427
8844
  const headerValueChanged = (was, value) => {
@@ -8483,7 +8900,8 @@ async function createProtection(options = {}) {
8483
8900
  const hardenHeadersOnly = (response, reqCtx) => {
8484
8901
  try {
8485
8902
  const meta = { status: response.status, headers: headerObject(response.headers) };
8486
- const r = screenText("", meta, reqCtx, hardensWithoutBody);
8903
+ const r = screenText("", meta, reqCtx, decidedWithoutBody);
8904
+ if (r.verdict === "block") return leakResponse();
8487
8905
  if (r.verdict !== "redact" || !r.headers) return response;
8488
8906
  if (!headersChanged(meta.headers, r.headers)) return response;
8489
8907
  return rebuildResponse(response, response.body, r.headers);
@@ -8503,6 +8921,8 @@ async function createProtection(options = {}) {
8503
8921
  const chunks = [];
8504
8922
  let size = 0;
8505
8923
  let overflow = false;
8924
+ let withheld = false;
8925
+ let headScreened = false;
8506
8926
  const MAX = screenCap;
8507
8927
  let hardened = false;
8508
8928
  let answeredWithoutBody = false;
@@ -8534,6 +8954,7 @@ async function createProtection(options = {}) {
8534
8954
  }
8535
8955
  };
8536
8956
  const stillToAnswer = (rule) => !hardensWithoutBody(rule);
8957
+ const notYetAnswered = (rule, entry) => (!answeredWithoutBody || stillToAnswer(rule)) && !(headScreened && decidedByHeaders(rule, entry));
8537
8958
  let pendingHead = null;
8538
8959
  let sending = false;
8539
8960
  const origWriteHead = typeof res.writeHead === "function" ? res.writeHead.bind(res) : null;
@@ -8595,7 +9016,12 @@ async function createProtection(options = {}) {
8595
9016
  if (typeof res.flushHeaders === "function") {
8596
9017
  const origFlushHeaders = res.flushHeaders.bind(res);
8597
9018
  res.flushHeaders = function() {
8598
- sendHead();
9019
+ const unread = screenUnreadHead();
9020
+ if (unread?.withhold) {
9021
+ sendWithheld();
9022
+ return void 0;
9023
+ }
9024
+ sendHead(unread);
8599
9025
  return origFlushHeaders();
8600
9026
  };
8601
9027
  }
@@ -8630,6 +9056,32 @@ async function createProtection(options = {}) {
8630
9056
  headers: at === -1 ? set2 : { ...set2, ...writeHeadHeaderObject(pendingHead[at]) }
8631
9057
  };
8632
9058
  };
9059
+ const screenUnreadHead = () => {
9060
+ if (headScreened) return void 0;
9061
+ headScreened = true;
9062
+ try {
9063
+ const head = effectiveHead();
9064
+ const r = screenText("", head, reqCtx, decidedByHeaders);
9065
+ if (r.verdict === "block") {
9066
+ if (!reallySent()) return { withhold: true };
9067
+ recordSkip("response", "headers-sent", { action: "block" });
9068
+ return void 0;
9069
+ }
9070
+ if (r.verdict !== "redact" || !r.headers) return void 0;
9071
+ const changed = /* @__PURE__ */ new Map();
9072
+ for (const [name, value] of Object.entries(r.headers)) {
9073
+ if (headerValueChanged(head.headers[name], value)) changed.set(name.toLowerCase(), value);
9074
+ }
9075
+ if (changed.size && reallySent()) {
9076
+ recordSkip("response", "headers-sent", { headers: [...changed.keys()] });
9077
+ return void 0;
9078
+ }
9079
+ return changed.size ? { headers: changed } : void 0;
9080
+ } catch (err) {
9081
+ notify(onError, err, "onError");
9082
+ return void 0;
9083
+ }
9084
+ };
8633
9085
  const sendHead = (changes) => {
8634
9086
  if (sending) return;
8635
9087
  const hardened2 = hardenOnce();
@@ -8651,6 +9103,17 @@ async function createProtection(options = {}) {
8651
9103
  applyToResponse({ headers: merged });
8652
9104
  origWriteHead(...args);
8653
9105
  };
9106
+ const sendWithheld = (cb) => {
9107
+ const body = JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" });
9108
+ const headers = /* @__PURE__ */ new Map();
9109
+ for (const name of Object.keys(effectiveHead().headers)) headers.set(name.toLowerCase(), null);
9110
+ headers.set("content-type", "application/json");
9111
+ headers.set("content-length", String(Buffer.byteLength(body)));
9112
+ headers.set("transfer-encoding", null);
9113
+ withheld = true;
9114
+ sendHead({ status: 500, headers });
9115
+ return origEnd(body, cb);
9116
+ };
8654
9117
  const applyToResponse = (changes) => {
8655
9118
  if (changes.status !== void 0) res.statusCode = changes.status;
8656
9119
  for (const [name, value] of changes.headers ?? []) {
@@ -8666,7 +9129,12 @@ async function createProtection(options = {}) {
8666
9129
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof enc === "string" ? enc : "utf8");
8667
9130
  size += buf.length;
8668
9131
  if (size > MAX) {
8669
- sendHead();
9132
+ const unread = screenUnreadHead();
9133
+ if (unread?.withhold) {
9134
+ sendWithheld();
9135
+ return;
9136
+ }
9137
+ sendHead(unread);
8670
9138
  for (const c of chunks) origWrite(c);
8671
9139
  chunks.length = 0;
8672
9140
  origWrite(buf);
@@ -8677,6 +9145,11 @@ async function createProtection(options = {}) {
8677
9145
  chunks.push(buf);
8678
9146
  };
8679
9147
  res.write = function(chunk, enc, cb) {
9148
+ if (withheld) {
9149
+ if (typeof enc === "function") enc();
9150
+ else if (typeof cb === "function") cb();
9151
+ return true;
9152
+ }
8680
9153
  if (overflow) return origWrite(chunk, enc, cb);
8681
9154
  collect(chunk, enc);
8682
9155
  if (typeof enc === "function") enc();
@@ -8692,15 +9165,24 @@ async function createProtection(options = {}) {
8692
9165
  cb = enc;
8693
9166
  enc = void 0;
8694
9167
  }
9168
+ if (withheld) {
9169
+ if (typeof cb === "function") cb();
9170
+ return res;
9171
+ }
8695
9172
  if (overflow) {
8696
9173
  if (chunk != null) origWrite(chunk, enc);
8697
9174
  return origEnd(cb);
8698
9175
  }
8699
9176
  collect(chunk, enc);
9177
+ if (withheld) {
9178
+ if (typeof cb === "function") cb();
9179
+ return res;
9180
+ }
8700
9181
  if (overflow) return origEnd(cb);
8701
9182
  hardenOnce();
8702
- const passThrough = () => {
8703
- sendHead();
9183
+ const passThrough = (changes) => {
9184
+ if (changes?.withhold) return sendWithheld(cb);
9185
+ sendHead(changes);
8704
9186
  for (const c of chunks) origWrite(c);
8705
9187
  return origEnd(cb);
8706
9188
  };
@@ -8711,21 +9193,28 @@ async function createProtection(options = {}) {
8711
9193
  const kind = screenableContentType(ct);
8712
9194
  if (kind === "skip" || kind === "sniff" && looksBinary(buffer)) {
8713
9195
  recordSkip("response", kind === "skip" ? baseContentType(ct) === "text/event-stream" ? "live-stream" : "non-text-content-type" : "binary-body");
8714
- return passThrough();
9196
+ return passThrough(screenUnreadHead());
8715
9197
  }
8716
9198
  const codings = declaredCodings(head.headers["content-encoding"]);
8717
9199
  if (codings.length > 0 && stillEncoded(buffer)) {
8718
9200
  recordSkip("response", "encoded-body", { encoding: codings.join(", ") });
8719
- return passThrough();
9201
+ return passThrough(screenUnreadHead());
9202
+ }
9203
+ const reading = charsetReading(ct, buffer);
9204
+ if (reading.skip) {
9205
+ recordSkip("response", "unsupported-charset", { charset: reading.skip });
9206
+ return passThrough(screenUnreadHead());
8720
9207
  }
8721
- const text = buffer.toString("utf8");
9208
+ const text = readJsonEscapes(buffer.toString("utf8", reading.bom ? 3 : 0));
8722
9209
  let r;
8723
9210
  try {
8724
- r = screenText(text, head, reqCtx, answeredWithoutBody ? stillToAnswer : void 0);
9211
+ r = screenText(text, head, reqCtx, notYetAnswered);
8725
9212
  } catch (err) {
8726
9213
  notify(onError, err, "onError");
8727
9214
  return passThrough();
8728
9215
  }
9216
+ if (r.verdict === "redact" && outgrowsCharset(reading.ascii, r.body)) r = { verdict: "block" };
9217
+ if (r.verdict === "redact") r = { ...r, body: withBom(reading.bom, r.body) };
8729
9218
  if (r.verdict !== "block" && r.verdict !== "redact") return passThrough();
8730
9219
  if (reallySent() && res.chunkedEncoding !== true) {
8731
9220
  notify(onError, new Error("Patchstack: response withheld \u2014 its headers were sent before the body could be screened"), "onError");
@@ -8733,20 +9222,11 @@ async function createProtection(options = {}) {
8733
9222
  return res;
8734
9223
  }
8735
9224
  if (r.verdict === "block") {
8736
- const body = JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" });
8737
9225
  if (reallySent()) {
8738
9226
  res.destroy?.();
8739
9227
  return res;
8740
9228
  }
8741
- sendHead({
8742
- status: 500,
8743
- headers: /* @__PURE__ */ new Map([
8744
- ["content-type", "application/json"],
8745
- ["content-length", String(Buffer.byteLength(body))],
8746
- ["transfer-encoding", null]
8747
- ])
8748
- });
8749
- return origEnd(body, cb);
9229
+ return sendWithheld(cb);
8750
9230
  }
8751
9231
  const changed = /* @__PURE__ */ new Map();
8752
9232
  if (r.headers) {
@@ -8842,21 +9322,15 @@ async function createProtection(options = {}) {
8842
9322
  // .fetch(), and by the Supabase guard on its forwarded upstream response.
8843
9323
  // A standalone response screen with no request phase of its own — the Supabase guard's forwarded
8844
9324
  // upstream response. It resolves once here, which is the only resolution for this call.
8845
- screenResponse: (response, request) => screenResp(
8846
- response,
8847
- request ? reqContextFromFetch(
8848
- request,
8849
- resolveClientIp({ headers: headerObject(request.headers), trustedProxy: options.trustedProxy })
8850
- ) : void 0
8851
- ),
9325
+ screenResponse: (response, request, ...hostArgs) => screenResp(response, request ? reqContextFromFetch(request, clientForResponse(request, hostArgs)) : void 0),
8852
9326
  // (request) => Response | null (null = allow, caller proceeds). Request phase only.
8853
9327
  fetchGuard() {
8854
- return async (request) => (await screenFetchRequest(request)).blocked;
9328
+ return async (request, ...hostArgs) => (await screenFetchRequest(request, hostArgs)).blocked;
8855
9329
  },
8856
9330
  // Wrap a fetch handler: screens the request, then the response (redact/block).
8857
9331
  fetch(handler) {
8858
9332
  return async (request, ...rest) => {
8859
- const { blocked, client } = await screenFetchRequest(request);
9333
+ const { blocked, client } = await screenFetchRequest(request, rest);
8860
9334
  if (blocked) return blocked;
8861
9335
  const response = await handler(request, ...rest);
8862
9336
  return screenResp(response, reqContextFromFetch(request, client));
@@ -8870,6 +9344,7 @@ async function createProtection(options = {}) {
8870
9344
  const { shaped, client } = shapeExpressRequest(req);
8871
9345
  try {
8872
9346
  result = engine.evaluate(shaped);
9347
+ recordEvaluationSkips(result);
8873
9348
  } catch (err) {
8874
9349
  notify(onError, err, "onError");
8875
9350
  if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
@@ -8904,33 +9379,25 @@ async function createProtection(options = {}) {
8904
9379
  screenNodeRequest(req, res, next, "", req.body);
8905
9380
  return;
8906
9381
  }
8907
- const chunks = [];
8908
- let size = 0;
8909
- let overflow = false;
8910
- req.on("data", (chunk) => {
8911
- size += chunk.length;
8912
- if (size > maxBytes) {
8913
- overflow = true;
9382
+ readBodyPrefix(req, maxBytes, (err, read) => {
9383
+ if (err) {
9384
+ notify(onError, err, "onError");
9385
+ next();
8914
9386
  return;
8915
9387
  }
8916
- chunks.push(chunk);
8917
- });
8918
- req.on("error", (err) => {
8919
- notify(onError, err, "onError");
8920
- next();
8921
- });
8922
- req.on("end", () => {
8923
- if (overflow) recordSkip("request", "body-cap", { bytes: size, limit: maxBytes });
8924
- screenNodeRequest(req, res, next, overflow ? "" : Buffer.concat(chunks).toString("utf8"));
9388
+ if (read.overflow) recordSkip("request", "body-cap", { bytes: read.size, limit: maxBytes });
9389
+ if (read.failed) recordSkip("request", "read-failed", { bytes: read.size });
9390
+ screenNodeRequest(req, res, next, read.text, void 0, read.overflow || read.failed);
8925
9391
  });
8926
9392
  };
8927
- function screenNodeRequest(req, res, next, rawBody, parsedBody) {
9393
+ function screenNodeRequest(req, res, next, rawBody, parsedBody, truncated = false) {
8928
9394
  let shaped;
8929
9395
  let result;
8930
9396
  try {
8931
9397
  shaped = fromNodeRequest(req, rawBody, { trustedProxy: options.trustedProxy });
8932
9398
  if (parsedBody !== void 0 && parsedBody !== null) shaped.body = parsedBody;
8933
9399
  result = engine.evaluate(shaped);
9400
+ recordEvaluationSkips(result);
8934
9401
  } catch (err) {
8935
9402
  notify(onError, err, "onError");
8936
9403
  return next();
@@ -8949,7 +9416,7 @@ async function createProtection(options = {}) {
8949
9416
  }
8950
9417
  },
8951
9418
  () => {
8952
- if (req.body === void 0) req.body = shaped.body;
9419
+ if (req.body === void 0 && !truncated) req.body = shaped.body;
8953
9420
  if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, shaped?._clientIp));
8954
9421
  next();
8955
9422
  },
@@ -9015,12 +9482,17 @@ async function createProtection(options = {}) {
9015
9482
  protection.stop = () => {
9016
9483
  loop?.stop();
9017
9484
  recovery?.stop();
9485
+ protection.uninstallEgress?.();
9018
9486
  const outstanding = [firewallLog?.stop(), detections?.stop()].filter(
9019
9487
  (wait) => wait && typeof wait.then === "function"
9020
9488
  );
9021
9489
  return Promise.all(outstanding).then(() => void 0);
9022
9490
  };
9023
- protection.stopRefresh = protection.stop;
9491
+ protection.stopRefresh = () => {
9492
+ loop?.stop();
9493
+ recovery?.stop();
9494
+ return Promise.resolve();
9495
+ };
9024
9496
  Object.defineProperty(protection, "ruleSource", {
9025
9497
  get: () => ({ ...ruleSource }),
9026
9498
  enumerable: true
@@ -9033,6 +9505,10 @@ async function createProtection(options = {}) {
9033
9505
  get: () => detections ? () => detections.health() : void 0,
9034
9506
  enumerable: true
9035
9507
  });
9508
+ Object.defineProperty(protection, "blockLogHealth", {
9509
+ get: () => firewallLog ? () => firewallLog.health() : void 0,
9510
+ enumerable: true
9511
+ });
9036
9512
  return protection;
9037
9513
  }
9038
9514
  function accessorOf(obj, name) {
@@ -9107,6 +9583,14 @@ function screenableContentType(ct) {
9107
9583
  if (base === "application/octet-stream") return "sniff";
9108
9584
  return "skip";
9109
9585
  }
9586
+ function prefilterText(body, headers) {
9587
+ const values = [];
9588
+ for (const value of Object.values(headers ?? {})) {
9589
+ if (Array.isArray(value)) values.push(...value.map(String));
9590
+ else if (value !== void 0 && value !== null) values.push(String(value));
9591
+ }
9592
+ return [body, ...values].join("\n").toLowerCase();
9593
+ }
9110
9594
  function looksBinary(bytes) {
9111
9595
  const n = Math.min(bytes.length, 512);
9112
9596
  let ctrl = 0;
@@ -9130,6 +9614,52 @@ function stillEncoded(bytes) {
9130
9614
  return true;
9131
9615
  }
9132
9616
  }
9617
+ var UTF8_CHARSETS = /* @__PURE__ */ new Set(["utf-8", "utf8", "unicode-1-1-utf-8", "us-ascii", "ascii"]);
9618
+ var WIDE_CHARSET = /^(?:utf-?(?:16|32)|ucs-?[24]|unicode|iso-10646)/;
9619
+ function declaredCharset(ct) {
9620
+ const m = /;\s*charset\s*=\s*(?:"([^"]*)"|([^;\s]*))/i.exec(String(ct ?? ""));
9621
+ const label = m ? (m[1] ?? m[2] ?? "").trim().toLowerCase() : "";
9622
+ return label || null;
9623
+ }
9624
+ function charsetReading(ct, bytes) {
9625
+ if (bytes.length >= 3 && bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191) return { ascii: false, bom: true };
9626
+ if (bytes.length >= 2 && bytes[0] === 255 && bytes[1] === 254) return { skip: "utf-16le" };
9627
+ if (bytes.length >= 2 && bytes[0] === 254 && bytes[1] === 255) return { skip: "utf-16be" };
9628
+ const charset = declaredCharset(ct);
9629
+ if (!charset || UTF8_CHARSETS.has(charset)) return { ascii: false };
9630
+ if (WIDE_CHARSET.test(charset)) return { skip: charset };
9631
+ for (let i = 0; i < bytes.length; i++) {
9632
+ const b = bytes[i];
9633
+ if (b >= 128 || b === 0 || b === 27) return { skip: charset };
9634
+ }
9635
+ return { ascii: true };
9636
+ }
9637
+ function decodeForScreening(ct, bytes) {
9638
+ const reading = charsetReading(ct, bytes);
9639
+ if (reading.skip) return { skip: "unsupported-charset", charset: reading.skip };
9640
+ return { text: new TextDecoder().decode(bytes), ascii: reading.ascii, bom: reading.bom === true };
9641
+ }
9642
+ function withBom(bom, body) {
9643
+ return bom ? "\uFEFF" + body : body;
9644
+ }
9645
+ function outgrowsCharset(ascii, body) {
9646
+ return ascii === true && /[^\x00-\x7f]/.test(body);
9647
+ }
9648
+ var KEPT_ESCAPES = /* @__PURE__ */ new Set([34, 92, 60, 62, 38, 39]);
9649
+ function readJsonEscapes(text) {
9650
+ if (!text.includes("\\u") && !text.includes("\\/")) return text;
9651
+ try {
9652
+ JSON.parse(text);
9653
+ } catch {
9654
+ return text;
9655
+ }
9656
+ return text.replace(/\\(?:u([0-9a-fA-F]{4})|([\s\S]))/g, (whole, hex, ch, offset) => {
9657
+ if (hex === void 0) return ch === "/" && text[offset - 1] !== "<" ? "/" : whole;
9658
+ const code = parseInt(hex, 16);
9659
+ if (code < 32 || code > 126 || KEPT_ESCAPES.has(code)) return whole;
9660
+ return String.fromCharCode(code);
9661
+ });
9662
+ }
9133
9663
  async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
9134
9664
  if (!response || typeof response.clone !== "function") return { skip: "not-a-response" };
9135
9665
  const ct = response.headers?.get?.("content-type") || "";
@@ -9183,17 +9713,18 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
9183
9713
  const bytes = concatBytes(chunks, size);
9184
9714
  if (codings.length > 0 && stillEncoded(bytes)) return encodedSkip();
9185
9715
  try {
9186
- return { text: new TextDecoder().decode(bytes) };
9716
+ return decodeForScreening(ct, bytes);
9187
9717
  } catch {
9188
9718
  return { skip: "decode-failed" };
9189
9719
  }
9190
9720
  }
9191
9721
  try {
9192
- if (codings.length > 0) {
9722
+ if (codings.length > 0 || typeof clone.arrayBuffer === "function") {
9193
9723
  const bytes = new Uint8Array(await clone.arrayBuffer());
9194
9724
  if (bytes.byteLength > cap) return { skip: "body-cap" };
9195
- if (stillEncoded(bytes)) return encodedSkip();
9196
- return { text: new TextDecoder().decode(bytes) };
9725
+ if (codings.length > 0 && stillEncoded(bytes)) return encodedSkip();
9726
+ if (codings.length === 0 && sniff && looksBinary(bytes)) return { skip: "binary-body" };
9727
+ return decodeForScreening(ct, bytes);
9197
9728
  }
9198
9729
  const text = await clone.text();
9199
9730
  if (text.length > cap) return { skip: "body-cap" };
@@ -9224,8 +9755,9 @@ function headerObject(headers) {
9224
9755
  function hasSpanMutations(rule) {
9225
9756
  let found = false;
9226
9757
  const walk2 = (conds) => {
9227
- for (const c of conds ?? []) {
9758
+ for (const c of Array.isArray(conds) ? conds : []) {
9228
9759
  if (found) return;
9760
+ if (!c || typeof c !== "object") continue;
9229
9761
  if (Array.isArray(c.rules)) walk2(c.rules);
9230
9762
  const isSpan = c.match && (c.match.type === "regex" || c.match.type === "contains" || c.match.type === "stripos");
9231
9763
  if (isSpan && Array.isArray(c.mutations) && c.mutations.length) found = true;
@@ -9234,10 +9766,34 @@ function hasSpanMutations(rule) {
9234
9766
  walk2(rule.rule_v2);
9235
9767
  return found;
9236
9768
  }
9237
- function extractRedactors(rule) {
9769
+ function redactorScope(parameter) {
9770
+ if (parameter === void 0 || parameter === null || parameterProblem(parameter) !== null) return null;
9771
+ const scope = { body: false, allHeaders: false, headers: /* @__PURE__ */ new Set() };
9772
+ for (const name of Array.isArray(parameter) ? parameter : [parameter]) {
9773
+ if (typeof name !== "string" || name === "") return null;
9774
+ if (name.startsWith("response.header.")) scope.headers.add(name.slice("response.header.".length).toLowerCase());
9775
+ else if (name === "response.headers") scope.allHeaders = true;
9776
+ else if (name === "response.body" || name === "response.status" || BROAD_SCOPE_SOURCES.has(name.split(".")[0])) {
9777
+ scope.body = true;
9778
+ scope.allHeaders = true;
9779
+ } else return null;
9780
+ }
9781
+ return scope;
9782
+ }
9783
+ var BROAD_SCOPE_SOURCES = /* @__PURE__ */ new Set(["get", "post", "request", "cookie", "files", "server", "raw", "all"]);
9784
+ function masksHeader(redactor, name) {
9785
+ return redactor.scope.allHeaders || redactor.scope.headers.has(name);
9786
+ }
9787
+ function extractRedactors(rule, onUnscoped) {
9238
9788
  const out = [];
9789
+ const scoped = (c) => {
9790
+ const scope = redactorScope(c.parameter);
9791
+ if (scope === null) onUnscoped?.(c.parameter);
9792
+ return scope;
9793
+ };
9239
9794
  const walk2 = (conds) => {
9240
- for (const c of conds ?? []) {
9795
+ for (const c of Array.isArray(conds) ? conds : []) {
9796
+ if (!c || typeof c !== "object") continue;
9241
9797
  if (Array.isArray(c.rules)) walk2(c.rules);
9242
9798
  const m = c.match;
9243
9799
  if (!m) continue;
@@ -9245,16 +9801,21 @@ function extractRedactors(rule) {
9245
9801
  const safe = safeRegExp(m.value);
9246
9802
  if (safe) {
9247
9803
  const flags = safe.flags.includes("g") ? safe.flags : safe.flags + "g";
9804
+ const scope = scoped(c);
9248
9805
  try {
9249
- out.push({ re: new RegExp(safe.source, flags) });
9806
+ if (scope) out.push({ re: new RegExp(safe.source, flags), scope });
9250
9807
  } catch {
9251
9808
  }
9252
9809
  }
9253
9810
  } else if ((m.type === "contains" || m.type === "stripos") && m.value != null) {
9254
- out.push({ literal: String(m.value) });
9811
+ const scope = scoped(c);
9812
+ if (scope) out.push({ literal: String(m.value), scope });
9255
9813
  } else if (m.type === "jwt_claim_equals" && typeof m.claim === "string") {
9256
- out.push({ jwtClaim: { claim: m.claim, value: String(m.value ?? "") } });
9257
- } else if (m.type === "array_key_value" && m.match && isBodyParam(c.parameter)) {
9814
+ const scope = scoped(c);
9815
+ if (scope) out.push({ jwtClaim: { claim: m.claim, value: String(m.value ?? "") }, scope });
9816
+ } else if (m.type === "array_key_value" && m.match && Array.isArray(c.parameter) && !readsBody(c.parameter) && redactorScope(c.parameter) === null) {
9817
+ onUnscoped?.(c.parameter);
9818
+ } else if (m.type === "array_key_value" && m.match && readsBody(c.parameter)) {
9258
9819
  const keys = Array.isArray(m.key) ? m.key : [m.key];
9259
9820
  for (const key of keys) {
9260
9821
  out.push({ jsonPath: String(key).split("."), condition: m.match });
@@ -9276,30 +9837,101 @@ function isJson(text) {
9276
9837
  return false;
9277
9838
  }
9278
9839
  }
9279
- function preservesJsonStructure(before, after) {
9280
- if (!isJson(before)) return true;
9281
- if (!isJson(after)) return false;
9282
- const expected = jsonStructure(before);
9283
- const actual = jsonStructure(after);
9284
- for (; ; ) {
9285
- const left = expected.next();
9286
- const right = actual.next();
9287
- if (left.done || right.done) return left.done === right.done;
9288
- if (left.value !== right.value) return false;
9289
- }
9840
+ var JSON_STRING = 1;
9841
+ var JSON_NUMBER = 2;
9842
+ var JSON_LITERAL = 3;
9843
+ var JSON_NUMBER_TOKEN = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
9844
+ function isHexCode(c) {
9845
+ return c >= 48 && c <= 57 || c >= 65 && c <= 70 || c >= 97 && c <= 102;
9290
9846
  }
9291
- function* jsonStructure(text) {
9292
- const tokens = /"(?:[^"\\]|\\[\s\S])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|[^\s]/g;
9293
- for (const match of text.matchAll(tokens)) {
9294
- const token = match[0];
9295
- if (token[0] !== '"') {
9296
- yield "token:" + token;
9847
+ var PLAIN_STRING_SEGMENT = /^[^"\\\u0000-\u001f]*$/;
9848
+ function lexJson(text) {
9849
+ let kinds = new Int32Array(1024);
9850
+ let spans = new Int32Array(2048);
9851
+ let count = 0;
9852
+ const n = text.length;
9853
+ let i = 0;
9854
+ while (i < n) {
9855
+ const c = text.charCodeAt(i);
9856
+ if (c === 32 || c === 9 || c === 10 || c === 13) {
9857
+ i++;
9297
9858
  continue;
9298
9859
  }
9299
- let next = match.index + token.length;
9300
- while (text[next] === " " || text[next] === " " || text[next] === "\r" || text[next] === "\n") next++;
9301
- yield text[next] === ":" ? "key:" + JSON.parse(token) : "string";
9860
+ const start = i;
9861
+ let kind;
9862
+ if (c === 34) {
9863
+ i++;
9864
+ const close = text.indexOf('"', i);
9865
+ if (close === -1) return null;
9866
+ if (PLAIN_STRING_SEGMENT.test(text.slice(i, close))) {
9867
+ i = close;
9868
+ } else {
9869
+ for (; ; ) {
9870
+ if (i >= n) return null;
9871
+ const d = text.charCodeAt(i);
9872
+ if (d === 34) break;
9873
+ if (d < 32) return null;
9874
+ if (d !== 92) {
9875
+ i++;
9876
+ continue;
9877
+ }
9878
+ const e = text.charCodeAt(i + 1);
9879
+ if (e === 117) {
9880
+ for (let k = 2; k < 6; k++) if (!isHexCode(text.charCodeAt(i + k))) return null;
9881
+ i += 6;
9882
+ } else if (e === 34 || e === 92 || e === 47 || e === 98 || e === 102 || e === 110 || e === 114 || e === 116) {
9883
+ i += 2;
9884
+ } else {
9885
+ return null;
9886
+ }
9887
+ }
9888
+ }
9889
+ i++;
9890
+ kind = JSON_STRING;
9891
+ } else if (c === 123 || c === 125 || c === 91 || c === 93 || c === 58 || c === 44) {
9892
+ i++;
9893
+ kind = c;
9894
+ } else if (text.startsWith("true", i) || text.startsWith("null", i)) {
9895
+ i += 4;
9896
+ kind = JSON_LITERAL;
9897
+ } else if (text.startsWith("false", i)) {
9898
+ i += 5;
9899
+ kind = JSON_LITERAL;
9900
+ } else {
9901
+ JSON_NUMBER_TOKEN.lastIndex = i;
9902
+ if (!JSON_NUMBER_TOKEN.test(text)) return null;
9903
+ i = JSON_NUMBER_TOKEN.lastIndex;
9904
+ kind = JSON_NUMBER;
9905
+ }
9906
+ if (count === kinds.length) {
9907
+ const grownKinds = new Int32Array(count * 2);
9908
+ grownKinds.set(kinds);
9909
+ kinds = grownKinds;
9910
+ const grownSpans = new Int32Array(count * 4);
9911
+ grownSpans.set(spans);
9912
+ spans = grownSpans;
9913
+ }
9914
+ kinds[count] = kind;
9915
+ spans[2 * count] = start;
9916
+ spans[2 * count + 1] = i;
9917
+ count++;
9918
+ }
9919
+ return { kinds, spans, count };
9920
+ }
9921
+ function sameJsonStructure(beforeText, before, afterText, after) {
9922
+ if (!before || !after || before.count !== after.count) return false;
9923
+ const { kinds, spans, count } = before;
9924
+ for (let k = 0; k < count; k++) {
9925
+ const kind = kinds[k];
9926
+ if (after.kinds[k] !== kind) return false;
9927
+ if (kind > JSON_LITERAL) continue;
9928
+ if (kind === JSON_STRING && kinds[k + 1] !== 58) continue;
9929
+ const was = beforeText.slice(spans[2 * k], spans[2 * k + 1]);
9930
+ const now = afterText.slice(after.spans[2 * k], after.spans[2 * k + 1]);
9931
+ if (was === now) continue;
9932
+ if (kind !== JSON_STRING || JSON.parse(was) !== JSON.parse(now)) return false;
9302
9933
  }
9934
+ return true;
9303
9935
  }
9304
9936
  function applyRedactors(body, redactors, mask, transform) {
9305
9937
  let out = body;
@@ -9316,6 +9948,11 @@ function applyRedactors(body, redactors, mask, transform) {
9316
9948
  }
9317
9949
  return out;
9318
9950
  }
9951
+ function readsBody(parameter) {
9952
+ if (!Array.isArray(parameter)) return isBodyParam(parameter);
9953
+ const names = parameter.every((member) => typeof member === "string" && member !== "");
9954
+ return names && parameterProblem(parameter) === null && parameter.some((member) => isBodyParam(member));
9955
+ }
9319
9956
  function isBodyParam(parameter) {
9320
9957
  return parameter == null || parameter === "response.body" || parameter === "raw" || parameter === "response.raw";
9321
9958
  }
@@ -9347,7 +9984,7 @@ function applyPathRedactors(text, pathRedactors, mask, cap, transform) {
9347
9984
  let obj;
9348
9985
  try {
9349
9986
  JSON.parse(text);
9350
- preserved = preserveBigInts(text, mask);
9987
+ preserved = preserveNumbers(text, mask);
9351
9988
  obj = JSON.parse(preserved.text);
9352
9989
  } catch {
9353
9990
  return text;
@@ -9366,22 +10003,22 @@ function applyPathRedactors(text, pathRedactors, mask, cap, transform) {
9366
10003
  }
9367
10004
  });
9368
10005
  }
9369
- return changed ? restoreBigInts(JSON.stringify(obj), preserved.numbers) : text;
10006
+ return changed ? restoreNumbers(JSON.stringify(obj), preserved.numbers) : text;
9370
10007
  }
9371
- function preserveBigInts(text, mask) {
10008
+ function preserveNumbers(text, mask) {
9372
10009
  const occupied = /* @__PURE__ */ new Set([mask]);
9373
- const integers = [];
10010
+ const found = [];
9374
10011
  const tokens = /"(?:[^"\\]|\\[\s\S])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/g;
9375
10012
  for (const match of text.matchAll(tokens)) {
9376
10013
  const token = match[0];
9377
10014
  if (token[0] === '"') occupied.add(JSON.parse(token));
9378
- else if (/^-?\d{16,}$/.test(token)) integers.push({ start: match.index, token });
10015
+ else found.push({ start: match.index, token });
9379
10016
  }
9380
10017
  const numbers = /* @__PURE__ */ new Map();
9381
10018
  const chunks = [];
9382
10019
  let offset = 0;
9383
10020
  let index = 0;
9384
- for (const { start, token } of integers) {
10021
+ for (const { start, token } of found) {
9385
10022
  let marker;
9386
10023
  do {
9387
10024
  marker = "__PSNUMBER_" + index++ + "__";
@@ -9393,7 +10030,7 @@ function preserveBigInts(text, mask) {
9393
10030
  chunks.push(text.slice(offset));
9394
10031
  return { text: chunks.join(""), numbers };
9395
10032
  }
9396
- function restoreBigInts(text, numbers) {
10033
+ function restoreNumbers(text, numbers) {
9397
10034
  return text.replace(/"(__PSNUMBER_\d+__)"/g, (token, marker) => numbers.get(marker) ?? token);
9398
10035
  }
9399
10036
  function isHeaderMutation(action) {