@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.
package/dist/protect.js CHANGED
@@ -961,6 +961,30 @@ function expandIPv6(host) {
961
961
  }
962
962
  return out;
963
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
+ }
964
988
  var warnedScopes = /* @__PURE__ */ new Set();
965
989
  function warnUnrecognisedScope(when) {
966
990
  const key = Object.keys(when).sort().join(",");
@@ -989,16 +1013,30 @@ function ruleAppliesTo(when, resolver) {
989
1013
  return true;
990
1014
  }
991
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
+ }
992
1026
  function pathMatches(pattern, path) {
993
- if (pattern.length > 2 && pattern.startsWith("/") && pattern.endsWith("/")) {
1027
+ if (isRegexForm(pattern)) {
994
1028
  const re = safeRegExp(pattern);
995
1029
  return re ? re.test(path) : false;
996
1030
  }
997
- if (pattern.includes("*")) {
998
- const re = safeRegExp("/^" + pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^?]*") + "$/");
999
- 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;
1000
1038
  }
1001
- return path === pattern;
1039
+ return target === folded;
1002
1040
  }
1003
1041
  function normalizeDefaultPort(host) {
1004
1042
  return String(host).toLowerCase().replace(/:(?:80|443)$/, "");
@@ -1151,6 +1189,8 @@ function matchValue(type, value, matchVal, matchObj) {
1151
1189
  const stripped = strValue.replace(/[ _\-,]/g, "");
1152
1190
  return ctypeResult(strValue, /^[\w$€-￿]*$/.test(stripped), matchVal);
1153
1191
  }
1192
+ case "jwt_claim_equals":
1193
+ return jwtClaimSpans(strValue, matchObj?.claim, matchVal).length > 0;
1154
1194
  case "array_key_value":
1155
1195
  return arrayKeyValue(value, matchObj);
1156
1196
  // file_contains (uploaded-file content scanning) is NOT WordPress-specific, but is
@@ -1916,13 +1956,43 @@ var DEFAULT_RESPONSE_RULES = [
1916
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])/" } }]
1917
1957
  },
1918
1958
  {
1919
- id: "resp-jwt",
1920
- title: "JWT in response body",
1959
+ id: "resp-supabase-secret-key",
1960
+ title: "Supabase secret key in response body",
1921
1961
  phase: "response",
1922
1962
  category: "secret-exposure",
1923
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",
1981
+ phase: "response",
1982
+ category: "secret-exposure",
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.
1924
1994
  prefilter: ["eyJ"],
1925
- 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" } }]
1926
1996
  },
1927
1997
  {
1928
1998
  id: "resp-db-connection-string",
@@ -2205,6 +2275,7 @@ var MATCH_TYPES = Object.freeze([
2205
2275
  "internal_host",
2206
2276
  "is_numeric",
2207
2277
  "isset",
2278
+ "jwt_claim_equals",
2208
2279
  "less_than",
2209
2280
  "more_than",
2210
2281
  "not_contains",
@@ -2230,6 +2301,8 @@ var OPERANDLESS_MATCH_TYPES = Object.freeze([
2230
2301
  "cors_reflected"
2231
2302
  ]);
2232
2303
  var OPERAND_SHAPES = Object.freeze([
2304
+ "positive-number",
2305
+ "boolean",
2233
2306
  "non-empty-string",
2234
2307
  "regex-literal",
2235
2308
  "string-or-string-list",
@@ -2254,6 +2327,14 @@ var MATCH_OPERANDS = Object.freeze({
2254
2327
  required: Object.freeze(["key", "match"]),
2255
2328
  shapes: Object.freeze({ key: "string-or-string-list", match: "object" })
2256
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
+ }),
2257
2338
  regex: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "regex-literal" }) }),
2258
2339
  contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
2259
2340
  stripos: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
@@ -2279,6 +2360,10 @@ function operandShapeProblem(shape, value) {
2279
2360
  const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
2280
2361
  const isHeaderName = (v) => typeof v === "string" && HEADER_NAME.test(v);
2281
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";
2282
2367
  case "non-empty-string":
2283
2368
  return isFilledString(value) ? null : "must be a non-empty string";
2284
2369
  case "regex-literal":
@@ -2361,6 +2446,13 @@ var ACTIONS = Object.freeze([
2361
2446
  "remove-header",
2362
2447
  "harden-cookie"
2363
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"]);
2364
2456
  var WHEN_KEYS = Object.freeze(["path", "method"]);
2365
2457
  var RULE_PROPERTIES = Object.freeze([
2366
2458
  "id",
@@ -2536,6 +2628,24 @@ function whenProblem(when) {
2536
2628
  function isNonEmptyString(value) {
2537
2629
  return typeof value === "string" && value.trim() !== "";
2538
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
+ }
2539
2649
  function actionProblem(action, rule) {
2540
2650
  if (action === void 0 || action === null) return null;
2541
2651
  if (!ACTIONS.includes(action)) return `unknown action "${action}"`;
@@ -2600,13 +2710,15 @@ function idOf(rule) {
2600
2710
  }
2601
2711
  function ruleProblem(rule) {
2602
2712
  if (!rule || typeof rule !== "object") return "not an object";
2713
+ const nullReason = nullPropertyProblem(rule);
2714
+ if (nullReason) return nullReason;
2603
2715
  if (rule.phase !== void 0 && !PHASES2.has(rule.phase)) return `unknown phase "${rule.phase}"`;
2604
2716
  const actionReason = actionProblem(rule.action, rule);
2605
2717
  if (actionReason) return actionReason;
2606
2718
  const scopeReason = whenProblem(rule.when);
2607
2719
  if (scopeReason) return scopeReason;
2608
- const capOverride = rule.max_bytes;
2609
- if (capOverride !== void 0 && !(Number(capOverride) > 0)) return "max_bytes must be a positive number";
2720
+ const propertyReason = rulePropertyProblem(rule);
2721
+ if (propertyReason) return propertyReason;
2610
2722
  return conditionsProblem(rule.rule_v2);
2611
2723
  }
2612
2724
  function conditionsProblem(conditions, depth = 0) {
@@ -3685,7 +3797,7 @@ async function createProtection(options = {}) {
3685
3797
  let reporter = null;
3686
3798
  if (refreshable && options.siteUuid && options.reportManifest !== false && cwd) {
3687
3799
  try {
3688
- ({ reportManifest: reporter } = await import("./refresh-manifest-ZSP76JWQ.js"));
3800
+ ({ reportManifest: reporter } = await import("./refresh-manifest-KNZQBC4V.js"));
3689
3801
  } catch (err) {
3690
3802
  notify(onError, err, "onError");
3691
3803
  }
@@ -3897,6 +4009,8 @@ function extractRedactors(rule) {
3897
4009
  }
3898
4010
  } else if ((m.type === "contains" || m.type === "stripos") && m.value != null) {
3899
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 ?? "") } });
3900
4014
  } else if (m.type === "array_key_value" && m.match && isBodyParam(c.parameter)) {
3901
4015
  const keys = Array.isArray(m.key) ? m.key : [m.key];
3902
4016
  for (const key of keys) {
@@ -3915,7 +4029,11 @@ function applyRedactors(body, redactors, mask, transform) {
3915
4029
  let out = body;
3916
4030
  for (const r of redactors) {
3917
4031
  if (r.re) out = out.replace(r.re, transform ? (m) => transform(m) : mask);
3918
- 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) {
3919
4037
  const re = new RegExp(r.literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
3920
4038
  out = out.replace(re, (m) => transform ? transform(m) : mask);
3921
4039
  }