@patchstack/connect 0.3.29 → 0.3.31

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.
Files changed (40) hide show
  1. package/AGENT-INSTALL.md +42 -23
  2. package/README.md +40 -11
  3. package/dist/{chunk-MJOTFUDE.js → chunk-LLKP5EJS.js} +1 -1
  4. package/dist/chunk-LLKP5EJS.js.map +1 -0
  5. package/dist/cli.js +1265 -287
  6. package/dist/cli.js.map +1 -1
  7. package/dist/index.cjs +456 -111
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.cts +76 -10
  10. package/dist/index.d.ts +76 -10
  11. package/dist/index.js +449 -104
  12. package/dist/index.js.map +1 -1
  13. package/dist/protect/templates/astro-middleware.ts +33 -13
  14. package/dist/protect/templates/demo-rules.json +2 -3
  15. package/dist/protect/templates/express-guard.cjs +26 -15
  16. package/dist/protect/templates/express-guard.js +26 -15
  17. package/dist/protect/templates/express-guard.ts +33 -16
  18. package/dist/protect/templates/fastify-plugin.cjs +33 -11
  19. package/dist/protect/templates/fastify-plugin.js +33 -11
  20. package/dist/protect/templates/fastify-plugin.ts +40 -12
  21. package/dist/protect/templates/generic-guard.cjs +28 -12
  22. package/dist/protect/templates/generic-guard.js +28 -13
  23. package/dist/protect/templates/generic-guard.ts +41 -20
  24. package/dist/protect/templates/guard.ts +47 -27
  25. package/dist/protect/templates/next-middleware.ts +29 -12
  26. package/dist/protect/templates/nuxt-middleware.ts +29 -12
  27. package/dist/protect/templates/rules.json +2 -2
  28. package/dist/protect/templates/sveltekit-hooks.ts +33 -13
  29. package/dist/protect.cjs +1204 -252
  30. package/dist/protect.cjs.map +1 -1
  31. package/dist/protect.d.ts +39 -11
  32. package/dist/protect.edge.js +792 -143
  33. package/dist/protect.edge.js.map +4 -4
  34. package/dist/protect.js +794 -145
  35. package/dist/protect.js.map +1 -1
  36. package/dist/{refresh-manifest-VRBE6RH6.js → refresh-manifest-KNZQBC4V.js} +387 -100
  37. package/dist/refresh-manifest-KNZQBC4V.js.map +1 -0
  38. package/package.json +5 -2
  39. package/dist/chunk-MJOTFUDE.js.map +0 -1
  40. package/dist/refresh-manifest-VRBE6RH6.js.map +0 -1
package/dist/protect.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  pulseAuthHeader
3
- } from "./chunk-MJOTFUDE.js";
3
+ } from "./chunk-LLKP5EJS.js";
4
4
 
5
5
  // src/protect/safe-origin.js
6
6
  function isSafeOrigin(value) {
@@ -131,6 +131,45 @@ function base64DecodeUtf8(value) {
131
131
  const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
132
132
  return new TextDecoder().decode(bytes);
133
133
  }
134
+ var NAMED_ENTITIES = {
135
+ lt: "<",
136
+ gt: ">",
137
+ amp: "&",
138
+ quot: '"',
139
+ apos: "'",
140
+ "#39": "'",
141
+ nbsp: "\xA0",
142
+ sol: "/",
143
+ bsol: "\\",
144
+ colon: ":",
145
+ lpar: "(",
146
+ rpar: ")",
147
+ equals: "=",
148
+ grave: "`",
149
+ Tab: " ",
150
+ NewLine: "\n",
151
+ semi: ";",
152
+ excl: "!",
153
+ num: "#",
154
+ dollar: "$",
155
+ percnt: "%",
156
+ ast: "*"
157
+ };
158
+ function decodeHtmlEntities(input) {
159
+ return input.replace(/&(#[xX]?[0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);?/g, (whole, body) => {
160
+ if (body[0] === "#") {
161
+ const hex = body[1] === "x" || body[1] === "X";
162
+ const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
163
+ if (!Number.isFinite(code) || code < 0 || code > 1114111) return whole;
164
+ try {
165
+ return String.fromCodePoint(code);
166
+ } catch {
167
+ return whole;
168
+ }
169
+ }
170
+ return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, body) ? NAMED_ENTITIES[body] : whole;
171
+ });
172
+ }
134
173
  var RequestResolver = class {
135
174
  #req;
136
175
  #cookies;
@@ -253,6 +292,8 @@ var RequestResolver = class {
253
292
  } catch {
254
293
  return value;
255
294
  }
295
+ case "htmlentitydecode":
296
+ return decodeHtmlEntities(String(value));
256
297
  case "intval":
257
298
  return parseInt(String(value), 10) || 0;
258
299
  case "getArrayValues":
@@ -920,6 +961,30 @@ function expandIPv6(host) {
920
961
  }
921
962
  return out;
922
963
  }
964
+ var JWT_CANDIDATE = /(?<![A-Za-z0-9_-])(?<![A-Za-z0-9_-]\.)eyJ[A-Za-z0-9_-]{2,2048}\.[A-Za-z0-9_-]{2,8192}\.[A-Za-z0-9_-]{0,2048}(?![A-Za-z0-9_-])(?!\.[A-Za-z0-9_-])/g;
965
+ function base64UrlToBase64(segment) {
966
+ const padded = segment.length % 4 === 0 ? segment : segment + "=".repeat(4 - segment.length % 4);
967
+ return padded.replace(/-/g, "+").replace(/_/g, "/");
968
+ }
969
+ function jwtClaimSpans(text, claim, value) {
970
+ const found = /* @__PURE__ */ new Set();
971
+ if (typeof text !== "string" || typeof claim !== "string" || claim === "") return [];
972
+ const wanted = String(value);
973
+ for (const [token] of text.matchAll(JWT_CANDIDATE)) {
974
+ const parts = token.split(".");
975
+ let payload;
976
+ try {
977
+ payload = JSON.parse(base64DecodeUtf8(base64UrlToBase64(parts[1])));
978
+ } catch {
979
+ continue;
980
+ }
981
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) continue;
982
+ if (!Object.prototype.hasOwnProperty.call(payload, claim)) continue;
983
+ const actual = payload[claim];
984
+ if (typeof actual === "string" && actual === wanted) found.add(token);
985
+ }
986
+ return [...found];
987
+ }
923
988
  var warnedScopes = /* @__PURE__ */ new Set();
924
989
  function warnUnrecognisedScope(when) {
925
990
  const key = Object.keys(when).sort().join(",");
@@ -948,16 +1013,30 @@ function ruleAppliesTo(when, resolver) {
948
1013
  return true;
949
1014
  }
950
1015
  }
1016
+ function foldPathForScope(value) {
1017
+ const out = String(value).toLowerCase();
1018
+ return out.endsWith("/") ? out.slice(0, -1) : out;
1019
+ }
1020
+ var REGEX_SYNTAX = /[\\^$*+?()[\]{}|]/;
1021
+ var REGEX_FORM = /^\/(.+?)\/([gimsuy]*)$/s;
1022
+ function isRegexForm(pattern) {
1023
+ const parts = REGEX_FORM.exec(pattern);
1024
+ return parts !== null && REGEX_SYNTAX.test(parts[1]);
1025
+ }
951
1026
  function pathMatches(pattern, path) {
952
- if (pattern.length > 2 && pattern.startsWith("/") && pattern.endsWith("/")) {
1027
+ if (isRegexForm(pattern)) {
953
1028
  const re = safeRegExp(pattern);
954
1029
  return re ? re.test(path) : false;
955
1030
  }
956
- if (pattern.includes("*")) {
957
- const re = safeRegExp("/^" + pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^?]*") + "$/");
958
- return re ? re.test(path) : false;
1031
+ const target = foldPathForScope(path);
1032
+ const folded = foldPathForScope(pattern);
1033
+ if (folded.includes("*")) {
1034
+ const trailing = folded.endsWith("/*");
1035
+ const body = (trailing ? folded.slice(0, -2) : folded).replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^?]*");
1036
+ const re = safeRegExp("/^" + body + (trailing ? "(?:/[^?]*)?" : "") + "$/");
1037
+ return re ? re.test(target) : false;
959
1038
  }
960
- return path === pattern;
1039
+ return target === folded;
961
1040
  }
962
1041
  function normalizeDefaultPort(host) {
963
1042
  return String(host).toLowerCase().replace(/:(?:80|443)$/, "");
@@ -1110,6 +1189,8 @@ function matchValue(type, value, matchVal, matchObj) {
1110
1189
  const stripped = strValue.replace(/[ _\-,]/g, "");
1111
1190
  return ctypeResult(strValue, /^[\w$€-￿]*$/.test(stripped), matchVal);
1112
1191
  }
1192
+ case "jwt_claim_equals":
1193
+ return jwtClaimSpans(strValue, matchObj?.claim, matchVal).length > 0;
1113
1194
  case "array_key_value":
1114
1195
  return arrayKeyValue(value, matchObj);
1115
1196
  // file_contains (uploaded-file content scanning) is NOT WordPress-specific, but is
@@ -1875,13 +1956,43 @@ var DEFAULT_RESPONSE_RULES = [
1875
1956
  rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:sk_live_[0-9A-Za-z]{16,}|rk_live_[0-9A-Za-z]{16,}|gh[opsu]_[0-9A-Za-z]{36}|github_pat_[0-9A-Za-z_]{60,}|glpat-[0-9A-Za-z_-]{20,}|xox[baprs]-[0-9A-Za-z-]{10,}|sk-ant-[0-9A-Za-z_-]{20,}|ya29\\.[0-9A-Za-z_-]{20,}|npm_[0-9A-Za-z]{36})(?![0-9A-Za-z])/" } }]
1876
1957
  },
1877
1958
  {
1878
- id: "resp-jwt",
1879
- title: "JWT in response body",
1959
+ id: "resp-supabase-secret-key",
1960
+ title: "Supabase secret key in response body",
1961
+ phase: "response",
1962
+ category: "secret-exposure",
1963
+ action: "redact",
1964
+ // Supabase's opaque key format, which is not a JWT and so is not covered by the JWT rule below.
1965
+ // `sb_secret_` is the elevated one — documented as full access, bypassing Row Level Security,
1966
+ // backend-only — while `sb_publishable_` is meant for public clients and must pass untouched.
1967
+ // Both key systems are live at once: new keys sit alongside the legacy `anon` / `service_role`
1968
+ // JWTs rather than replacing them, so this rule and the JWT rule cover different halves.
1969
+ //
1970
+ // The documented grammar is the prefix, 22 base64url characters, `_`, then an 8-character
1971
+ // base64url checksum. Matching it exactly is what keeps the mask on the key and off the response
1972
+ // around it: a variable-length suffix would either run past the key into adjacent syntax or stop
1973
+ // short and leave the tail of a real key visible. The trailing guard rejects a longer run of key
1974
+ // characters, which is not this format.
1975
+ prefilter: ["sb_secret_"],
1976
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\bsb_secret_[A-Za-z0-9_-]{22}_[A-Za-z0-9_-]{8}(?![A-Za-z0-9_-])/" } }]
1977
+ },
1978
+ {
1979
+ id: "resp-supabase-service-role-key",
1980
+ title: "Supabase service_role key in response body",
1880
1981
  phase: "response",
1881
1982
  category: "secret-exposure",
1882
1983
  action: "redact",
1984
+ // Scoped to the one role that must never leave: `service_role` bypasses Row Level Security
1985
+ // entirely. A JWT in a response body is not inherently a leak — the Supabase `anon` key is public
1986
+ // by design and a login response carries the user's own access token — so this decides on the
1987
+ // decoded payload rather than on the shape of the token.
1988
+ //
1989
+ // `jwt_claim_equals` also yields the matching token spans, which is what lets `redact` mask those
1990
+ // tokens and serve the rest of the response.
1991
+ //
1992
+ // A broad any-JWT rule is a reasonable thing to want; it belongs in the managed hardening bundle as
1993
+ // an opt-in, observe-only rule rather than an on-by-default mask.
1883
1994
  prefilter: ["eyJ"],
1884
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\b/" } }]
1995
+ rule_v2: [{ parameter: "response.body", match: { type: "jwt_claim_equals", claim: "role", value: "service_role" } }]
1885
1996
  },
1886
1997
  {
1887
1998
  id: "resp-db-connection-string",
@@ -2106,25 +2217,468 @@ function toEnvelope(value) {
2106
2217
  return null;
2107
2218
  }
2108
2219
 
2109
- // src/protect/rules/validate.js
2110
- var LIMITS = {
2220
+ // src/protect/rules/contract.js
2221
+ var SOURCES = Object.freeze({
2222
+ raw: Object.freeze({ keyed: false }),
2223
+ all: Object.freeze({ keyed: false }),
2224
+ false: Object.freeze({ keyed: false }),
2225
+ rules: Object.freeze({ keyed: false, group: true }),
2226
+ get: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
2227
+ post: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
2228
+ request: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
2229
+ cookie: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
2230
+ // A bare `files.<name>` resolves to the filename; an optional trailing attribute selects another part
2231
+ // of the upload. Optional, not required — filename-scoped rules use the bare form.
2232
+ files: Object.freeze({
2233
+ keyed: true,
2234
+ keys: "any",
2235
+ wildcard: true,
2236
+ optional_key_suffixes: Object.freeze(["content", "filename", "type"])
2237
+ }),
2238
+ server: Object.freeze({
2239
+ keyed: true,
2240
+ keys: Object.freeze([
2241
+ "REQUEST_URI",
2242
+ "REQUEST_METHOD",
2243
+ "HTTP_USER_AGENT",
2244
+ "HTTP_REFERER",
2245
+ "HTTP_HOST",
2246
+ "REMOTE_ADDR",
2247
+ "ip",
2248
+ "CONTENT_TYPE",
2249
+ "CONTENT_LENGTH"
2250
+ ]),
2251
+ key_prefixes: Object.freeze(["HTTP_"])
2252
+ }),
2253
+ response: Object.freeze({
2254
+ keyed: true,
2255
+ keys: Object.freeze(["status", "body", "headers"]),
2256
+ key_prefixes: Object.freeze(["header."])
2257
+ }),
2258
+ egress: Object.freeze({ keyed: true, keys: Object.freeze(["url", "host", "method"]) })
2259
+ });
2260
+ var MATCH_TYPES = Object.freeze([
2261
+ "array_in_array",
2262
+ "array_key_value",
2263
+ "contains",
2264
+ "cors_reflected",
2265
+ "cross_origin",
2266
+ "ctype_alnum",
2267
+ "ctype_digit",
2268
+ "ctype_special",
2269
+ "equals",
2270
+ "equals_strict",
2271
+ "hostname",
2272
+ "in_array",
2273
+ "inline_js_xss",
2274
+ "inline_xss",
2275
+ "internal_host",
2276
+ "is_numeric",
2277
+ "isset",
2278
+ "jwt_claim_equals",
2279
+ "less_than",
2280
+ "more_than",
2281
+ "not_contains",
2282
+ "not_in_array",
2283
+ "off_origin",
2284
+ "quotes",
2285
+ "regex",
2286
+ "stripos"
2287
+ ]);
2288
+ var PARAMETERLESS_MATCH_TYPES = Object.freeze(["cross_origin", "off_origin", "cors_reflected"]);
2289
+ var OPERANDLESS_MATCH_TYPES = Object.freeze([
2290
+ "isset",
2291
+ "quotes",
2292
+ "is_numeric",
2293
+ "ctype_alnum",
2294
+ "ctype_digit",
2295
+ "ctype_special",
2296
+ "inline_xss",
2297
+ "inline_js_xss",
2298
+ "internal_host",
2299
+ "cross_origin",
2300
+ "off_origin",
2301
+ "cors_reflected"
2302
+ ]);
2303
+ var OPERAND_SHAPES = Object.freeze([
2304
+ "positive-number",
2305
+ "boolean",
2306
+ "non-empty-string",
2307
+ "regex-literal",
2308
+ "string-or-string-list",
2309
+ "scalar",
2310
+ "string",
2311
+ "numeric",
2312
+ "scalar-or-non-empty-list",
2313
+ "non-empty-list",
2314
+ "object",
2315
+ "non-empty-object",
2316
+ "non-empty-string-list",
2317
+ "header-name-map",
2318
+ "header-name-list",
2319
+ "cookie-flags"
2320
+ ]);
2321
+ var COOKIE_FLAGS = Object.freeze(["httpOnly", "secure", "sameSite"]);
2322
+ var SAME_SITE_VALUES = Object.freeze(["Strict", "Lax", "None"]);
2323
+ var HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
2324
+ var REGEX_LITERAL = /^\/(.+?)\/([gimsuy]*)$/s;
2325
+ var MATCH_OPERANDS = Object.freeze({
2326
+ array_key_value: Object.freeze({
2327
+ required: Object.freeze(["key", "match"]),
2328
+ shapes: Object.freeze({ key: "string-or-string-list", match: "object" })
2329
+ }),
2330
+ // Decides on a JWT's DECODED payload rather than its shape, and yields the matching token spans so
2331
+ // `redact` masks only those tokens. `claim` names an own, top-level claim; `value` is compared with
2332
+ // exact string equality. Anything not positively identified — undecodable, not JSON, not an object,
2333
+ // oversized, claim missing, claim not a string, or a different value — is a no-match.
2334
+ jwt_claim_equals: Object.freeze({
2335
+ required: Object.freeze(["claim", "value"]),
2336
+ shapes: Object.freeze({ claim: "non-empty-string", value: "non-empty-string" })
2337
+ }),
2338
+ regex: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "regex-literal" }) }),
2339
+ contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
2340
+ stripos: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
2341
+ not_contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
2342
+ // A hostname is compared to `new URL(value).hostname`, so an empty one only ever equals the empty
2343
+ // hostname of an opaque-scheme URL (`file:`, `mailto:`, `data:`) — never a host, and a false positive
2344
+ // on those. The other three are the list-membership family: an empty list makes `in_array` and
2345
+ // `array_in_array` inert and `not_in_array` true for every value.
2346
+ hostname: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
2347
+ in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
2348
+ not_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
2349
+ array_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-list" }) }),
2350
+ // The comparison family. `equals` coerces, so any scalar can match. `equals_strict` does not, and its
2351
+ // subject is always a string by the time it is compared — so a number operand is unconditionally
2352
+ // inert, which is the trap the engine's own note about `'1' !== 1` describes.
2353
+ equals: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar" }) }),
2354
+ equals_strict: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "string" }) }),
2355
+ less_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) }),
2356
+ more_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) })
2357
+ });
2358
+ function operandShapeProblem(shape, value) {
2359
+ const isFilledString = (v) => typeof v === "string" && v.trim() !== "";
2360
+ const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
2361
+ const isHeaderName = (v) => typeof v === "string" && HEADER_NAME.test(v);
2362
+ switch (shape) {
2363
+ case "positive-number":
2364
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? null : "must be a finite positive number";
2365
+ case "boolean":
2366
+ return typeof value === "boolean" ? null : "must be true or false";
2367
+ case "non-empty-string":
2368
+ return isFilledString(value) ? null : "must be a non-empty string";
2369
+ case "regex-literal":
2370
+ if (!isFilledString(value)) return "must be a non-empty string";
2371
+ return REGEX_LITERAL.test(value) ? null : "must be a delimited pattern, as in /pattern/i";
2372
+ case "string-or-string-list":
2373
+ if (isFilledString(value)) return null;
2374
+ if (Array.isArray(value) && value.length > 0 && value.every(isFilledString)) return null;
2375
+ return "must be a non-empty string, or a non-empty list of them";
2376
+ case "scalar":
2377
+ return isScalar(value) ? null : "must be a string, number or boolean";
2378
+ case "string":
2379
+ return typeof value === "string" ? null : "must be a string (this comparison is type-strict)";
2380
+ case "numeric":
2381
+ return isScalar(value) && Number.isFinite(Number(value)) ? null : "must be a number";
2382
+ case "scalar-or-non-empty-list":
2383
+ if (isScalar(value)) return null;
2384
+ if (Array.isArray(value) && value.length > 0 && value.every(isScalar)) return null;
2385
+ return "must be a scalar, or a non-empty list of them";
2386
+ case "non-empty-list":
2387
+ return Array.isArray(value) && value.length > 0 && value.every(isScalar) ? null : "must be a non-empty list of scalars";
2388
+ case "header-name-map": {
2389
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
2390
+ const names = Object.keys(value);
2391
+ if (names.length === 0) return "must be an object with at least one entry";
2392
+ const bad = names.find((name) => !isHeaderName(name));
2393
+ return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
2394
+ }
2395
+ case "header-name-list": {
2396
+ if (!Array.isArray(value) || value.length === 0) return "must be a non-empty list of HTTP header names";
2397
+ const bad = value.find((name) => !isHeaderName(name));
2398
+ return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
2399
+ }
2400
+ case "cookie-flags": {
2401
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
2402
+ const unknown = Object.keys(value).filter((key) => !COOKIE_FLAGS.includes(key));
2403
+ if (unknown.length > 0) return `has no flag named ${unknown.map((k) => JSON.stringify(k)).join(", ")}`;
2404
+ for (const flag of ["httpOnly", "secure"]) {
2405
+ if (value[flag] !== void 0 && typeof value[flag] !== "boolean") {
2406
+ return `flag "${flag}" must be true or false`;
2407
+ }
2408
+ }
2409
+ const sameSite = value.sameSite;
2410
+ const sameSiteOff = sameSite === false;
2411
+ if (sameSite !== void 0 && !sameSiteOff) {
2412
+ if (typeof sameSite !== "string" || !SAME_SITE_VALUES.some((v) => v.toLowerCase() === sameSite.toLowerCase())) {
2413
+ return `flag "sameSite" must be false, or one of ${SAME_SITE_VALUES.join(", ")}`;
2414
+ }
2415
+ }
2416
+ if (value.httpOnly === false && value.secure === false && sameSiteOff) {
2417
+ return "turns off every flag, so it would harden nothing";
2418
+ }
2419
+ return null;
2420
+ }
2421
+ case "object":
2422
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? null : "must be an object";
2423
+ case "non-empty-object":
2424
+ return value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0 ? null : "must be an object with at least one entry";
2425
+ case "non-empty-string-list":
2426
+ return Array.isArray(value) && value.length > 0 && value.every(isFilledString) ? null : "must be a non-empty list of non-empty strings";
2427
+ default:
2428
+ return null;
2429
+ }
2430
+ }
2431
+ var MUTATIONS = Object.freeze([
2432
+ "base64_decode",
2433
+ "getArrayValues",
2434
+ "htmlentitydecode",
2435
+ "intval",
2436
+ "json_decode",
2437
+ "json_encode",
2438
+ "urldecode"
2439
+ ]);
2440
+ var PHASES = Object.freeze(["request", "response", "egress"]);
2441
+ var ACTIONS = Object.freeze([
2442
+ "block",
2443
+ "redact",
2444
+ "encode",
2445
+ "set-header",
2446
+ "remove-header",
2447
+ "harden-cookie"
2448
+ ]);
2449
+ var RULE_PROPERTY_SHAPES = Object.freeze({
2450
+ max_bytes: "positive-number",
2451
+ bypass_limit: "boolean",
2452
+ ensure: "boolean",
2453
+ prefilter: "non-empty-string-list"
2454
+ });
2455
+ var ENFORCEMENT_VALUES = Object.freeze(["dry-run"]);
2456
+ var WHEN_KEYS = Object.freeze(["path", "method"]);
2457
+ var RULE_PROPERTIES = Object.freeze([
2458
+ "id",
2459
+ "rule_id",
2460
+ "title",
2461
+ "category",
2462
+ "phase",
2463
+ "action",
2464
+ "rule_v2",
2465
+ "when",
2466
+ "message",
2467
+ "enforcement",
2468
+ "source_revision",
2469
+ "prefilter",
2470
+ "max_bytes",
2471
+ "bypass_limit",
2472
+ "set_headers",
2473
+ "remove_headers",
2474
+ "cookie_flags",
2475
+ "ensure"
2476
+ ]);
2477
+ var ACTION_PROPERTIES = Object.freeze({
2478
+ block: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["request", "response", "egress"]) }),
2479
+ redact: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
2480
+ encode: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
2481
+ "set-header": Object.freeze({
2482
+ required: Object.freeze(["set_headers"]),
2483
+ defaulted: Object.freeze(["ensure"]),
2484
+ phases: Object.freeze(["response"]),
2485
+ shapes: Object.freeze({ set_headers: "header-name-map" })
2486
+ }),
2487
+ "remove-header": Object.freeze({
2488
+ required: Object.freeze(["remove_headers"]),
2489
+ defaulted: Object.freeze([]),
2490
+ phases: Object.freeze(["response"]),
2491
+ shapes: Object.freeze({ remove_headers: "header-name-list" })
2492
+ }),
2493
+ "harden-cookie": Object.freeze({
2494
+ required: Object.freeze([]),
2495
+ defaulted: Object.freeze(["cookie_flags"]),
2496
+ phases: Object.freeze(["response"]),
2497
+ shapes: Object.freeze({ cookie_flags: "cookie-flags" })
2498
+ })
2499
+ });
2500
+ var LIMITS = Object.freeze({
2111
2501
  maxRules: 5e3,
2112
2502
  maxWhitelists: 2e3,
2113
2503
  maxConditionsPerRule: 250,
2114
2504
  maxNestingDepth: 12,
2115
2505
  maxRegexLength: 1e3,
2116
2506
  maxValueLength: 8192
2117
- };
2118
- var PHASES = /* @__PURE__ */ new Set(["request", "response", "egress"]);
2119
- var ACTIONS = /* @__PURE__ */ new Set(["block", "redact", "encode", "set-header", "remove-header", "harden-cookie"]);
2507
+ });
2508
+ var GROUP_PARAMETER = "rules";
2509
+ function isGroup(condition) {
2510
+ return condition?.parameter === GROUP_PARAMETER && Array.isArray(condition?.rules);
2511
+ }
2512
+ function conditionShapeProblem(condition) {
2513
+ if (!condition || typeof condition !== "object") return "condition is not an object";
2514
+ const named = condition.parameter === GROUP_PARAMETER;
2515
+ const nested = Array.isArray(condition.rules);
2516
+ if (named && !nested) {
2517
+ return `parameter "${GROUP_PARAMETER}" is a group and needs a "rules" array`;
2518
+ }
2519
+ if (nested && !named) {
2520
+ return `condition carries nested rules but its parameter is ${JSON.stringify(condition.parameter)}; a group must be {"parameter":"${GROUP_PARAMETER}"}`;
2521
+ }
2522
+ if (named && nested) {
2523
+ if (condition.match !== void 0) return "a group carries no match of its own; the engine ignores it";
2524
+ if (condition.mutations !== void 0) return "a group carries no mutations of its own; the engine ignores them";
2525
+ if (condition.rules.length === 0) return "group has no conditions (would never match)";
2526
+ }
2527
+ return null;
2528
+ }
2529
+ function parameterProblem(parameter) {
2530
+ if (parameter === void 0 || parameter === null) return null;
2531
+ if (Array.isArray(parameter)) {
2532
+ if (parameter.length === 0) return "parameter list is empty";
2533
+ for (const member of parameter) {
2534
+ const problem = parameterProblem(member);
2535
+ if (problem !== null) return problem;
2536
+ }
2537
+ return null;
2538
+ }
2539
+ if (typeof parameter !== "string" || parameter === "") return "parameter must be a non-empty string";
2540
+ if (parameter === GROUP_PARAMETER) {
2541
+ return `"${GROUP_PARAMETER}" is the group source and is only valid as a whole group, not as a parameter`;
2542
+ }
2543
+ const dot = parameter.indexOf(".");
2544
+ const name = dot === -1 ? parameter : parameter.slice(0, dot);
2545
+ const source = SOURCES[name];
2546
+ if (source === void 0) return `unknown parameter source "${name}"`;
2547
+ if (dot === -1) {
2548
+ return source.keyed === true ? `source "${name}" needs a key, e.g. "${name}.fieldname"` : null;
2549
+ }
2550
+ if (source.keyed !== true) return `source "${name}" takes no key`;
2551
+ const key = parameter.slice(dot + 1);
2552
+ if (key === "") return `source "${name}" needs a key after the dot`;
2553
+ return keyProblem(name, source, key);
2554
+ }
2555
+ function keyProblem(name, source, key) {
2556
+ if (key.endsWith("*") && source.wildcard !== true) {
2557
+ return `"${name}" does not fan out over a wildcard key`;
2558
+ }
2559
+ const bare = key.endsWith("*") ? key.slice(0, -1) : key;
2560
+ if (source.keys === "any") return null;
2561
+ if (Array.isArray(source.keys) && source.keys.includes(bare)) return null;
2562
+ for (const prefix of source.key_prefixes ?? []) {
2563
+ if (bare.startsWith(prefix) && bare.length > prefix.length) return null;
2564
+ }
2565
+ const accepted = [
2566
+ ...Array.isArray(source.keys) ? source.keys : [],
2567
+ ...(source.key_prefixes ?? []).map((p) => `${p}\u2026`)
2568
+ ].join(", ");
2569
+ return `"${name}" has no key "${key}" (expected one of: ${accepted})`;
2570
+ }
2571
+ function matchProblem(match, parameter) {
2572
+ if (!match || typeof match !== "object") return "condition has no match object";
2573
+ if (typeof match.type !== "string" || match.type === "") return "match.type must be a non-empty string";
2574
+ if (!MATCH_TYPES.includes(match.type)) return `unknown match type "${match.type}"`;
2575
+ const parameterless = PARAMETERLESS_MATCH_TYPES.includes(match.type);
2576
+ if (!parameterless && (parameter === void 0 || parameter === null)) {
2577
+ return `match type "${match.type}" needs a parameter`;
2578
+ }
2579
+ if (parameterless && parameter !== void 0 && parameter !== null) {
2580
+ return `match type "${match.type}" reads the whole request and takes no parameter`;
2581
+ }
2582
+ const operands = MATCH_OPERANDS[match.type];
2583
+ if (operands) {
2584
+ for (const required of operands.required) {
2585
+ if (match[required] === void 0 || match[required] === null) {
2586
+ return `match type "${match.type}" needs "${required}"`;
2587
+ }
2588
+ }
2589
+ for (const [operand, shape] of Object.entries(operands.shapes ?? {})) {
2590
+ if (match[operand] === void 0) continue;
2591
+ const problem = operandShapeProblem(shape, match[operand]);
2592
+ if (problem) return `match type "${match.type}" operand "${operand}" ${problem}`;
2593
+ }
2594
+ } else if (!OPERANDLESS_MATCH_TYPES.includes(match.type) && match.value === void 0) {
2595
+ return `match type "${match.type}" needs a value`;
2596
+ }
2597
+ return null;
2598
+ }
2599
+ function mutationsProblem(mutations) {
2600
+ if (mutations === void 0 || mutations === null) return null;
2601
+ if (!Array.isArray(mutations)) return "mutations must be an array";
2602
+ for (const mutation of mutations) {
2603
+ if (typeof mutation !== "string" || !MUTATIONS.includes(mutation)) {
2604
+ return `unknown mutation "${String(mutation)}" \u2014 applied as a no-op, so the condition would test the untransformed value`;
2605
+ }
2606
+ }
2607
+ return null;
2608
+ }
2609
+ function whenProblem(when) {
2610
+ if (when === void 0 || when === null) return null;
2611
+ if (typeof when !== "object" || Array.isArray(when)) return "when must be an object";
2612
+ const keys = Object.keys(when);
2613
+ if (keys.length === 0) return "when is empty \u2014 remove it, or name a path or method";
2614
+ const unknown = keys.filter((key) => !WHEN_KEYS.includes(key));
2615
+ if (unknown.length > 0) {
2616
+ return `when names no supported key (${unknown.join(", ")}) \u2014 the engine understands ${WHEN_KEYS.join(" and ")}, and an unrecognised scope is ignored, so the rule would apply to every request`;
2617
+ }
2618
+ if ("path" in when && !isNonEmptyString(when.path)) {
2619
+ return "when.path must be a non-empty string";
2620
+ }
2621
+ if ("method" in when) {
2622
+ const methods = Array.isArray(when.method) ? when.method : [when.method];
2623
+ if (methods.length === 0) return "when.method is an empty list, so the rule would match no method";
2624
+ if (!methods.every(isNonEmptyString)) return "when.method must be a non-empty string, or a list of them";
2625
+ }
2626
+ return null;
2627
+ }
2628
+ function isNonEmptyString(value) {
2629
+ return typeof value === "string" && value.trim() !== "";
2630
+ }
2631
+ function nullPropertyProblem(rule) {
2632
+ if (!rule || typeof rule !== "object") return null;
2633
+ for (const property of RULE_PROPERTIES) {
2634
+ if (property in rule && rule[property] === null) {
2635
+ return `"${property}" is present but null; omit it to mean the default, since a null is a value that was meant to be something and is not`;
2636
+ }
2637
+ }
2638
+ return null;
2639
+ }
2640
+ function rulePropertyProblem(rule) {
2641
+ if (!rule || typeof rule !== "object") return null;
2642
+ for (const [property, shape] of Object.entries(RULE_PROPERTY_SHAPES)) {
2643
+ if (rule[property] === void 0 || rule[property] === null) continue;
2644
+ const problem = operandShapeProblem(shape, rule[property]);
2645
+ if (problem) return `"${property}" ${problem}`;
2646
+ }
2647
+ return null;
2648
+ }
2649
+ function actionProblem(action, rule) {
2650
+ if (action === void 0 || action === null) return null;
2651
+ if (!ACTIONS.includes(action)) return `unknown action "${action}"`;
2652
+ const spec = ACTION_PROPERTIES[action];
2653
+ for (const required of spec?.required ?? []) {
2654
+ if (rule?.[required] === void 0 || rule?.[required] === null) {
2655
+ return `action "${action}" needs "${required}"`;
2656
+ }
2657
+ }
2658
+ for (const [property, shape] of Object.entries(spec?.shapes ?? {})) {
2659
+ if (rule?.[property] === void 0 || rule?.[property] === null) continue;
2660
+ const problem = operandShapeProblem(shape, rule[property]);
2661
+ if (problem) return `action "${action}" property "${property}" ${problem}`;
2662
+ }
2663
+ const phase = rule?.phase ?? "request";
2664
+ if (spec !== void 0 && !spec.phases.includes(phase)) {
2665
+ return `action "${action}" is carried out on the ${spec.phases.join("/")} phase; on "${phase}" the rule blocks instead`;
2666
+ }
2667
+ return null;
2668
+ }
2669
+
2670
+ // src/protect/rules/validate.js
2671
+ var LIMITS2 = LIMITS;
2672
+ var PHASES2 = new Set(PHASES);
2673
+ var ACTIONS2 = new Set(ACTIONS);
2120
2674
  function validateBundle(bundle, opts = {}) {
2121
2675
  const rejected = [];
2122
2676
  const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
2123
2677
  const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
2124
2678
  const firewall = [];
2125
2679
  for (const rule of inFirewall) {
2126
- if (firewall.length >= LIMITS.maxRules) {
2127
- rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS.maxRules})` });
2680
+ if (firewall.length >= LIMITS2.maxRules) {
2681
+ rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS2.maxRules})` });
2128
2682
  continue;
2129
2683
  }
2130
2684
  const reason = ruleProblem(rule);
@@ -2133,8 +2687,8 @@ function validateBundle(bundle, opts = {}) {
2133
2687
  }
2134
2688
  const whitelists = [];
2135
2689
  for (const wl of inWhitelists) {
2136
- if (whitelists.length >= LIMITS.maxWhitelists) {
2137
- rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS.maxWhitelists})` });
2690
+ if (whitelists.length >= LIMITS2.maxWhitelists) {
2691
+ rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS2.maxWhitelists})` });
2138
2692
  continue;
2139
2693
  }
2140
2694
  if (!opts.allowGlobalWhitelists && wl && Array.isArray(wl.rule_v2) && !wl.rule_id) {
@@ -2156,37 +2710,47 @@ function idOf(rule) {
2156
2710
  }
2157
2711
  function ruleProblem(rule) {
2158
2712
  if (!rule || typeof rule !== "object") return "not an object";
2159
- if (rule.phase !== void 0 && !PHASES.has(rule.phase)) return `unknown phase "${rule.phase}"`;
2160
- if (rule.action !== void 0 && !ACTIONS.has(rule.action)) return `unknown action "${rule.action}"`;
2161
- const capOverride = rule.max_bytes;
2162
- if (capOverride !== void 0 && !(Number(capOverride) > 0)) return "max_bytes must be a positive number";
2713
+ const nullReason = nullPropertyProblem(rule);
2714
+ if (nullReason) return nullReason;
2715
+ if (rule.phase !== void 0 && !PHASES2.has(rule.phase)) return `unknown phase "${rule.phase}"`;
2716
+ const actionReason = actionProblem(rule.action, rule);
2717
+ if (actionReason) return actionReason;
2718
+ const scopeReason = whenProblem(rule.when);
2719
+ if (scopeReason) return scopeReason;
2720
+ const propertyReason = rulePropertyProblem(rule);
2721
+ if (propertyReason) return propertyReason;
2163
2722
  return conditionsProblem(rule.rule_v2);
2164
2723
  }
2165
2724
  function conditionsProblem(conditions, depth = 0) {
2166
2725
  if (!Array.isArray(conditions)) return "rule_v2 must be an array of conditions";
2167
2726
  if (conditions.length === 0) return "rule_v2 is empty (would never match)";
2168
- if (depth > LIMITS.maxNestingDepth) return `nesting deeper than ${LIMITS.maxNestingDepth}`;
2169
- if (conditions.length > LIMITS.maxConditionsPerRule) {
2170
- return `more than ${LIMITS.maxConditionsPerRule} conditions`;
2727
+ if (depth > LIMITS2.maxNestingDepth) return `nesting deeper than ${LIMITS2.maxNestingDepth}`;
2728
+ if (conditions.length > LIMITS2.maxConditionsPerRule) {
2729
+ return `more than ${LIMITS2.maxConditionsPerRule} conditions`;
2171
2730
  }
2172
2731
  for (const c of conditions) {
2173
- if (!c || typeof c !== "object") return "condition is not an object";
2174
- if (Array.isArray(c.rules)) {
2732
+ const shapeReason = conditionShapeProblem(c);
2733
+ if (shapeReason) return shapeReason;
2734
+ if (isGroup(c)) {
2175
2735
  const nested = conditionsProblem(c.rules, depth + 1);
2176
2736
  if (nested) return nested;
2177
2737
  continue;
2178
2738
  }
2739
+ const parameterReason = parameterProblem(c.parameter);
2740
+ if (parameterReason) return parameterReason;
2741
+ const mutationReason = mutationsProblem(c.mutations);
2742
+ if (mutationReason) return mutationReason;
2743
+ const matchReason = matchProblem(c.match, c.parameter);
2744
+ if (matchReason) return matchReason;
2179
2745
  const m = c.match;
2180
- if (!m || typeof m !== "object") return "condition has no match object";
2181
- if (typeof m.type !== "string" || m.type === "") return "match.type must be a non-empty string";
2182
2746
  if (m.type === "regex") {
2183
2747
  if (typeof m.value !== "string") return "regex match.value must be a string";
2184
- if (m.value.length > LIMITS.maxRegexLength) return `regex longer than ${LIMITS.maxRegexLength} chars`;
2185
- } else if (typeof m.value === "string" && m.value.length > LIMITS.maxValueLength) {
2186
- return `match.value longer than ${LIMITS.maxValueLength} chars`;
2748
+ if (m.value.length > LIMITS2.maxRegexLength) return `regex longer than ${LIMITS2.maxRegexLength} chars`;
2749
+ } else if (typeof m.value === "string" && m.value.length > LIMITS2.maxValueLength) {
2750
+ return `match.value longer than ${LIMITS2.maxValueLength} chars`;
2187
2751
  }
2188
2752
  if (m.match) {
2189
- const nested = conditionsProblem([{ match: m.match }], depth + 1);
2753
+ const nested = conditionsProblem([{ parameter: c.parameter, match: m.match }], depth + 1);
2190
2754
  if (nested) return nested;
2191
2755
  }
2192
2756
  }
@@ -2214,64 +2778,67 @@ function reportRejections(rejected, options, label) {
2214
2778
  `${label}: rejected the entire update because ${rejected.length} rule(s) failed validation \u2014 keeping the previous ruleset and NOT caching this response: ${sample}${rejected.length > 3 ? ", \u2026" : ""}`
2215
2779
  ), "onError");
2216
2780
  }
2781
+ function fromSource(bundle, reason) {
2782
+ return reason === void 0 ? { ...bundle, source: { ok: true } } : { ...bundle, source: { ok: false, reason } };
2783
+ }
2217
2784
  async function resolveRules(options, store, ctx = {}) {
2218
2785
  const timeoutMs = ctx.timeoutMs;
2219
2786
  if (options.siteUuid) {
2220
2787
  const prior = await store.read();
2221
2788
  const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, reportsDetections: options.reportDetections === true });
2222
2789
  const res = await client.getRules();
2223
- if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
2790
+ if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
2224
2791
  if (res.success && !res.notModified) {
2225
2792
  const rejected = liveUpdateRejections(res, options);
2226
2793
  if (rejected.length > 0) {
2227
2794
  reportRejections(rejected, options, "rule update rejected");
2228
- if (prior?.bundle) return normalizeBundle(prior.bundle, options);
2229
- if (options.rules) return normalizeBundle(options.rules, options);
2230
- return emptyBundle();
2795
+ if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
2796
+ if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
2797
+ return fromSource(emptyBundle(), "update rejected");
2231
2798
  }
2232
2799
  const bundle = normalizeBundle(res, options);
2233
2800
  await store.write({ bundle, etag: res.etag ?? null });
2234
- return bundle;
2801
+ return fromSource(bundle);
2235
2802
  }
2236
2803
  if (prior?.bundle) {
2237
2804
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
2238
- return normalizeBundle(prior.bundle, options);
2805
+ return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
2239
2806
  }
2240
2807
  if (options.rules) {
2241
2808
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`), "onError");
2242
- return normalizeBundle(options.rules, options);
2809
+ return fromSource(normalizeBundle(options.rules, options), res.error ?? "no usable response");
2243
2810
  }
2244
2811
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
2245
- return emptyBundle();
2812
+ return fromSource(emptyBundle(), res.error ?? "no usable response");
2246
2813
  }
2247
2814
  if (options.token) {
2248
2815
  const prior = await store.read();
2249
2816
  const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs });
2250
2817
  const res = await client.getRules();
2251
- if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
2818
+ if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
2252
2819
  if (res.success && !res.notModified) {
2253
2820
  const rejected = liveUpdateRejections(res, options);
2254
2821
  if (rejected.length > 0) {
2255
2822
  reportRejections(rejected, options, "rule update rejected");
2256
- if (prior?.bundle) return normalizeBundle(prior.bundle, options);
2257
- if (options.rules) return normalizeBundle(options.rules, options);
2258
- return emptyBundle();
2823
+ if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
2824
+ if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
2825
+ return fromSource(emptyBundle(), "update rejected");
2259
2826
  }
2260
2827
  const bundle = normalizeBundle(res, options);
2261
2828
  await store.write({ bundle, etag: res.etag ?? null });
2262
- return bundle;
2829
+ return fromSource(bundle);
2263
2830
  }
2264
2831
  if (prior?.bundle) {
2265
2832
  notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
2266
- return normalizeBundle(prior.bundle, options);
2833
+ return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
2267
2834
  }
2268
2835
  notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
2269
- return emptyBundle();
2836
+ return fromSource(emptyBundle(), res.error ?? "no usable response");
2270
2837
  }
2271
2838
  if (options.rules) {
2272
- return normalizeBundle(options.rules, options);
2839
+ return fromSource(normalizeBundle(options.rules, options));
2273
2840
  }
2274
- return emptyBundle();
2841
+ return fromSource(emptyBundle());
2275
2842
  }
2276
2843
  function normalizeBundle(b, options = {}) {
2277
2844
  const enforcement = b?.enforcement ?? b?.mode;
@@ -2324,8 +2891,9 @@ function startRefresh(tick, { refreshMs, onError } = {}) {
2324
2891
  const run = async () => {
2325
2892
  if (stopped) return;
2326
2893
  try {
2327
- await tick();
2328
- failures = 0;
2894
+ const status = await tick();
2895
+ if (status && status.ok === false) failures++;
2896
+ else failures = 0;
2329
2897
  } catch (err) {
2330
2898
  failures++;
2331
2899
  notify(onError, err, "onError");
@@ -2352,7 +2920,8 @@ function makeRefreshHandler(tick, secret) {
2352
2920
  if (provided !== secret) return new Response("forbidden", { status: 403 });
2353
2921
  let refreshed = true;
2354
2922
  try {
2355
- await tick();
2923
+ const status = await tick();
2924
+ if (status && status.ok === false) refreshed = false;
2356
2925
  } catch {
2357
2926
  refreshed = false;
2358
2927
  }
@@ -2380,6 +2949,12 @@ function ruleParameters(rule) {
2380
2949
  walk(rule?.rule_v2);
2381
2950
  return [...out];
2382
2951
  }
2952
+ function revisionOf(rule) {
2953
+ const revision = rule?.source_revision;
2954
+ if (typeof revision === "string" && revision !== "") return revision;
2955
+ if (typeof revision === "number" && Number.isFinite(revision)) return String(revision);
2956
+ return null;
2957
+ }
2383
2958
  function routeOf(path) {
2384
2959
  if (typeof path !== "string" || path === "") return null;
2385
2960
  const cut = path.search(/[?#]/);
@@ -2388,16 +2963,30 @@ function routeOf(path) {
2388
2963
  function createDetectionReporter(opts) {
2389
2964
  const siteUuid = opts.siteUuid ?? process.env?.PATCHSTACK_SITE_UUID;
2390
2965
  if (!siteUuid) {
2391
- return { record() {
2392
- }, flush() {
2393
- }, stop() {
2394
- }, dropped: () => 0 };
2966
+ return {
2967
+ record() {
2968
+ },
2969
+ flush() {
2970
+ },
2971
+ stop() {
2972
+ },
2973
+ setRulesEtag() {
2974
+ },
2975
+ dropped: () => 0,
2976
+ health: () => ({ sent: 0, delivered: 0, failed: 0, dropped: 0, lastDeliveredAt: null })
2977
+ };
2395
2978
  }
2396
2979
  const configured = opts.baseUrl ?? process.env?.PATCHSTACK_PULSE_RULES_URL;
2397
2980
  const baseUrl = typeof configured === "string" && isSafeOrigin(configured) ? configured.replace(/\/$/, "") : DEFAULT_BASE_URL3;
2398
2981
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
2399
2982
  const flushMs = Number.isFinite(opts.flushMs) && opts.flushMs > 0 ? opts.flushMs : DEFAULT_FLUSH_MS;
2400
2983
  const maxQueue = Number.isFinite(opts.maxQueue) && opts.maxQueue > 0 ? opts.maxQueue : MAX_QUEUE;
2984
+ let rulesEtag = opts.rulesEtag ?? null;
2985
+ let sent = 0;
2986
+ let delivered = 0;
2987
+ let failed = 0;
2988
+ let droppedTotal = 0;
2989
+ let lastDeliveredAt = null;
2401
2990
  let queue = [];
2402
2991
  let timer = null;
2403
2992
  let stopped = false;
@@ -2411,6 +3000,8 @@ function createDetectionReporter(opts) {
2411
3000
  const batch = queue.splice(0, MAX_BATCH);
2412
3001
  const droppedWith = dropped;
2413
3002
  dropped = 0;
3003
+ droppedTotal += droppedWith;
3004
+ sent += batch.length;
2414
3005
  void (async () => {
2415
3006
  try {
2416
3007
  const res = await fetchImpl(`${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, {
@@ -2419,15 +3010,22 @@ function createDetectionReporter(opts) {
2419
3010
  "Content-Type": "application/json",
2420
3011
  Accept: "application/json",
2421
3012
  "User-Agent": "@patchstack/connect",
2422
- // Same credential path as the rules fetch, and unauthenticated when none resolves: the
2423
- // server accepts the UUID, and reporting must never hinge on getting a token.
3013
+ // Same credential path as the rules fetch. The detections endpoint is site-addressed and
3014
+ // requires a verified, site-bound token, so a batch sent without one is refused — which is
3015
+ // why the runtime does not build a reporter when no credential resolves, rather than
3016
+ // posting into a 401.
2424
3017
  ...await pulseAuthHeader({ pulseAuth: opts.pulseAuth, endpoint: baseUrl }, fetchImpl)
2425
3018
  },
2426
3019
  body: JSON.stringify({ detections: batch, dropped: droppedWith })
2427
3020
  });
2428
- if (res && typeof res.then === "function") res.catch(() => {
2429
- });
3021
+ if (res && res.ok) {
3022
+ delivered += batch.length;
3023
+ lastDeliveredAt = (/* @__PURE__ */ new Date()).toISOString();
3024
+ } else {
3025
+ failed += batch.length;
3026
+ }
2430
3027
  } catch {
3028
+ failed += batch.length;
2431
3029
  }
2432
3030
  })();
2433
3031
  };
@@ -2456,7 +3054,12 @@ function createDetectionReporter(opts) {
2456
3054
  // The state this detection was handled under, which is the whole point: `false` is a rule that
2457
3055
  // saw traffic it would have stopped.
2458
3056
  enforced: detection.mode === "block",
2459
- rules_etag: opts.rulesEtag ?? null,
3057
+ rules_etag: rulesEtag,
3058
+ // The revision of THIS rule, as the bundle delivered it. The bundle identity above answers "which
3059
+ // bundle", which changes whenever anything in it changes — so it cannot say whether the counts for
3060
+ // one rule describe the document that rule has now. Passed through untouched, and null when the
3061
+ // bundle carried none.
3062
+ rule_revision: revisionOf(detection.rule),
2460
3063
  detected_at: (/* @__PURE__ */ new Date()).toISOString()
2461
3064
  });
2462
3065
  if (queue.length >= MAX_BATCH) {
@@ -2470,7 +3073,24 @@ function createDetectionReporter(opts) {
2470
3073
  stopped = true;
2471
3074
  flush();
2472
3075
  },
2473
- dropped: () => dropped
3076
+ /**
3077
+ * Point later events at the bundle now running. Called after an ACCEPTED swap only — a rejected
3078
+ * or failed refresh keeps the previous rules, so it must keep the previous identity too.
3079
+ *
3080
+ * Already-queued events are not rewritten: the stamp is taken when an event is recorded, which is
3081
+ * the only moment the two are known to agree.
3082
+ *
3083
+ * @param {string | null | undefined} next
3084
+ */
3085
+ setRulesEtag(next) {
3086
+ rulesEtag = next ?? null;
3087
+ },
3088
+ dropped: () => dropped,
3089
+ /**
3090
+ * Delivery health, counted in events: attempted, acknowledged, refused or unreachable, and dropped
3091
+ * for queue pressure — plus when a batch was last acknowledged. No request data of any kind.
3092
+ */
3093
+ health: () => ({ sent, delivered, failed, dropped: droppedTotal + dropped, lastDeliveredAt })
2474
3094
  };
2475
3095
  }
2476
3096
 
@@ -2735,22 +3355,32 @@ async function createProtection(options = {}) {
2735
3355
  const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
2736
3356
  const pulseAuth = await resolvePulseAuth(options);
2737
3357
  if (options.siteUuid && !pulseAuth) {
2738
- const message = "Patchstack: no API credential resolved for site " + options.siteUuid + ". Rule updates may be rejected and this guard would keep running on its cached rules. Set PATCHSTACK_API_KEY (or pass { pulseAuth }) \u2014 required on runtimes without a filesystem.";
3358
+ const message = "Patchstack: no API credential resolved for site " + options.siteUuid + ". Rule updates will be rejected and this guard would keep running on its cached rules. Set PATCHSTACK_API_KEY (or pass { pulseAuth }) \u2014 required on runtimes without a filesystem.";
2739
3359
  notify(onError, new Error(message), "onError");
2740
3360
  console.warn(message);
2741
3361
  }
2742
3362
  const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth });
3363
+ let detectionReporting = "off";
2743
3364
  if (options.reportDetections === true && options.siteUuid && telemetryEnabled()) {
2744
- detections = createDetectionReporter({
2745
- siteUuid: options.siteUuid,
2746
- baseUrl: options.pulseRulesUrl,
2747
- pulseAuth,
2748
- // The bundle the guard is actually running, so a hit can be attributed to the rules that produced
2749
- // it rather than to whatever is current when the report is read.
2750
- rulesEtag: (await store.read())?.etag ?? null,
2751
- fetchImpl: options.fetchImpl,
2752
- flushMs: options.detectionFlushMs
2753
- });
3365
+ if (!pulseAuth) {
3366
+ detectionReporting = "unavailable-no-credential";
3367
+ const message = "Patchstack: detection reporting is enabled for site " + options.siteUuid + " but no API credential resolved, so no report could be delivered. Reporting is off.";
3368
+ notify(onError, new Error(message), "onError");
3369
+ console.warn(message);
3370
+ } else {
3371
+ detectionReporting = "on";
3372
+ detections = createDetectionReporter({
3373
+ siteUuid: options.siteUuid,
3374
+ baseUrl: options.pulseRulesUrl,
3375
+ pulseAuth,
3376
+ // The bundle the guard is actually running, so a hit can be attributed to the rules that produced
3377
+ // it rather than to whatever is current when the report is read. Kept current across refreshes —
3378
+ // see the refresh tick below.
3379
+ rulesEtag: (await store.read())?.etag ?? null,
3380
+ fetchImpl: options.fetchImpl,
3381
+ flushMs: options.detectionFlushMs
3382
+ });
3383
+ }
2754
3384
  }
2755
3385
  let mode = resolveMode(options, bundle);
2756
3386
  let requestRules;
@@ -3084,11 +3714,17 @@ async function createProtection(options = {}) {
3084
3714
  );
3085
3715
  };
3086
3716
  },
3087
- // Node / Connect middleware — buffers the body itself (request phase).
3717
+ // Node / Connect middleware — buffers the body itself (request phase). Register it BEFORE any body
3718
+ // parser: it reads the request stream, and exposes what it read as `req.body` so a parser is not
3719
+ // also needed. (`.express()` is the other way round — it reads a body somebody else parsed.)
3088
3720
  // Pass { screenResponses: true } to also screen the outgoing response (buffers it).
3089
3721
  node(nodeOptions = {}) {
3090
3722
  const maxBytes = nodeOptions.maxBodyBytes ?? 1024 * 1024;
3091
3723
  return (req, res, next) => {
3724
+ if (req.readableEnded || req.body !== void 0) {
3725
+ screenNodeRequest(req, res, next, "", req.body);
3726
+ return;
3727
+ }
3092
3728
  const chunks = [];
3093
3729
  let size = 0;
3094
3730
  let overflow = false;
@@ -3106,38 +3742,41 @@ async function createProtection(options = {}) {
3106
3742
  });
3107
3743
  req.on("end", () => {
3108
3744
  if (overflow) recordSkip("request", "body-cap", { bytes: size, limit: maxBytes });
3109
- const rawBody = overflow ? "" : Buffer.concat(chunks).toString("utf8");
3110
- let shaped;
3111
- let result;
3112
- try {
3113
- shaped = fromNodeRequest(req, rawBody);
3114
- result = engine.evaluate(shaped);
3115
- } catch (err) {
3116
- notify(onError, err, "onError");
3117
- return next();
3118
- }
3119
- decide(
3120
- "request",
3121
- result,
3122
- () => {
3123
- res.statusCode = 403;
3124
- if (isDocumentNavigation((n) => req.headers?.[n])) {
3125
- res.setHeader("content-type", "text/html; charset=utf-8");
3126
- res.end(renderBlockPage({ url: req.url || "/", code: result?.rule?.id }));
3127
- } else {
3128
- res.setHeader("content-type", "application/json");
3129
- res.end(JSON.stringify(blockBody(result)));
3130
- }
3131
- },
3132
- () => {
3133
- if (req.body === void 0) req.body = shaped.body;
3134
- if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
3135
- next();
3136
- },
3137
- nodeRequestMeta(req)
3138
- );
3745
+ screenNodeRequest(req, res, next, overflow ? "" : Buffer.concat(chunks).toString("utf8"));
3139
3746
  });
3140
3747
  };
3748
+ function screenNodeRequest(req, res, next, rawBody, parsedBody) {
3749
+ let shaped;
3750
+ let result;
3751
+ try {
3752
+ shaped = fromNodeRequest(req, rawBody);
3753
+ if (parsedBody !== void 0 && parsedBody !== null) shaped.body = parsedBody;
3754
+ result = engine.evaluate(shaped);
3755
+ } catch (err) {
3756
+ notify(onError, err, "onError");
3757
+ return next();
3758
+ }
3759
+ decide(
3760
+ "request",
3761
+ result,
3762
+ () => {
3763
+ res.statusCode = 403;
3764
+ if (isDocumentNavigation((n) => req.headers?.[n])) {
3765
+ res.setHeader("content-type", "text/html; charset=utf-8");
3766
+ res.end(renderBlockPage({ url: req.url || "/", code: result?.rule?.id }));
3767
+ } else {
3768
+ res.setHeader("content-type", "application/json");
3769
+ res.end(JSON.stringify(blockBody(result)));
3770
+ }
3771
+ },
3772
+ () => {
3773
+ if (req.body === void 0) req.body = shaped.body;
3774
+ if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
3775
+ next();
3776
+ },
3777
+ nodeRequestMeta(req)
3778
+ );
3779
+ }
3141
3780
  }
3142
3781
  };
3143
3782
  if (options.egress) {
@@ -3158,7 +3797,7 @@ async function createProtection(options = {}) {
3158
3797
  let reporter = null;
3159
3798
  if (refreshable && options.siteUuid && options.reportManifest !== false && cwd) {
3160
3799
  try {
3161
- ({ reportManifest: reporter } = await import("./refresh-manifest-VRBE6RH6.js"));
3800
+ ({ reportManifest: reporter } = await import("./refresh-manifest-KNZQBC4V.js"));
3162
3801
  } catch (err) {
3163
3802
  notify(onError, err, "onError");
3164
3803
  }
@@ -3174,24 +3813,22 @@ async function createProtection(options = {}) {
3174
3813
  const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
3175
3814
  mode = resolveMode(options, next);
3176
3815
  applyBundle(next);
3816
+ if (detections) detections.setRulesEtag((await store.read())?.etag ?? null);
3817
+ return next.source ?? { ok: true };
3177
3818
  };
3178
3819
  if (live) {
3179
3820
  protection.refresh = () => runRefreshTick();
3180
3821
  protection.refreshHandler = () => makeRefreshHandler(runRefreshTick, refreshSecret);
3181
3822
  }
3182
- if (options.refreshMs > 0 && live) {
3183
- const loop = startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError });
3184
- protection.stopRefresh = () => {
3185
- loop.stop();
3186
- firewallLog?.stop();
3187
- detections?.stop();
3188
- };
3189
- } else if (firewallLog) {
3190
- protection.stopRefresh = () => {
3191
- firewallLog.stop();
3192
- detections?.stop();
3193
- };
3194
- }
3823
+ const loop = options.refreshMs > 0 && live ? startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError }) : null;
3824
+ protection.stop = () => {
3825
+ loop?.stop();
3826
+ firewallLog?.stop();
3827
+ detections?.stop();
3828
+ };
3829
+ protection.stopRefresh = protection.stop;
3830
+ protection.detectionReporting = detectionReporting;
3831
+ if (detections) protection.detectionHealth = () => detections.health();
3195
3832
  return protection;
3196
3833
  }
3197
3834
  function resolveMode(options, bundle) {
@@ -3202,22 +3839,35 @@ function resolveMode(options, bundle) {
3202
3839
  if (options?.mode === "dry-run") return "dry-run";
3203
3840
  return "dry-run";
3204
3841
  }
3842
+ var CREDENTIAL_FILES = [".patchstackrc.local.json", ".patchstackrc.json"];
3843
+ async function readCredentialField(options, field) {
3844
+ if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
3845
+ let readFileSync;
3846
+ let join;
3847
+ try {
3848
+ const [fs, pathMod] = await Promise.all([import("fs"), import("path")]);
3849
+ readFileSync = fs.readFileSync;
3850
+ join = pathMod.join;
3851
+ } catch {
3852
+ return void 0;
3853
+ }
3854
+ const cwd = options?.cwd ?? process.cwd();
3855
+ for (const filename of CREDENTIAL_FILES) {
3856
+ try {
3857
+ const value = JSON.parse(readFileSync(join(cwd, filename), "utf8"))?.[field];
3858
+ if (typeof value === "string" && value.length > 0) return value;
3859
+ } catch {
3860
+ }
3861
+ }
3862
+ return void 0;
3863
+ }
3205
3864
  async function resolveApiKey(options) {
3206
3865
  if (typeof options?.apiKey === "string" && options.apiKey.length > 0) return options.apiKey;
3207
3866
  if (typeof process !== "undefined") {
3208
3867
  const fromEnv = process.env?.PATCHSTACK_API_KEY;
3209
3868
  if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
3210
3869
  }
3211
- try {
3212
- if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
3213
- const [{ readFileSync }, { join }] = await Promise.all([import("fs"), import("path")]);
3214
- const cwd = options?.cwd ?? process.cwd();
3215
- const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
3216
- const key = JSON.parse(raw)?.apiKey;
3217
- if (typeof key === "string" && key.length > 0) return key;
3218
- } catch {
3219
- }
3220
- return void 0;
3870
+ return readCredentialField(options, "apiKey");
3221
3871
  }
3222
3872
  async function resolvePulseAuth(options) {
3223
3873
  if (typeof options?.pulseAuth === "string" && options.pulseAuth.length > 0) return options.pulseAuth;
@@ -3225,15 +3875,8 @@ async function resolvePulseAuth(options) {
3225
3875
  const fromEnv = process.env?.PATCHSTACK_PULSE_AUTH;
3226
3876
  if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
3227
3877
  }
3228
- try {
3229
- if (typeof process === "undefined" || typeof process.cwd !== "function") return resolveApiKey(options);
3230
- const [{ readFileSync }, { join }] = await Promise.all([import("fs"), import("path")]);
3231
- const cwd = options?.cwd ?? process.cwd();
3232
- const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
3233
- const key = JSON.parse(raw)?.pulseAuth;
3234
- if (typeof key === "string" && key.length > 0) return key;
3235
- } catch {
3236
- }
3878
+ const fromFile = await readCredentialField(options, "pulseAuth");
3879
+ if (fromFile !== void 0) return fromFile;
3237
3880
  return resolveApiKey(options);
3238
3881
  }
3239
3882
  function byPhase(rules, phase) {
@@ -3366,6 +4009,8 @@ function extractRedactors(rule) {
3366
4009
  }
3367
4010
  } else if ((m.type === "contains" || m.type === "stripos") && m.value != null) {
3368
4011
  out.push({ literal: String(m.value) });
4012
+ } else if (m.type === "jwt_claim_equals" && typeof m.claim === "string") {
4013
+ out.push({ jwtClaim: { claim: m.claim, value: String(m.value ?? "") } });
3369
4014
  } else if (m.type === "array_key_value" && m.match && isBodyParam(c.parameter)) {
3370
4015
  const keys = Array.isArray(m.key) ? m.key : [m.key];
3371
4016
  for (const key of keys) {
@@ -3384,7 +4029,11 @@ function applyRedactors(body, redactors, mask, transform) {
3384
4029
  let out = body;
3385
4030
  for (const r of redactors) {
3386
4031
  if (r.re) out = out.replace(r.re, transform ? (m) => transform(m) : mask);
3387
- else if (r.literal) {
4032
+ else if (r.jwtClaim) {
4033
+ for (const token of jwtClaimSpans(out, r.jwtClaim.claim, r.jwtClaim.value)) {
4034
+ out = out.split(token).join(transform ? transform(token) : mask);
4035
+ }
4036
+ } else if (r.literal) {
3388
4037
  const re = new RegExp(r.literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
3389
4038
  out = out.replace(re, (m) => transform ? transform(m) : mask);
3390
4039
  }
@@ -3518,11 +4167,13 @@ function applyHeaderMutation(headers, rule) {
3518
4167
  }
3519
4168
  }
3520
4169
  }
4170
+ var SAME_SITE = ["Strict", "Lax", "None"];
3521
4171
  function hardenCookie(cookie, { httpOnly = true, secure = true, sameSite = "Lax" } = {}) {
3522
4172
  let out = String(cookie);
3523
4173
  if (httpOnly && !/;\s*httponly/i.test(out)) out += "; HttpOnly";
3524
4174
  if (secure && !/;\s*secure/i.test(out)) out += "; Secure";
3525
- if (sameSite && !/;\s*samesite\s*=/i.test(out)) out += `; SameSite=${sameSite}`;
4175
+ const canonical = SAME_SITE.find((v) => typeof sameSite === "string" && v.toLowerCase() === sameSite.toLowerCase());
4176
+ if (canonical && !/;\s*samesite\s*=/i.test(out)) out += `; SameSite=${canonical}`;
3526
4177
  return out;
3527
4178
  }
3528
4179
  function rebuildResponse(response, body, redactedHeaders) {
@@ -3536,11 +4187,9 @@ function rebuildResponse(response, body, redactedHeaders) {
3536
4187
  } catch {
3537
4188
  }
3538
4189
  } else if (typeof value === "string") {
3539
- if (headers.get(name) !== value) {
3540
- try {
3541
- headers.set(name, value);
3542
- } catch {
3543
- }
4190
+ try {
4191
+ if (headers.get(name) !== value) headers.set(name, value);
4192
+ } catch {
3544
4193
  }
3545
4194
  } else if (Array.isArray(value)) {
3546
4195
  try {