@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
@@ -160,6 +160,45 @@ function base64DecodeUtf8(value) {
160
160
  const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
161
161
  return new TextDecoder().decode(bytes);
162
162
  }
163
+ var NAMED_ENTITIES = {
164
+ lt: "<",
165
+ gt: ">",
166
+ amp: "&",
167
+ quot: '"',
168
+ apos: "'",
169
+ "#39": "'",
170
+ nbsp: "\xA0",
171
+ sol: "/",
172
+ bsol: "\\",
173
+ colon: ":",
174
+ lpar: "(",
175
+ rpar: ")",
176
+ equals: "=",
177
+ grave: "`",
178
+ Tab: " ",
179
+ NewLine: "\n",
180
+ semi: ";",
181
+ excl: "!",
182
+ num: "#",
183
+ dollar: "$",
184
+ percnt: "%",
185
+ ast: "*"
186
+ };
187
+ function decodeHtmlEntities(input) {
188
+ return input.replace(/&(#[xX]?[0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);?/g, (whole, body) => {
189
+ if (body[0] === "#") {
190
+ const hex = body[1] === "x" || body[1] === "X";
191
+ const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
192
+ if (!Number.isFinite(code) || code < 0 || code > 1114111) return whole;
193
+ try {
194
+ return String.fromCodePoint(code);
195
+ } catch {
196
+ return whole;
197
+ }
198
+ }
199
+ return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, body) ? NAMED_ENTITIES[body] : whole;
200
+ });
201
+ }
163
202
  var RequestResolver = class {
164
203
  #req;
165
204
  #cookies;
@@ -282,6 +321,8 @@ var RequestResolver = class {
282
321
  } catch {
283
322
  return value;
284
323
  }
324
+ case "htmlentitydecode":
325
+ return decodeHtmlEntities(String(value));
285
326
  case "intval":
286
327
  return parseInt(String(value), 10) || 0;
287
328
  case "getArrayValues":
@@ -949,6 +990,30 @@ function expandIPv6(host) {
949
990
  }
950
991
  return out;
951
992
  }
993
+ 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;
994
+ function base64UrlToBase64(segment) {
995
+ const padded = segment.length % 4 === 0 ? segment : segment + "=".repeat(4 - segment.length % 4);
996
+ return padded.replace(/-/g, "+").replace(/_/g, "/");
997
+ }
998
+ function jwtClaimSpans(text, claim, value) {
999
+ const found = /* @__PURE__ */ new Set();
1000
+ if (typeof text !== "string" || typeof claim !== "string" || claim === "") return [];
1001
+ const wanted = String(value);
1002
+ for (const [token] of text.matchAll(JWT_CANDIDATE)) {
1003
+ const parts = token.split(".");
1004
+ let payload;
1005
+ try {
1006
+ payload = JSON.parse(base64DecodeUtf8(base64UrlToBase64(parts[1])));
1007
+ } catch {
1008
+ continue;
1009
+ }
1010
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) continue;
1011
+ if (!Object.prototype.hasOwnProperty.call(payload, claim)) continue;
1012
+ const actual = payload[claim];
1013
+ if (typeof actual === "string" && actual === wanted) found.add(token);
1014
+ }
1015
+ return [...found];
1016
+ }
952
1017
  var warnedScopes = /* @__PURE__ */ new Set();
953
1018
  function warnUnrecognisedScope(when) {
954
1019
  const key = Object.keys(when).sort().join(",");
@@ -977,16 +1042,30 @@ function ruleAppliesTo(when, resolver) {
977
1042
  return true;
978
1043
  }
979
1044
  }
1045
+ function foldPathForScope(value) {
1046
+ const out = String(value).toLowerCase();
1047
+ return out.endsWith("/") ? out.slice(0, -1) : out;
1048
+ }
1049
+ var REGEX_SYNTAX = /[\\^$*+?()[\]{}|]/;
1050
+ var REGEX_FORM = /^\/(.+?)\/([gimsuy]*)$/s;
1051
+ function isRegexForm(pattern) {
1052
+ const parts = REGEX_FORM.exec(pattern);
1053
+ return parts !== null && REGEX_SYNTAX.test(parts[1]);
1054
+ }
980
1055
  function pathMatches(pattern, path) {
981
- if (pattern.length > 2 && pattern.startsWith("/") && pattern.endsWith("/")) {
1056
+ if (isRegexForm(pattern)) {
982
1057
  const re = safeRegExp(pattern);
983
1058
  return re ? re.test(path) : false;
984
1059
  }
985
- if (pattern.includes("*")) {
986
- const re = safeRegExp("/^" + pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^?]*") + "$/");
987
- return re ? re.test(path) : false;
1060
+ const target = foldPathForScope(path);
1061
+ const folded = foldPathForScope(pattern);
1062
+ if (folded.includes("*")) {
1063
+ const trailing = folded.endsWith("/*");
1064
+ const body = (trailing ? folded.slice(0, -2) : folded).replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^?]*");
1065
+ const re = safeRegExp("/^" + body + (trailing ? "(?:/[^?]*)?" : "") + "$/");
1066
+ return re ? re.test(target) : false;
988
1067
  }
989
- return path === pattern;
1068
+ return target === folded;
990
1069
  }
991
1070
  function normalizeDefaultPort(host) {
992
1071
  return String(host).toLowerCase().replace(/:(?:80|443)$/, "");
@@ -1139,6 +1218,8 @@ function matchValue(type, value, matchVal, matchObj) {
1139
1218
  const stripped = strValue.replace(/[ _\-,]/g, "");
1140
1219
  return ctypeResult(strValue, /^[\w$€-￿]*$/.test(stripped), matchVal);
1141
1220
  }
1221
+ case "jwt_claim_equals":
1222
+ return jwtClaimSpans(strValue, matchObj?.claim, matchVal).length > 0;
1142
1223
  case "array_key_value":
1143
1224
  return arrayKeyValue(value, matchObj);
1144
1225
  // file_contains (uploaded-file content scanning) is NOT WordPress-specific, but is
@@ -1967,13 +2048,43 @@ var DEFAULT_RESPONSE_RULES = [
1967
2048
  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])/" } }]
1968
2049
  },
1969
2050
  {
1970
- id: "resp-jwt",
1971
- title: "JWT in response body",
2051
+ id: "resp-supabase-secret-key",
2052
+ title: "Supabase secret key in response body",
1972
2053
  phase: "response",
1973
2054
  category: "secret-exposure",
1974
2055
  action: "redact",
2056
+ // Supabase's opaque key format, which is not a JWT and so is not covered by the JWT rule below.
2057
+ // `sb_secret_` is the elevated one — documented as full access, bypassing Row Level Security,
2058
+ // backend-only — while `sb_publishable_` is meant for public clients and must pass untouched.
2059
+ // Both key systems are live at once: new keys sit alongside the legacy `anon` / `service_role`
2060
+ // JWTs rather than replacing them, so this rule and the JWT rule cover different halves.
2061
+ //
2062
+ // The documented grammar is the prefix, 22 base64url characters, `_`, then an 8-character
2063
+ // base64url checksum. Matching it exactly is what keeps the mask on the key and off the response
2064
+ // around it: a variable-length suffix would either run past the key into adjacent syntax or stop
2065
+ // short and leave the tail of a real key visible. The trailing guard rejects a longer run of key
2066
+ // characters, which is not this format.
2067
+ prefilter: ["sb_secret_"],
2068
+ 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_-])/" } }]
2069
+ },
2070
+ {
2071
+ id: "resp-supabase-service-role-key",
2072
+ title: "Supabase service_role key in response body",
2073
+ phase: "response",
2074
+ category: "secret-exposure",
2075
+ action: "redact",
2076
+ // Scoped to the one role that must never leave: `service_role` bypasses Row Level Security
2077
+ // entirely. A JWT in a response body is not inherently a leak — the Supabase `anon` key is public
2078
+ // by design and a login response carries the user's own access token — so this decides on the
2079
+ // decoded payload rather than on the shape of the token.
2080
+ //
2081
+ // `jwt_claim_equals` also yields the matching token spans, which is what lets `redact` mask those
2082
+ // tokens and serve the rest of the response.
2083
+ //
2084
+ // A broad any-JWT rule is a reasonable thing to want; it belongs in the managed hardening bundle as
2085
+ // an opt-in, observe-only rule rather than an on-by-default mask.
1975
2086
  prefilter: ["eyJ"],
1976
- 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/" } }]
2087
+ rule_v2: [{ parameter: "response.body", match: { type: "jwt_claim_equals", claim: "role", value: "service_role" } }]
1977
2088
  },
1978
2089
  {
1979
2090
  id: "resp-db-connection-string",
@@ -2198,25 +2309,468 @@ function toEnvelope(value) {
2198
2309
  return null;
2199
2310
  }
2200
2311
 
2201
- // src/protect/rules/validate.js
2202
- var LIMITS = {
2312
+ // src/protect/rules/contract.js
2313
+ var SOURCES = Object.freeze({
2314
+ raw: Object.freeze({ keyed: false }),
2315
+ all: Object.freeze({ keyed: false }),
2316
+ false: Object.freeze({ keyed: false }),
2317
+ rules: Object.freeze({ keyed: false, group: true }),
2318
+ get: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
2319
+ post: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
2320
+ request: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
2321
+ cookie: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
2322
+ // A bare `files.<name>` resolves to the filename; an optional trailing attribute selects another part
2323
+ // of the upload. Optional, not required — filename-scoped rules use the bare form.
2324
+ files: Object.freeze({
2325
+ keyed: true,
2326
+ keys: "any",
2327
+ wildcard: true,
2328
+ optional_key_suffixes: Object.freeze(["content", "filename", "type"])
2329
+ }),
2330
+ server: Object.freeze({
2331
+ keyed: true,
2332
+ keys: Object.freeze([
2333
+ "REQUEST_URI",
2334
+ "REQUEST_METHOD",
2335
+ "HTTP_USER_AGENT",
2336
+ "HTTP_REFERER",
2337
+ "HTTP_HOST",
2338
+ "REMOTE_ADDR",
2339
+ "ip",
2340
+ "CONTENT_TYPE",
2341
+ "CONTENT_LENGTH"
2342
+ ]),
2343
+ key_prefixes: Object.freeze(["HTTP_"])
2344
+ }),
2345
+ response: Object.freeze({
2346
+ keyed: true,
2347
+ keys: Object.freeze(["status", "body", "headers"]),
2348
+ key_prefixes: Object.freeze(["header."])
2349
+ }),
2350
+ egress: Object.freeze({ keyed: true, keys: Object.freeze(["url", "host", "method"]) })
2351
+ });
2352
+ var MATCH_TYPES = Object.freeze([
2353
+ "array_in_array",
2354
+ "array_key_value",
2355
+ "contains",
2356
+ "cors_reflected",
2357
+ "cross_origin",
2358
+ "ctype_alnum",
2359
+ "ctype_digit",
2360
+ "ctype_special",
2361
+ "equals",
2362
+ "equals_strict",
2363
+ "hostname",
2364
+ "in_array",
2365
+ "inline_js_xss",
2366
+ "inline_xss",
2367
+ "internal_host",
2368
+ "is_numeric",
2369
+ "isset",
2370
+ "jwt_claim_equals",
2371
+ "less_than",
2372
+ "more_than",
2373
+ "not_contains",
2374
+ "not_in_array",
2375
+ "off_origin",
2376
+ "quotes",
2377
+ "regex",
2378
+ "stripos"
2379
+ ]);
2380
+ var PARAMETERLESS_MATCH_TYPES = Object.freeze(["cross_origin", "off_origin", "cors_reflected"]);
2381
+ var OPERANDLESS_MATCH_TYPES = Object.freeze([
2382
+ "isset",
2383
+ "quotes",
2384
+ "is_numeric",
2385
+ "ctype_alnum",
2386
+ "ctype_digit",
2387
+ "ctype_special",
2388
+ "inline_xss",
2389
+ "inline_js_xss",
2390
+ "internal_host",
2391
+ "cross_origin",
2392
+ "off_origin",
2393
+ "cors_reflected"
2394
+ ]);
2395
+ var OPERAND_SHAPES = Object.freeze([
2396
+ "positive-number",
2397
+ "boolean",
2398
+ "non-empty-string",
2399
+ "regex-literal",
2400
+ "string-or-string-list",
2401
+ "scalar",
2402
+ "string",
2403
+ "numeric",
2404
+ "scalar-or-non-empty-list",
2405
+ "non-empty-list",
2406
+ "object",
2407
+ "non-empty-object",
2408
+ "non-empty-string-list",
2409
+ "header-name-map",
2410
+ "header-name-list",
2411
+ "cookie-flags"
2412
+ ]);
2413
+ var COOKIE_FLAGS = Object.freeze(["httpOnly", "secure", "sameSite"]);
2414
+ var SAME_SITE_VALUES = Object.freeze(["Strict", "Lax", "None"]);
2415
+ var HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
2416
+ var REGEX_LITERAL = /^\/(.+?)\/([gimsuy]*)$/s;
2417
+ var MATCH_OPERANDS = Object.freeze({
2418
+ array_key_value: Object.freeze({
2419
+ required: Object.freeze(["key", "match"]),
2420
+ shapes: Object.freeze({ key: "string-or-string-list", match: "object" })
2421
+ }),
2422
+ // Decides on a JWT's DECODED payload rather than its shape, and yields the matching token spans so
2423
+ // `redact` masks only those tokens. `claim` names an own, top-level claim; `value` is compared with
2424
+ // exact string equality. Anything not positively identified — undecodable, not JSON, not an object,
2425
+ // oversized, claim missing, claim not a string, or a different value — is a no-match.
2426
+ jwt_claim_equals: Object.freeze({
2427
+ required: Object.freeze(["claim", "value"]),
2428
+ shapes: Object.freeze({ claim: "non-empty-string", value: "non-empty-string" })
2429
+ }),
2430
+ regex: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "regex-literal" }) }),
2431
+ contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
2432
+ stripos: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
2433
+ not_contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
2434
+ // A hostname is compared to `new URL(value).hostname`, so an empty one only ever equals the empty
2435
+ // hostname of an opaque-scheme URL (`file:`, `mailto:`, `data:`) — never a host, and a false positive
2436
+ // on those. The other three are the list-membership family: an empty list makes `in_array` and
2437
+ // `array_in_array` inert and `not_in_array` true for every value.
2438
+ hostname: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
2439
+ in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
2440
+ not_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
2441
+ array_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-list" }) }),
2442
+ // The comparison family. `equals` coerces, so any scalar can match. `equals_strict` does not, and its
2443
+ // subject is always a string by the time it is compared — so a number operand is unconditionally
2444
+ // inert, which is the trap the engine's own note about `'1' !== 1` describes.
2445
+ equals: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar" }) }),
2446
+ equals_strict: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "string" }) }),
2447
+ less_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) }),
2448
+ more_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) })
2449
+ });
2450
+ function operandShapeProblem(shape, value) {
2451
+ const isFilledString = (v) => typeof v === "string" && v.trim() !== "";
2452
+ const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
2453
+ const isHeaderName = (v) => typeof v === "string" && HEADER_NAME.test(v);
2454
+ switch (shape) {
2455
+ case "positive-number":
2456
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? null : "must be a finite positive number";
2457
+ case "boolean":
2458
+ return typeof value === "boolean" ? null : "must be true or false";
2459
+ case "non-empty-string":
2460
+ return isFilledString(value) ? null : "must be a non-empty string";
2461
+ case "regex-literal":
2462
+ if (!isFilledString(value)) return "must be a non-empty string";
2463
+ return REGEX_LITERAL.test(value) ? null : "must be a delimited pattern, as in /pattern/i";
2464
+ case "string-or-string-list":
2465
+ if (isFilledString(value)) return null;
2466
+ if (Array.isArray(value) && value.length > 0 && value.every(isFilledString)) return null;
2467
+ return "must be a non-empty string, or a non-empty list of them";
2468
+ case "scalar":
2469
+ return isScalar(value) ? null : "must be a string, number or boolean";
2470
+ case "string":
2471
+ return typeof value === "string" ? null : "must be a string (this comparison is type-strict)";
2472
+ case "numeric":
2473
+ return isScalar(value) && Number.isFinite(Number(value)) ? null : "must be a number";
2474
+ case "scalar-or-non-empty-list":
2475
+ if (isScalar(value)) return null;
2476
+ if (Array.isArray(value) && value.length > 0 && value.every(isScalar)) return null;
2477
+ return "must be a scalar, or a non-empty list of them";
2478
+ case "non-empty-list":
2479
+ return Array.isArray(value) && value.length > 0 && value.every(isScalar) ? null : "must be a non-empty list of scalars";
2480
+ case "header-name-map": {
2481
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
2482
+ const names = Object.keys(value);
2483
+ if (names.length === 0) return "must be an object with at least one entry";
2484
+ const bad = names.find((name) => !isHeaderName(name));
2485
+ return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
2486
+ }
2487
+ case "header-name-list": {
2488
+ if (!Array.isArray(value) || value.length === 0) return "must be a non-empty list of HTTP header names";
2489
+ const bad = value.find((name) => !isHeaderName(name));
2490
+ return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
2491
+ }
2492
+ case "cookie-flags": {
2493
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
2494
+ const unknown = Object.keys(value).filter((key) => !COOKIE_FLAGS.includes(key));
2495
+ if (unknown.length > 0) return `has no flag named ${unknown.map((k) => JSON.stringify(k)).join(", ")}`;
2496
+ for (const flag of ["httpOnly", "secure"]) {
2497
+ if (value[flag] !== void 0 && typeof value[flag] !== "boolean") {
2498
+ return `flag "${flag}" must be true or false`;
2499
+ }
2500
+ }
2501
+ const sameSite = value.sameSite;
2502
+ const sameSiteOff = sameSite === false;
2503
+ if (sameSite !== void 0 && !sameSiteOff) {
2504
+ if (typeof sameSite !== "string" || !SAME_SITE_VALUES.some((v) => v.toLowerCase() === sameSite.toLowerCase())) {
2505
+ return `flag "sameSite" must be false, or one of ${SAME_SITE_VALUES.join(", ")}`;
2506
+ }
2507
+ }
2508
+ if (value.httpOnly === false && value.secure === false && sameSiteOff) {
2509
+ return "turns off every flag, so it would harden nothing";
2510
+ }
2511
+ return null;
2512
+ }
2513
+ case "object":
2514
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? null : "must be an object";
2515
+ case "non-empty-object":
2516
+ return value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0 ? null : "must be an object with at least one entry";
2517
+ case "non-empty-string-list":
2518
+ return Array.isArray(value) && value.length > 0 && value.every(isFilledString) ? null : "must be a non-empty list of non-empty strings";
2519
+ default:
2520
+ return null;
2521
+ }
2522
+ }
2523
+ var MUTATIONS = Object.freeze([
2524
+ "base64_decode",
2525
+ "getArrayValues",
2526
+ "htmlentitydecode",
2527
+ "intval",
2528
+ "json_decode",
2529
+ "json_encode",
2530
+ "urldecode"
2531
+ ]);
2532
+ var PHASES = Object.freeze(["request", "response", "egress"]);
2533
+ var ACTIONS = Object.freeze([
2534
+ "block",
2535
+ "redact",
2536
+ "encode",
2537
+ "set-header",
2538
+ "remove-header",
2539
+ "harden-cookie"
2540
+ ]);
2541
+ var RULE_PROPERTY_SHAPES = Object.freeze({
2542
+ max_bytes: "positive-number",
2543
+ bypass_limit: "boolean",
2544
+ ensure: "boolean",
2545
+ prefilter: "non-empty-string-list"
2546
+ });
2547
+ var ENFORCEMENT_VALUES = Object.freeze(["dry-run"]);
2548
+ var WHEN_KEYS = Object.freeze(["path", "method"]);
2549
+ var RULE_PROPERTIES = Object.freeze([
2550
+ "id",
2551
+ "rule_id",
2552
+ "title",
2553
+ "category",
2554
+ "phase",
2555
+ "action",
2556
+ "rule_v2",
2557
+ "when",
2558
+ "message",
2559
+ "enforcement",
2560
+ "source_revision",
2561
+ "prefilter",
2562
+ "max_bytes",
2563
+ "bypass_limit",
2564
+ "set_headers",
2565
+ "remove_headers",
2566
+ "cookie_flags",
2567
+ "ensure"
2568
+ ]);
2569
+ var ACTION_PROPERTIES = Object.freeze({
2570
+ block: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["request", "response", "egress"]) }),
2571
+ redact: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
2572
+ encode: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
2573
+ "set-header": Object.freeze({
2574
+ required: Object.freeze(["set_headers"]),
2575
+ defaulted: Object.freeze(["ensure"]),
2576
+ phases: Object.freeze(["response"]),
2577
+ shapes: Object.freeze({ set_headers: "header-name-map" })
2578
+ }),
2579
+ "remove-header": Object.freeze({
2580
+ required: Object.freeze(["remove_headers"]),
2581
+ defaulted: Object.freeze([]),
2582
+ phases: Object.freeze(["response"]),
2583
+ shapes: Object.freeze({ remove_headers: "header-name-list" })
2584
+ }),
2585
+ "harden-cookie": Object.freeze({
2586
+ required: Object.freeze([]),
2587
+ defaulted: Object.freeze(["cookie_flags"]),
2588
+ phases: Object.freeze(["response"]),
2589
+ shapes: Object.freeze({ cookie_flags: "cookie-flags" })
2590
+ })
2591
+ });
2592
+ var LIMITS = Object.freeze({
2203
2593
  maxRules: 5e3,
2204
2594
  maxWhitelists: 2e3,
2205
2595
  maxConditionsPerRule: 250,
2206
2596
  maxNestingDepth: 12,
2207
2597
  maxRegexLength: 1e3,
2208
2598
  maxValueLength: 8192
2209
- };
2210
- var PHASES = /* @__PURE__ */ new Set(["request", "response", "egress"]);
2211
- var ACTIONS = /* @__PURE__ */ new Set(["block", "redact", "encode", "set-header", "remove-header", "harden-cookie"]);
2599
+ });
2600
+ var GROUP_PARAMETER = "rules";
2601
+ function isGroup(condition) {
2602
+ return condition?.parameter === GROUP_PARAMETER && Array.isArray(condition?.rules);
2603
+ }
2604
+ function conditionShapeProblem(condition) {
2605
+ if (!condition || typeof condition !== "object") return "condition is not an object";
2606
+ const named = condition.parameter === GROUP_PARAMETER;
2607
+ const nested = Array.isArray(condition.rules);
2608
+ if (named && !nested) {
2609
+ return `parameter "${GROUP_PARAMETER}" is a group and needs a "rules" array`;
2610
+ }
2611
+ if (nested && !named) {
2612
+ return `condition carries nested rules but its parameter is ${JSON.stringify(condition.parameter)}; a group must be {"parameter":"${GROUP_PARAMETER}"}`;
2613
+ }
2614
+ if (named && nested) {
2615
+ if (condition.match !== void 0) return "a group carries no match of its own; the engine ignores it";
2616
+ if (condition.mutations !== void 0) return "a group carries no mutations of its own; the engine ignores them";
2617
+ if (condition.rules.length === 0) return "group has no conditions (would never match)";
2618
+ }
2619
+ return null;
2620
+ }
2621
+ function parameterProblem(parameter) {
2622
+ if (parameter === void 0 || parameter === null) return null;
2623
+ if (Array.isArray(parameter)) {
2624
+ if (parameter.length === 0) return "parameter list is empty";
2625
+ for (const member of parameter) {
2626
+ const problem = parameterProblem(member);
2627
+ if (problem !== null) return problem;
2628
+ }
2629
+ return null;
2630
+ }
2631
+ if (typeof parameter !== "string" || parameter === "") return "parameter must be a non-empty string";
2632
+ if (parameter === GROUP_PARAMETER) {
2633
+ return `"${GROUP_PARAMETER}" is the group source and is only valid as a whole group, not as a parameter`;
2634
+ }
2635
+ const dot = parameter.indexOf(".");
2636
+ const name = dot === -1 ? parameter : parameter.slice(0, dot);
2637
+ const source = SOURCES[name];
2638
+ if (source === void 0) return `unknown parameter source "${name}"`;
2639
+ if (dot === -1) {
2640
+ return source.keyed === true ? `source "${name}" needs a key, e.g. "${name}.fieldname"` : null;
2641
+ }
2642
+ if (source.keyed !== true) return `source "${name}" takes no key`;
2643
+ const key = parameter.slice(dot + 1);
2644
+ if (key === "") return `source "${name}" needs a key after the dot`;
2645
+ return keyProblem(name, source, key);
2646
+ }
2647
+ function keyProblem(name, source, key) {
2648
+ if (key.endsWith("*") && source.wildcard !== true) {
2649
+ return `"${name}" does not fan out over a wildcard key`;
2650
+ }
2651
+ const bare = key.endsWith("*") ? key.slice(0, -1) : key;
2652
+ if (source.keys === "any") return null;
2653
+ if (Array.isArray(source.keys) && source.keys.includes(bare)) return null;
2654
+ for (const prefix of source.key_prefixes ?? []) {
2655
+ if (bare.startsWith(prefix) && bare.length > prefix.length) return null;
2656
+ }
2657
+ const accepted = [
2658
+ ...Array.isArray(source.keys) ? source.keys : [],
2659
+ ...(source.key_prefixes ?? []).map((p) => `${p}\u2026`)
2660
+ ].join(", ");
2661
+ return `"${name}" has no key "${key}" (expected one of: ${accepted})`;
2662
+ }
2663
+ function matchProblem(match, parameter) {
2664
+ if (!match || typeof match !== "object") return "condition has no match object";
2665
+ if (typeof match.type !== "string" || match.type === "") return "match.type must be a non-empty string";
2666
+ if (!MATCH_TYPES.includes(match.type)) return `unknown match type "${match.type}"`;
2667
+ const parameterless = PARAMETERLESS_MATCH_TYPES.includes(match.type);
2668
+ if (!parameterless && (parameter === void 0 || parameter === null)) {
2669
+ return `match type "${match.type}" needs a parameter`;
2670
+ }
2671
+ if (parameterless && parameter !== void 0 && parameter !== null) {
2672
+ return `match type "${match.type}" reads the whole request and takes no parameter`;
2673
+ }
2674
+ const operands = MATCH_OPERANDS[match.type];
2675
+ if (operands) {
2676
+ for (const required of operands.required) {
2677
+ if (match[required] === void 0 || match[required] === null) {
2678
+ return `match type "${match.type}" needs "${required}"`;
2679
+ }
2680
+ }
2681
+ for (const [operand, shape] of Object.entries(operands.shapes ?? {})) {
2682
+ if (match[operand] === void 0) continue;
2683
+ const problem = operandShapeProblem(shape, match[operand]);
2684
+ if (problem) return `match type "${match.type}" operand "${operand}" ${problem}`;
2685
+ }
2686
+ } else if (!OPERANDLESS_MATCH_TYPES.includes(match.type) && match.value === void 0) {
2687
+ return `match type "${match.type}" needs a value`;
2688
+ }
2689
+ return null;
2690
+ }
2691
+ function mutationsProblem(mutations) {
2692
+ if (mutations === void 0 || mutations === null) return null;
2693
+ if (!Array.isArray(mutations)) return "mutations must be an array";
2694
+ for (const mutation of mutations) {
2695
+ if (typeof mutation !== "string" || !MUTATIONS.includes(mutation)) {
2696
+ return `unknown mutation "${String(mutation)}" \u2014 applied as a no-op, so the condition would test the untransformed value`;
2697
+ }
2698
+ }
2699
+ return null;
2700
+ }
2701
+ function whenProblem(when) {
2702
+ if (when === void 0 || when === null) return null;
2703
+ if (typeof when !== "object" || Array.isArray(when)) return "when must be an object";
2704
+ const keys = Object.keys(when);
2705
+ if (keys.length === 0) return "when is empty \u2014 remove it, or name a path or method";
2706
+ const unknown = keys.filter((key) => !WHEN_KEYS.includes(key));
2707
+ if (unknown.length > 0) {
2708
+ 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`;
2709
+ }
2710
+ if ("path" in when && !isNonEmptyString(when.path)) {
2711
+ return "when.path must be a non-empty string";
2712
+ }
2713
+ if ("method" in when) {
2714
+ const methods = Array.isArray(when.method) ? when.method : [when.method];
2715
+ if (methods.length === 0) return "when.method is an empty list, so the rule would match no method";
2716
+ if (!methods.every(isNonEmptyString)) return "when.method must be a non-empty string, or a list of them";
2717
+ }
2718
+ return null;
2719
+ }
2720
+ function isNonEmptyString(value) {
2721
+ return typeof value === "string" && value.trim() !== "";
2722
+ }
2723
+ function nullPropertyProblem(rule) {
2724
+ if (!rule || typeof rule !== "object") return null;
2725
+ for (const property of RULE_PROPERTIES) {
2726
+ if (property in rule && rule[property] === null) {
2727
+ 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`;
2728
+ }
2729
+ }
2730
+ return null;
2731
+ }
2732
+ function rulePropertyProblem(rule) {
2733
+ if (!rule || typeof rule !== "object") return null;
2734
+ for (const [property, shape] of Object.entries(RULE_PROPERTY_SHAPES)) {
2735
+ if (rule[property] === void 0 || rule[property] === null) continue;
2736
+ const problem = operandShapeProblem(shape, rule[property]);
2737
+ if (problem) return `"${property}" ${problem}`;
2738
+ }
2739
+ return null;
2740
+ }
2741
+ function actionProblem(action, rule) {
2742
+ if (action === void 0 || action === null) return null;
2743
+ if (!ACTIONS.includes(action)) return `unknown action "${action}"`;
2744
+ const spec = ACTION_PROPERTIES[action];
2745
+ for (const required of spec?.required ?? []) {
2746
+ if (rule?.[required] === void 0 || rule?.[required] === null) {
2747
+ return `action "${action}" needs "${required}"`;
2748
+ }
2749
+ }
2750
+ for (const [property, shape] of Object.entries(spec?.shapes ?? {})) {
2751
+ if (rule?.[property] === void 0 || rule?.[property] === null) continue;
2752
+ const problem = operandShapeProblem(shape, rule[property]);
2753
+ if (problem) return `action "${action}" property "${property}" ${problem}`;
2754
+ }
2755
+ const phase = rule?.phase ?? "request";
2756
+ if (spec !== void 0 && !spec.phases.includes(phase)) {
2757
+ return `action "${action}" is carried out on the ${spec.phases.join("/")} phase; on "${phase}" the rule blocks instead`;
2758
+ }
2759
+ return null;
2760
+ }
2761
+
2762
+ // src/protect/rules/validate.js
2763
+ var LIMITS2 = LIMITS;
2764
+ var PHASES2 = new Set(PHASES);
2765
+ var ACTIONS2 = new Set(ACTIONS);
2212
2766
  function validateBundle(bundle, opts = {}) {
2213
2767
  const rejected = [];
2214
2768
  const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
2215
2769
  const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
2216
2770
  const firewall = [];
2217
2771
  for (const rule of inFirewall) {
2218
- if (firewall.length >= LIMITS.maxRules) {
2219
- rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS.maxRules})` });
2772
+ if (firewall.length >= LIMITS2.maxRules) {
2773
+ rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS2.maxRules})` });
2220
2774
  continue;
2221
2775
  }
2222
2776
  const reason = ruleProblem(rule);
@@ -2225,8 +2779,8 @@ function validateBundle(bundle, opts = {}) {
2225
2779
  }
2226
2780
  const whitelists = [];
2227
2781
  for (const wl of inWhitelists) {
2228
- if (whitelists.length >= LIMITS.maxWhitelists) {
2229
- rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS.maxWhitelists})` });
2782
+ if (whitelists.length >= LIMITS2.maxWhitelists) {
2783
+ rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS2.maxWhitelists})` });
2230
2784
  continue;
2231
2785
  }
2232
2786
  if (!opts.allowGlobalWhitelists && wl && Array.isArray(wl.rule_v2) && !wl.rule_id) {
@@ -2248,37 +2802,47 @@ function idOf(rule) {
2248
2802
  }
2249
2803
  function ruleProblem(rule) {
2250
2804
  if (!rule || typeof rule !== "object") return "not an object";
2251
- if (rule.phase !== void 0 && !PHASES.has(rule.phase)) return `unknown phase "${rule.phase}"`;
2252
- if (rule.action !== void 0 && !ACTIONS.has(rule.action)) return `unknown action "${rule.action}"`;
2253
- const capOverride = rule.max_bytes;
2254
- if (capOverride !== void 0 && !(Number(capOverride) > 0)) return "max_bytes must be a positive number";
2805
+ const nullReason = nullPropertyProblem(rule);
2806
+ if (nullReason) return nullReason;
2807
+ if (rule.phase !== void 0 && !PHASES2.has(rule.phase)) return `unknown phase "${rule.phase}"`;
2808
+ const actionReason = actionProblem(rule.action, rule);
2809
+ if (actionReason) return actionReason;
2810
+ const scopeReason = whenProblem(rule.when);
2811
+ if (scopeReason) return scopeReason;
2812
+ const propertyReason = rulePropertyProblem(rule);
2813
+ if (propertyReason) return propertyReason;
2255
2814
  return conditionsProblem(rule.rule_v2);
2256
2815
  }
2257
2816
  function conditionsProblem(conditions, depth = 0) {
2258
2817
  if (!Array.isArray(conditions)) return "rule_v2 must be an array of conditions";
2259
2818
  if (conditions.length === 0) return "rule_v2 is empty (would never match)";
2260
- if (depth > LIMITS.maxNestingDepth) return `nesting deeper than ${LIMITS.maxNestingDepth}`;
2261
- if (conditions.length > LIMITS.maxConditionsPerRule) {
2262
- return `more than ${LIMITS.maxConditionsPerRule} conditions`;
2819
+ if (depth > LIMITS2.maxNestingDepth) return `nesting deeper than ${LIMITS2.maxNestingDepth}`;
2820
+ if (conditions.length > LIMITS2.maxConditionsPerRule) {
2821
+ return `more than ${LIMITS2.maxConditionsPerRule} conditions`;
2263
2822
  }
2264
2823
  for (const c of conditions) {
2265
- if (!c || typeof c !== "object") return "condition is not an object";
2266
- if (Array.isArray(c.rules)) {
2824
+ const shapeReason = conditionShapeProblem(c);
2825
+ if (shapeReason) return shapeReason;
2826
+ if (isGroup(c)) {
2267
2827
  const nested = conditionsProblem(c.rules, depth + 1);
2268
2828
  if (nested) return nested;
2269
2829
  continue;
2270
2830
  }
2831
+ const parameterReason = parameterProblem(c.parameter);
2832
+ if (parameterReason) return parameterReason;
2833
+ const mutationReason = mutationsProblem(c.mutations);
2834
+ if (mutationReason) return mutationReason;
2835
+ const matchReason = matchProblem(c.match, c.parameter);
2836
+ if (matchReason) return matchReason;
2271
2837
  const m = c.match;
2272
- if (!m || typeof m !== "object") return "condition has no match object";
2273
- if (typeof m.type !== "string" || m.type === "") return "match.type must be a non-empty string";
2274
2838
  if (m.type === "regex") {
2275
2839
  if (typeof m.value !== "string") return "regex match.value must be a string";
2276
- if (m.value.length > LIMITS.maxRegexLength) return `regex longer than ${LIMITS.maxRegexLength} chars`;
2277
- } else if (typeof m.value === "string" && m.value.length > LIMITS.maxValueLength) {
2278
- return `match.value longer than ${LIMITS.maxValueLength} chars`;
2840
+ if (m.value.length > LIMITS2.maxRegexLength) return `regex longer than ${LIMITS2.maxRegexLength} chars`;
2841
+ } else if (typeof m.value === "string" && m.value.length > LIMITS2.maxValueLength) {
2842
+ return `match.value longer than ${LIMITS2.maxValueLength} chars`;
2279
2843
  }
2280
2844
  if (m.match) {
2281
- const nested = conditionsProblem([{ match: m.match }], depth + 1);
2845
+ const nested = conditionsProblem([{ parameter: c.parameter, match: m.match }], depth + 1);
2282
2846
  if (nested) return nested;
2283
2847
  }
2284
2848
  }
@@ -2306,64 +2870,67 @@ function reportRejections(rejected, options, label) {
2306
2870
  `${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" : ""}`
2307
2871
  ), "onError");
2308
2872
  }
2873
+ function fromSource(bundle, reason) {
2874
+ return reason === void 0 ? { ...bundle, source: { ok: true } } : { ...bundle, source: { ok: false, reason } };
2875
+ }
2309
2876
  async function resolveRules(options, store, ctx = {}) {
2310
2877
  const timeoutMs = ctx.timeoutMs;
2311
2878
  if (options.siteUuid) {
2312
2879
  const prior = await store.read();
2313
2880
  const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, reportsDetections: options.reportDetections === true });
2314
2881
  const res = await client.getRules();
2315
- if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
2882
+ if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
2316
2883
  if (res.success && !res.notModified) {
2317
2884
  const rejected = liveUpdateRejections(res, options);
2318
2885
  if (rejected.length > 0) {
2319
2886
  reportRejections(rejected, options, "rule update rejected");
2320
- if (prior?.bundle) return normalizeBundle(prior.bundle, options);
2321
- if (options.rules) return normalizeBundle(options.rules, options);
2322
- return emptyBundle();
2887
+ if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
2888
+ if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
2889
+ return fromSource(emptyBundle(), "update rejected");
2323
2890
  }
2324
2891
  const bundle = normalizeBundle(res, options);
2325
2892
  await store.write({ bundle, etag: res.etag ?? null });
2326
- return bundle;
2893
+ return fromSource(bundle);
2327
2894
  }
2328
2895
  if (prior?.bundle) {
2329
2896
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
2330
- return normalizeBundle(prior.bundle, options);
2897
+ return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
2331
2898
  }
2332
2899
  if (options.rules) {
2333
2900
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`), "onError");
2334
- return normalizeBundle(options.rules, options);
2901
+ return fromSource(normalizeBundle(options.rules, options), res.error ?? "no usable response");
2335
2902
  }
2336
2903
  notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
2337
- return emptyBundle();
2904
+ return fromSource(emptyBundle(), res.error ?? "no usable response");
2338
2905
  }
2339
2906
  if (options.token) {
2340
2907
  const prior = await store.read();
2341
2908
  const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs });
2342
2909
  const res = await client.getRules();
2343
- if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
2910
+ if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
2344
2911
  if (res.success && !res.notModified) {
2345
2912
  const rejected = liveUpdateRejections(res, options);
2346
2913
  if (rejected.length > 0) {
2347
2914
  reportRejections(rejected, options, "rule update rejected");
2348
- if (prior?.bundle) return normalizeBundle(prior.bundle, options);
2349
- if (options.rules) return normalizeBundle(options.rules, options);
2350
- return emptyBundle();
2915
+ if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
2916
+ if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
2917
+ return fromSource(emptyBundle(), "update rejected");
2351
2918
  }
2352
2919
  const bundle = normalizeBundle(res, options);
2353
2920
  await store.write({ bundle, etag: res.etag ?? null });
2354
- return bundle;
2921
+ return fromSource(bundle);
2355
2922
  }
2356
2923
  if (prior?.bundle) {
2357
2924
  notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
2358
- return normalizeBundle(prior.bundle, options);
2925
+ return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
2359
2926
  }
2360
2927
  notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
2361
- return emptyBundle();
2928
+ return fromSource(emptyBundle(), res.error ?? "no usable response");
2362
2929
  }
2363
2930
  if (options.rules) {
2364
- return normalizeBundle(options.rules, options);
2931
+ return fromSource(normalizeBundle(options.rules, options));
2365
2932
  }
2366
- return emptyBundle();
2933
+ return fromSource(emptyBundle());
2367
2934
  }
2368
2935
  function normalizeBundle(b, options = {}) {
2369
2936
  const enforcement = b?.enforcement ?? b?.mode;
@@ -2416,8 +2983,9 @@ function startRefresh(tick, { refreshMs, onError } = {}) {
2416
2983
  const run = async () => {
2417
2984
  if (stopped) return;
2418
2985
  try {
2419
- await tick();
2420
- failures = 0;
2986
+ const status = await tick();
2987
+ if (status && status.ok === false) failures++;
2988
+ else failures = 0;
2421
2989
  } catch (err) {
2422
2990
  failures++;
2423
2991
  notify(onError, err, "onError");
@@ -2444,7 +3012,8 @@ function makeRefreshHandler(tick, secret) {
2444
3012
  if (provided !== secret) return new Response("forbidden", { status: 403 });
2445
3013
  let refreshed = true;
2446
3014
  try {
2447
- await tick();
3015
+ const status = await tick();
3016
+ if (status && status.ok === false) refreshed = false;
2448
3017
  } catch {
2449
3018
  refreshed = false;
2450
3019
  }
@@ -2472,6 +3041,12 @@ function ruleParameters(rule) {
2472
3041
  walk(rule?.rule_v2);
2473
3042
  return [...out];
2474
3043
  }
3044
+ function revisionOf(rule) {
3045
+ const revision = rule?.source_revision;
3046
+ if (typeof revision === "string" && revision !== "") return revision;
3047
+ if (typeof revision === "number" && Number.isFinite(revision)) return String(revision);
3048
+ return null;
3049
+ }
2475
3050
  function routeOf(path) {
2476
3051
  if (typeof path !== "string" || path === "") return null;
2477
3052
  const cut = path.search(/[?#]/);
@@ -2480,16 +3055,30 @@ function routeOf(path) {
2480
3055
  function createDetectionReporter(opts) {
2481
3056
  const siteUuid = opts.siteUuid ?? process.env?.PATCHSTACK_SITE_UUID;
2482
3057
  if (!siteUuid) {
2483
- return { record() {
2484
- }, flush() {
2485
- }, stop() {
2486
- }, dropped: () => 0 };
3058
+ return {
3059
+ record() {
3060
+ },
3061
+ flush() {
3062
+ },
3063
+ stop() {
3064
+ },
3065
+ setRulesEtag() {
3066
+ },
3067
+ dropped: () => 0,
3068
+ health: () => ({ sent: 0, delivered: 0, failed: 0, dropped: 0, lastDeliveredAt: null })
3069
+ };
2487
3070
  }
2488
3071
  const configured = opts.baseUrl ?? process.env?.PATCHSTACK_PULSE_RULES_URL;
2489
3072
  const baseUrl = typeof configured === "string" && isSafeOrigin(configured) ? configured.replace(/\/$/, "") : DEFAULT_BASE_URL3;
2490
3073
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
2491
3074
  const flushMs = Number.isFinite(opts.flushMs) && opts.flushMs > 0 ? opts.flushMs : DEFAULT_FLUSH_MS;
2492
3075
  const maxQueue = Number.isFinite(opts.maxQueue) && opts.maxQueue > 0 ? opts.maxQueue : MAX_QUEUE;
3076
+ let rulesEtag = opts.rulesEtag ?? null;
3077
+ let sent = 0;
3078
+ let delivered = 0;
3079
+ let failed = 0;
3080
+ let droppedTotal = 0;
3081
+ let lastDeliveredAt = null;
2493
3082
  let queue = [];
2494
3083
  let timer = null;
2495
3084
  let stopped = false;
@@ -2503,6 +3092,8 @@ function createDetectionReporter(opts) {
2503
3092
  const batch = queue.splice(0, MAX_BATCH);
2504
3093
  const droppedWith = dropped;
2505
3094
  dropped = 0;
3095
+ droppedTotal += droppedWith;
3096
+ sent += batch.length;
2506
3097
  void (async () => {
2507
3098
  try {
2508
3099
  const res = await fetchImpl(`${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, {
@@ -2511,15 +3102,22 @@ function createDetectionReporter(opts) {
2511
3102
  "Content-Type": "application/json",
2512
3103
  Accept: "application/json",
2513
3104
  "User-Agent": "@patchstack/connect",
2514
- // Same credential path as the rules fetch, and unauthenticated when none resolves: the
2515
- // server accepts the UUID, and reporting must never hinge on getting a token.
3105
+ // Same credential path as the rules fetch. The detections endpoint is site-addressed and
3106
+ // requires a verified, site-bound token, so a batch sent without one is refused — which is
3107
+ // why the runtime does not build a reporter when no credential resolves, rather than
3108
+ // posting into a 401.
2516
3109
  ...await pulseAuthHeader({ pulseAuth: opts.pulseAuth, endpoint: baseUrl }, fetchImpl)
2517
3110
  },
2518
3111
  body: JSON.stringify({ detections: batch, dropped: droppedWith })
2519
3112
  });
2520
- if (res && typeof res.then === "function") res.catch(() => {
2521
- });
3113
+ if (res && res.ok) {
3114
+ delivered += batch.length;
3115
+ lastDeliveredAt = (/* @__PURE__ */ new Date()).toISOString();
3116
+ } else {
3117
+ failed += batch.length;
3118
+ }
2522
3119
  } catch {
3120
+ failed += batch.length;
2523
3121
  }
2524
3122
  })();
2525
3123
  };
@@ -2548,7 +3146,12 @@ function createDetectionReporter(opts) {
2548
3146
  // The state this detection was handled under, which is the whole point: `false` is a rule that
2549
3147
  // saw traffic it would have stopped.
2550
3148
  enforced: detection.mode === "block",
2551
- rules_etag: opts.rulesEtag ?? null,
3149
+ rules_etag: rulesEtag,
3150
+ // The revision of THIS rule, as the bundle delivered it. The bundle identity above answers "which
3151
+ // bundle", which changes whenever anything in it changes — so it cannot say whether the counts for
3152
+ // one rule describe the document that rule has now. Passed through untouched, and null when the
3153
+ // bundle carried none.
3154
+ rule_revision: revisionOf(detection.rule),
2552
3155
  detected_at: (/* @__PURE__ */ new Date()).toISOString()
2553
3156
  });
2554
3157
  if (queue.length >= MAX_BATCH) {
@@ -2562,7 +3165,24 @@ function createDetectionReporter(opts) {
2562
3165
  stopped = true;
2563
3166
  flush();
2564
3167
  },
2565
- dropped: () => dropped
3168
+ /**
3169
+ * Point later events at the bundle now running. Called after an ACCEPTED swap only — a rejected
3170
+ * or failed refresh keeps the previous rules, so it must keep the previous identity too.
3171
+ *
3172
+ * Already-queued events are not rewritten: the stamp is taken when an event is recorded, which is
3173
+ * the only moment the two are known to agree.
3174
+ *
3175
+ * @param {string | null | undefined} next
3176
+ */
3177
+ setRulesEtag(next) {
3178
+ rulesEtag = next ?? null;
3179
+ },
3180
+ dropped: () => dropped,
3181
+ /**
3182
+ * Delivery health, counted in events: attempted, acknowledged, refused or unreachable, and dropped
3183
+ * for queue pressure — plus when a batch was last acknowledged. No request data of any kind.
3184
+ */
3185
+ health: () => ({ sent, delivered, failed, dropped: droppedTotal + dropped, lastDeliveredAt })
2566
3186
  };
2567
3187
  }
2568
3188
 
@@ -2827,22 +3447,32 @@ async function createProtection(options = {}) {
2827
3447
  const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
2828
3448
  const pulseAuth = await resolvePulseAuth(options);
2829
3449
  if (options.siteUuid && !pulseAuth) {
2830
- 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.";
3450
+ 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.";
2831
3451
  notify(onError, new Error(message), "onError");
2832
3452
  console.warn(message);
2833
3453
  }
2834
3454
  const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth });
3455
+ let detectionReporting = "off";
2835
3456
  if (options.reportDetections === true && options.siteUuid && telemetryEnabled()) {
2836
- detections = createDetectionReporter({
2837
- siteUuid: options.siteUuid,
2838
- baseUrl: options.pulseRulesUrl,
2839
- pulseAuth,
2840
- // The bundle the guard is actually running, so a hit can be attributed to the rules that produced
2841
- // it rather than to whatever is current when the report is read.
2842
- rulesEtag: (await store.read())?.etag ?? null,
2843
- fetchImpl: options.fetchImpl,
2844
- flushMs: options.detectionFlushMs
2845
- });
3457
+ if (!pulseAuth) {
3458
+ detectionReporting = "unavailable-no-credential";
3459
+ 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.";
3460
+ notify(onError, new Error(message), "onError");
3461
+ console.warn(message);
3462
+ } else {
3463
+ detectionReporting = "on";
3464
+ detections = createDetectionReporter({
3465
+ siteUuid: options.siteUuid,
3466
+ baseUrl: options.pulseRulesUrl,
3467
+ pulseAuth,
3468
+ // The bundle the guard is actually running, so a hit can be attributed to the rules that produced
3469
+ // it rather than to whatever is current when the report is read. Kept current across refreshes —
3470
+ // see the refresh tick below.
3471
+ rulesEtag: (await store.read())?.etag ?? null,
3472
+ fetchImpl: options.fetchImpl,
3473
+ flushMs: options.detectionFlushMs
3474
+ });
3475
+ }
2846
3476
  }
2847
3477
  let mode = resolveMode(options, bundle);
2848
3478
  let requestRules;
@@ -3176,11 +3806,17 @@ async function createProtection(options = {}) {
3176
3806
  );
3177
3807
  };
3178
3808
  },
3179
- // Node / Connect middleware — buffers the body itself (request phase).
3809
+ // Node / Connect middleware — buffers the body itself (request phase). Register it BEFORE any body
3810
+ // parser: it reads the request stream, and exposes what it read as `req.body` so a parser is not
3811
+ // also needed. (`.express()` is the other way round — it reads a body somebody else parsed.)
3180
3812
  // Pass { screenResponses: true } to also screen the outgoing response (buffers it).
3181
3813
  node(nodeOptions = {}) {
3182
3814
  const maxBytes = nodeOptions.maxBodyBytes ?? 1024 * 1024;
3183
3815
  return (req, res, next) => {
3816
+ if (req.readableEnded || req.body !== void 0) {
3817
+ screenNodeRequest(req, res, next, "", req.body);
3818
+ return;
3819
+ }
3184
3820
  const chunks = [];
3185
3821
  let size = 0;
3186
3822
  let overflow = false;
@@ -3198,38 +3834,41 @@ async function createProtection(options = {}) {
3198
3834
  });
3199
3835
  req.on("end", () => {
3200
3836
  if (overflow) recordSkip("request", "body-cap", { bytes: size, limit: maxBytes });
3201
- const rawBody = overflow ? "" : Buffer.concat(chunks).toString("utf8");
3202
- let shaped;
3203
- let result;
3204
- try {
3205
- shaped = fromNodeRequest(req, rawBody);
3206
- result = engine.evaluate(shaped);
3207
- } catch (err) {
3208
- notify(onError, err, "onError");
3209
- return next();
3210
- }
3211
- decide(
3212
- "request",
3213
- result,
3214
- () => {
3215
- res.statusCode = 403;
3216
- if (isDocumentNavigation((n) => req.headers?.[n])) {
3217
- res.setHeader("content-type", "text/html; charset=utf-8");
3218
- res.end(renderBlockPage({ url: req.url || "/", code: result?.rule?.id }));
3219
- } else {
3220
- res.setHeader("content-type", "application/json");
3221
- res.end(JSON.stringify(blockBody(result)));
3222
- }
3223
- },
3224
- () => {
3225
- if (req.body === void 0) req.body = shaped.body;
3226
- if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
3227
- next();
3228
- },
3229
- nodeRequestMeta(req)
3230
- );
3837
+ screenNodeRequest(req, res, next, overflow ? "" : Buffer.concat(chunks).toString("utf8"));
3231
3838
  });
3232
3839
  };
3840
+ function screenNodeRequest(req, res, next, rawBody, parsedBody) {
3841
+ let shaped;
3842
+ let result;
3843
+ try {
3844
+ shaped = fromNodeRequest(req, rawBody);
3845
+ if (parsedBody !== void 0 && parsedBody !== null) shaped.body = parsedBody;
3846
+ result = engine.evaluate(shaped);
3847
+ } catch (err) {
3848
+ notify(onError, err, "onError");
3849
+ return next();
3850
+ }
3851
+ decide(
3852
+ "request",
3853
+ result,
3854
+ () => {
3855
+ res.statusCode = 403;
3856
+ if (isDocumentNavigation((n) => req.headers?.[n])) {
3857
+ res.setHeader("content-type", "text/html; charset=utf-8");
3858
+ res.end(renderBlockPage({ url: req.url || "/", code: result?.rule?.id }));
3859
+ } else {
3860
+ res.setHeader("content-type", "application/json");
3861
+ res.end(JSON.stringify(blockBody(result)));
3862
+ }
3863
+ },
3864
+ () => {
3865
+ if (req.body === void 0) req.body = shaped.body;
3866
+ if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
3867
+ next();
3868
+ },
3869
+ nodeRequestMeta(req)
3870
+ );
3871
+ }
3233
3872
  }
3234
3873
  };
3235
3874
  if (options.egress) {
@@ -3266,24 +3905,22 @@ async function createProtection(options = {}) {
3266
3905
  const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
3267
3906
  mode = resolveMode(options, next);
3268
3907
  applyBundle(next);
3908
+ if (detections) detections.setRulesEtag((await store.read())?.etag ?? null);
3909
+ return next.source ?? { ok: true };
3269
3910
  };
3270
3911
  if (live) {
3271
3912
  protection.refresh = () => runRefreshTick();
3272
3913
  protection.refreshHandler = () => makeRefreshHandler(runRefreshTick, refreshSecret);
3273
3914
  }
3274
- if (options.refreshMs > 0 && live) {
3275
- const loop = startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError });
3276
- protection.stopRefresh = () => {
3277
- loop.stop();
3278
- firewallLog?.stop();
3279
- detections?.stop();
3280
- };
3281
- } else if (firewallLog) {
3282
- protection.stopRefresh = () => {
3283
- firewallLog.stop();
3284
- detections?.stop();
3285
- };
3286
- }
3915
+ const loop = options.refreshMs > 0 && live ? startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError }) : null;
3916
+ protection.stop = () => {
3917
+ loop?.stop();
3918
+ firewallLog?.stop();
3919
+ detections?.stop();
3920
+ };
3921
+ protection.stopRefresh = protection.stop;
3922
+ protection.detectionReporting = detectionReporting;
3923
+ if (detections) protection.detectionHealth = () => detections.health();
3287
3924
  return protection;
3288
3925
  }
3289
3926
  function resolveMode(options, bundle) {
@@ -3294,22 +3931,35 @@ function resolveMode(options, bundle) {
3294
3931
  if (options?.mode === "dry-run") return "dry-run";
3295
3932
  return "dry-run";
3296
3933
  }
3934
+ var CREDENTIAL_FILES = [".patchstackrc.local.json", ".patchstackrc.json"];
3935
+ async function readCredentialField(options, field) {
3936
+ if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
3937
+ let readFileSync;
3938
+ let join;
3939
+ try {
3940
+ const [fs, pathMod] = await Promise.all([Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1)), Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1))]);
3941
+ readFileSync = fs.readFileSync;
3942
+ join = pathMod.join;
3943
+ } catch {
3944
+ return void 0;
3945
+ }
3946
+ const cwd = options?.cwd ?? process.cwd();
3947
+ for (const filename of CREDENTIAL_FILES) {
3948
+ try {
3949
+ const value = JSON.parse(readFileSync(join(cwd, filename), "utf8"))?.[field];
3950
+ if (typeof value === "string" && value.length > 0) return value;
3951
+ } catch {
3952
+ }
3953
+ }
3954
+ return void 0;
3955
+ }
3297
3956
  async function resolveApiKey(options) {
3298
3957
  if (typeof options?.apiKey === "string" && options.apiKey.length > 0) return options.apiKey;
3299
3958
  if (typeof process !== "undefined") {
3300
3959
  const fromEnv = process.env?.PATCHSTACK_API_KEY;
3301
3960
  if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
3302
3961
  }
3303
- try {
3304
- if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
3305
- const [{ readFileSync }, { join }] = await Promise.all([Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1)), Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1))]);
3306
- const cwd = options?.cwd ?? process.cwd();
3307
- const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
3308
- const key = JSON.parse(raw)?.apiKey;
3309
- if (typeof key === "string" && key.length > 0) return key;
3310
- } catch {
3311
- }
3312
- return void 0;
3962
+ return readCredentialField(options, "apiKey");
3313
3963
  }
3314
3964
  async function resolvePulseAuth(options) {
3315
3965
  if (typeof options?.pulseAuth === "string" && options.pulseAuth.length > 0) return options.pulseAuth;
@@ -3317,15 +3967,8 @@ async function resolvePulseAuth(options) {
3317
3967
  const fromEnv = process.env?.PATCHSTACK_PULSE_AUTH;
3318
3968
  if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
3319
3969
  }
3320
- try {
3321
- if (typeof process === "undefined" || typeof process.cwd !== "function") return resolveApiKey(options);
3322
- const [{ readFileSync }, { join }] = await Promise.all([Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1)), Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1))]);
3323
- const cwd = options?.cwd ?? process.cwd();
3324
- const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
3325
- const key = JSON.parse(raw)?.pulseAuth;
3326
- if (typeof key === "string" && key.length > 0) return key;
3327
- } catch {
3328
- }
3970
+ const fromFile = await readCredentialField(options, "pulseAuth");
3971
+ if (fromFile !== void 0) return fromFile;
3329
3972
  return resolveApiKey(options);
3330
3973
  }
3331
3974
  function byPhase(rules, phase) {
@@ -3458,6 +4101,8 @@ function extractRedactors(rule) {
3458
4101
  }
3459
4102
  } else if ((m.type === "contains" || m.type === "stripos") && m.value != null) {
3460
4103
  out.push({ literal: String(m.value) });
4104
+ } else if (m.type === "jwt_claim_equals" && typeof m.claim === "string") {
4105
+ out.push({ jwtClaim: { claim: m.claim, value: String(m.value ?? "") } });
3461
4106
  } else if (m.type === "array_key_value" && m.match && isBodyParam(c.parameter)) {
3462
4107
  const keys = Array.isArray(m.key) ? m.key : [m.key];
3463
4108
  for (const key of keys) {
@@ -3476,7 +4121,11 @@ function applyRedactors(body, redactors, mask, transform) {
3476
4121
  let out = body;
3477
4122
  for (const r of redactors) {
3478
4123
  if (r.re) out = out.replace(r.re, transform ? (m) => transform(m) : mask);
3479
- else if (r.literal) {
4124
+ else if (r.jwtClaim) {
4125
+ for (const token of jwtClaimSpans(out, r.jwtClaim.claim, r.jwtClaim.value)) {
4126
+ out = out.split(token).join(transform ? transform(token) : mask);
4127
+ }
4128
+ } else if (r.literal) {
3480
4129
  const re = new RegExp(r.literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
3481
4130
  out = out.replace(re, (m) => transform ? transform(m) : mask);
3482
4131
  }
@@ -3610,11 +4259,13 @@ function applyHeaderMutation(headers, rule) {
3610
4259
  }
3611
4260
  }
3612
4261
  }
4262
+ var SAME_SITE = ["Strict", "Lax", "None"];
3613
4263
  function hardenCookie(cookie, { httpOnly = true, secure = true, sameSite = "Lax" } = {}) {
3614
4264
  let out = String(cookie);
3615
4265
  if (httpOnly && !/;\s*httponly/i.test(out)) out += "; HttpOnly";
3616
4266
  if (secure && !/;\s*secure/i.test(out)) out += "; Secure";
3617
- if (sameSite && !/;\s*samesite\s*=/i.test(out)) out += `; SameSite=${sameSite}`;
4267
+ const canonical = SAME_SITE.find((v) => typeof sameSite === "string" && v.toLowerCase() === sameSite.toLowerCase());
4268
+ if (canonical && !/;\s*samesite\s*=/i.test(out)) out += `; SameSite=${canonical}`;
3618
4269
  return out;
3619
4270
  }
3620
4271
  function rebuildResponse(response, body, redactedHeaders) {
@@ -3628,11 +4279,9 @@ function rebuildResponse(response, body, redactedHeaders) {
3628
4279
  } catch {
3629
4280
  }
3630
4281
  } else if (typeof value === "string") {
3631
- if (headers.get(name) !== value) {
3632
- try {
3633
- headers.set(name, value);
3634
- } catch {
3635
- }
4282
+ try {
4283
+ if (headers.get(name) !== value) headers.set(name, value);
4284
+ } catch {
3636
4285
  }
3637
4286
  } else if (Array.isArray(value)) {
3638
4287
  try {