@patchstack/connect 0.3.30 → 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.
@@ -990,6 +990,30 @@ function expandIPv6(host) {
990
990
  }
991
991
  return out;
992
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
+ }
993
1017
  var warnedScopes = /* @__PURE__ */ new Set();
994
1018
  function warnUnrecognisedScope(when) {
995
1019
  const key = Object.keys(when).sort().join(",");
@@ -1018,16 +1042,30 @@ function ruleAppliesTo(when, resolver) {
1018
1042
  return true;
1019
1043
  }
1020
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
+ }
1021
1055
  function pathMatches(pattern, path) {
1022
- if (pattern.length > 2 && pattern.startsWith("/") && pattern.endsWith("/")) {
1056
+ if (isRegexForm(pattern)) {
1023
1057
  const re = safeRegExp(pattern);
1024
1058
  return re ? re.test(path) : false;
1025
1059
  }
1026
- if (pattern.includes("*")) {
1027
- const re = safeRegExp("/^" + pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^?]*") + "$/");
1028
- 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;
1029
1067
  }
1030
- return path === pattern;
1068
+ return target === folded;
1031
1069
  }
1032
1070
  function normalizeDefaultPort(host) {
1033
1071
  return String(host).toLowerCase().replace(/:(?:80|443)$/, "");
@@ -1180,6 +1218,8 @@ function matchValue(type, value, matchVal, matchObj) {
1180
1218
  const stripped = strValue.replace(/[ _\-,]/g, "");
1181
1219
  return ctypeResult(strValue, /^[\w$€-￿]*$/.test(stripped), matchVal);
1182
1220
  }
1221
+ case "jwt_claim_equals":
1222
+ return jwtClaimSpans(strValue, matchObj?.claim, matchVal).length > 0;
1183
1223
  case "array_key_value":
1184
1224
  return arrayKeyValue(value, matchObj);
1185
1225
  // file_contains (uploaded-file content scanning) is NOT WordPress-specific, but is
@@ -2008,13 +2048,43 @@ var DEFAULT_RESPONSE_RULES = [
2008
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])/" } }]
2009
2049
  },
2010
2050
  {
2011
- id: "resp-jwt",
2012
- title: "JWT in response body",
2051
+ id: "resp-supabase-secret-key",
2052
+ title: "Supabase secret key in response body",
2013
2053
  phase: "response",
2014
2054
  category: "secret-exposure",
2015
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.
2016
2086
  prefilter: ["eyJ"],
2017
- 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" } }]
2018
2088
  },
2019
2089
  {
2020
2090
  id: "resp-db-connection-string",
@@ -2297,6 +2367,7 @@ var MATCH_TYPES = Object.freeze([
2297
2367
  "internal_host",
2298
2368
  "is_numeric",
2299
2369
  "isset",
2370
+ "jwt_claim_equals",
2300
2371
  "less_than",
2301
2372
  "more_than",
2302
2373
  "not_contains",
@@ -2322,6 +2393,8 @@ var OPERANDLESS_MATCH_TYPES = Object.freeze([
2322
2393
  "cors_reflected"
2323
2394
  ]);
2324
2395
  var OPERAND_SHAPES = Object.freeze([
2396
+ "positive-number",
2397
+ "boolean",
2325
2398
  "non-empty-string",
2326
2399
  "regex-literal",
2327
2400
  "string-or-string-list",
@@ -2346,6 +2419,14 @@ var MATCH_OPERANDS = Object.freeze({
2346
2419
  required: Object.freeze(["key", "match"]),
2347
2420
  shapes: Object.freeze({ key: "string-or-string-list", match: "object" })
2348
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
+ }),
2349
2430
  regex: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "regex-literal" }) }),
2350
2431
  contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
2351
2432
  stripos: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
@@ -2371,6 +2452,10 @@ function operandShapeProblem(shape, value) {
2371
2452
  const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
2372
2453
  const isHeaderName = (v) => typeof v === "string" && HEADER_NAME.test(v);
2373
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";
2374
2459
  case "non-empty-string":
2375
2460
  return isFilledString(value) ? null : "must be a non-empty string";
2376
2461
  case "regex-literal":
@@ -2453,6 +2538,13 @@ var ACTIONS = Object.freeze([
2453
2538
  "remove-header",
2454
2539
  "harden-cookie"
2455
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"]);
2456
2548
  var WHEN_KEYS = Object.freeze(["path", "method"]);
2457
2549
  var RULE_PROPERTIES = Object.freeze([
2458
2550
  "id",
@@ -2628,6 +2720,24 @@ function whenProblem(when) {
2628
2720
  function isNonEmptyString(value) {
2629
2721
  return typeof value === "string" && value.trim() !== "";
2630
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
+ }
2631
2741
  function actionProblem(action, rule) {
2632
2742
  if (action === void 0 || action === null) return null;
2633
2743
  if (!ACTIONS.includes(action)) return `unknown action "${action}"`;
@@ -2692,13 +2802,15 @@ function idOf(rule) {
2692
2802
  }
2693
2803
  function ruleProblem(rule) {
2694
2804
  if (!rule || typeof rule !== "object") return "not an object";
2805
+ const nullReason = nullPropertyProblem(rule);
2806
+ if (nullReason) return nullReason;
2695
2807
  if (rule.phase !== void 0 && !PHASES2.has(rule.phase)) return `unknown phase "${rule.phase}"`;
2696
2808
  const actionReason = actionProblem(rule.action, rule);
2697
2809
  if (actionReason) return actionReason;
2698
2810
  const scopeReason = whenProblem(rule.when);
2699
2811
  if (scopeReason) return scopeReason;
2700
- const capOverride = rule.max_bytes;
2701
- if (capOverride !== void 0 && !(Number(capOverride) > 0)) return "max_bytes must be a positive number";
2812
+ const propertyReason = rulePropertyProblem(rule);
2813
+ if (propertyReason) return propertyReason;
2702
2814
  return conditionsProblem(rule.rule_v2);
2703
2815
  }
2704
2816
  function conditionsProblem(conditions, depth = 0) {
@@ -3989,6 +4101,8 @@ function extractRedactors(rule) {
3989
4101
  }
3990
4102
  } else if ((m.type === "contains" || m.type === "stripos") && m.value != null) {
3991
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 ?? "") } });
3992
4106
  } else if (m.type === "array_key_value" && m.match && isBodyParam(c.parameter)) {
3993
4107
  const keys = Array.isArray(m.key) ? m.key : [m.key];
3994
4108
  for (const key of keys) {
@@ -4007,7 +4121,11 @@ function applyRedactors(body, redactors, mask, transform) {
4007
4121
  let out = body;
4008
4122
  for (const r of redactors) {
4009
4123
  if (r.re) out = out.replace(r.re, transform ? (m) => transform(m) : mask);
4010
- 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) {
4011
4129
  const re = new RegExp(r.literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
4012
4130
  out = out.replace(re, (m) => transform ? transform(m) : mask);
4013
4131
  }