@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.cjs CHANGED
@@ -682,7 +682,7 @@ async function walkNodeModules(cwd) {
682
682
  seen.set(await (0, import_promises5.realpath)(cwd), -1);
683
683
  } catch {
684
684
  }
685
- return dedupeByShallowest(await walk(root, 0, seen));
685
+ return dedupeByShallowest(await walk(root, 0, seen, cwd));
686
686
  }
687
687
  function dedupeByShallowest(found) {
688
688
  const best = /* @__PURE__ */ new Map();
@@ -692,7 +692,7 @@ function dedupeByShallowest(found) {
692
692
  }
693
693
  return [...best.values()].map(({ entry }) => entry);
694
694
  }
695
- async function walk(dir, depth, seen) {
695
+ async function walk(dir, depth, seen, root) {
696
696
  let names;
697
697
  try {
698
698
  names = await (0, import_promises5.readdir)(dir);
@@ -704,7 +704,7 @@ async function walk(dir, depth, seen) {
704
704
  const fullPath = import_node_path4.default.join(dir, name);
705
705
  const resolved = await claim(fullPath, seen, depth);
706
706
  if (resolved === null) return [];
707
- if (PACKAGE_STORE_DIRS.has(name)) return walk(fullPath, depth, seen);
707
+ if (PACKAGE_STORE_DIRS.has(name)) return walk(fullPath, depth, seen, root);
708
708
  if (name.startsWith("@")) {
709
709
  let subNames;
710
710
  try {
@@ -717,16 +717,16 @@ async function walk(dir, depth, seen) {
717
717
  const scopedDir = import_node_path4.default.join(fullPath, sub);
718
718
  const scopedReal = await claim(scopedDir, seen, depth);
719
719
  if (scopedReal === null) return [];
720
- return packageAndNested(scopedDir, scopedReal, depth, seen);
720
+ return packageAndNested(scopedDir, scopedReal, depth, seen, root);
721
721
  });
722
722
  return scoped.flat();
723
723
  }
724
- return packageAndNested(fullPath, resolved, depth, seen);
724
+ return packageAndNested(fullPath, resolved, depth, seen, root);
725
725
  });
726
726
  return perEntry.flat();
727
727
  }
728
- async function packageAndNested(pkgDir, resolved, depth, seen) {
729
- const [own, nested] = await Promise.all([readPackage(pkgDir, depth), walkNested(pkgDir, depth, seen)]);
728
+ async function packageAndNested(pkgDir, resolved, depth, seen, root) {
729
+ const [own, nested] = await Promise.all([readPackage(pkgDir, depth, root), walkNested(pkgDir, depth, seen, root)]);
730
730
  return own === null ? nested : [{ entry: own, realpath: resolved, depth }, ...nested];
731
731
  }
732
732
  async function mapLimit(items, fn) {
@@ -741,7 +741,10 @@ async function mapLimit(items, fn) {
741
741
  await Promise.all(workers);
742
742
  return results;
743
743
  }
744
- async function readPackage(pkgDir, depth) {
744
+ function installLocation(root, pkgDir, impl = import_node_path4.default) {
745
+ return impl.relative(root, pkgDir).split(impl.sep).join("/");
746
+ }
747
+ async function readPackage(pkgDir, depth, root) {
745
748
  let raw;
746
749
  try {
747
750
  raw = await (0, import_promises5.readFile)(import_node_path4.default.join(pkgDir, "package.json"), "utf8");
@@ -760,14 +763,14 @@ async function readPackage(pkgDir, depth) {
760
763
  if (typeof parsed.version !== "string" || parsed.version.length === 0) {
761
764
  return null;
762
765
  }
763
- return { name: parsed.name, version: parsed.version, direct: depth === 0 };
766
+ return { name: parsed.name, version: parsed.version, path: installLocation(root, pkgDir), direct: depth === 0 };
764
767
  }
765
- async function walkNested(pkgDir, depth, seen) {
768
+ async function walkNested(pkgDir, depth, seen, root) {
766
769
  const nested = import_node_path4.default.join(pkgDir, "node_modules");
767
770
  if (await claim(nested, seen, depth + 1) === null) {
768
771
  return [];
769
772
  }
770
- return walk(nested, depth + 1, seen);
773
+ return walk(nested, depth + 1, seen, root);
771
774
  }
772
775
  async function claim(dir, seen, depth) {
773
776
  let resolved;
@@ -1453,21 +1456,33 @@ var init_parsers = __esm({
1453
1456
  });
1454
1457
 
1455
1458
  // src/normalize.ts
1456
- function buildWirePayload(manifest) {
1459
+ function buildWirePayload(manifest, options = {}) {
1460
+ const withPaths = options.installPaths === true;
1457
1461
  const seen = /* @__PURE__ */ new Map();
1458
1462
  const wirePackages = [];
1463
+ const byIdentity = /* @__PURE__ */ new Map();
1464
+ let entriesWithoutPath = 0;
1459
1465
  for (const entry of manifest.packages) {
1466
+ const identity = `${entry.name}@${entry.version}`;
1467
+ const location = withPaths && entry.path !== void 0 && entry.path !== "" ? entry.path : void 0;
1468
+ if (location === void 0) entriesWithoutPath++;
1469
+ const existing = byIdentity.get(identity);
1470
+ if (existing) {
1471
+ if (location !== void 0) addPath(existing, location);
1472
+ continue;
1473
+ }
1460
1474
  const versions = seen.get(entry.name);
1461
1475
  if (versions) {
1462
- if (versions.has(entry.version)) {
1463
- continue;
1464
- }
1465
1476
  versions.add(entry.version);
1466
1477
  } else {
1467
1478
  seen.set(entry.name, /* @__PURE__ */ new Set([entry.version]));
1468
1479
  }
1469
- wirePackages.push({ name: entry.name, version: entry.version });
1480
+ const wire = { name: entry.name, version: entry.version };
1481
+ if (location !== void 0) addPath(wire, location);
1482
+ byIdentity.set(identity, wire);
1483
+ wirePackages.push(wire);
1470
1484
  }
1485
+ for (const wire of wirePackages) wire.paths?.sort();
1471
1486
  wirePackages.sort((a, b) => {
1472
1487
  if (a.name === b.name) {
1473
1488
  return compareVersions(a.version, b.version);
@@ -1481,7 +1496,18 @@ function buildWirePayload(manifest) {
1481
1496
  }
1482
1497
  }
1483
1498
  return {
1484
- payload: { ecosystem: manifest.ecosystem, packages: wirePackages },
1499
+ payload: {
1500
+ ecosystem: manifest.ecosystem,
1501
+ packages: wirePackages,
1502
+ // Every entry had a location, so a missing path anywhere means the package is not installed there.
1503
+ // One entry without one forfeits that for the whole payload: a consumer reading a short `paths` has
1504
+ // no way to tell which entry was the incomplete one.
1505
+ //
1506
+ // No separate check for "locations were not requested": with them off every entry counts as without
1507
+ // one, so this is already false — which is the default, and the safe direction. An explicit
1508
+ // `withPaths &&` here was a guard no test could distinguish from its absence.
1509
+ installPathsComplete: entriesWithoutPath === 0 && manifest.packages.length > 0
1510
+ },
1485
1511
  stats: {
1486
1512
  uniqueNames: seen.size,
1487
1513
  duplicateNames,
@@ -1489,6 +1515,10 @@ function buildWirePayload(manifest) {
1489
1515
  }
1490
1516
  };
1491
1517
  }
1518
+ function addPath(wire, installPath) {
1519
+ if (wire.paths === void 0) wire.paths = [installPath];
1520
+ else if (!wire.paths.includes(installPath)) wire.paths.push(installPath);
1521
+ }
1492
1522
  function compareVersions(a, b) {
1493
1523
  if (a === b) {
1494
1524
  return 0;
@@ -2553,6 +2583,30 @@ function expandIPv6(host) {
2553
2583
  }
2554
2584
  return out;
2555
2585
  }
2586
+ 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;
2587
+ function base64UrlToBase64(segment) {
2588
+ const padded = segment.length % 4 === 0 ? segment : segment + "=".repeat(4 - segment.length % 4);
2589
+ return padded.replace(/-/g, "+").replace(/_/g, "/");
2590
+ }
2591
+ function jwtClaimSpans(text, claim2, value) {
2592
+ const found = /* @__PURE__ */ new Set();
2593
+ if (typeof text !== "string" || typeof claim2 !== "string" || claim2 === "") return [];
2594
+ const wanted = String(value);
2595
+ for (const [token] of text.matchAll(JWT_CANDIDATE)) {
2596
+ const parts = token.split(".");
2597
+ let payload;
2598
+ try {
2599
+ payload = JSON.parse(base64DecodeUtf8(base64UrlToBase64(parts[1])));
2600
+ } catch {
2601
+ continue;
2602
+ }
2603
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) continue;
2604
+ if (!Object.prototype.hasOwnProperty.call(payload, claim2)) continue;
2605
+ const actual = payload[claim2];
2606
+ if (typeof actual === "string" && actual === wanted) found.add(token);
2607
+ }
2608
+ return [...found];
2609
+ }
2556
2610
  var warnedScopes = /* @__PURE__ */ new Set();
2557
2611
  function warnUnrecognisedScope(when) {
2558
2612
  const key = Object.keys(when).sort().join(",");
@@ -2581,16 +2635,30 @@ function ruleAppliesTo(when, resolver) {
2581
2635
  return true;
2582
2636
  }
2583
2637
  }
2638
+ function foldPathForScope(value) {
2639
+ const out = String(value).toLowerCase();
2640
+ return out.endsWith("/") ? out.slice(0, -1) : out;
2641
+ }
2642
+ var REGEX_SYNTAX = /[\\^$*+?()[\]{}|]/;
2643
+ var REGEX_FORM = /^\/(.+?)\/([gimsuy]*)$/s;
2644
+ function isRegexForm(pattern) {
2645
+ const parts = REGEX_FORM.exec(pattern);
2646
+ return parts !== null && REGEX_SYNTAX.test(parts[1]);
2647
+ }
2584
2648
  function pathMatches(pattern, path7) {
2585
- if (pattern.length > 2 && pattern.startsWith("/") && pattern.endsWith("/")) {
2649
+ if (isRegexForm(pattern)) {
2586
2650
  const re = safeRegExp(pattern);
2587
2651
  return re ? re.test(path7) : false;
2588
2652
  }
2589
- if (pattern.includes("*")) {
2590
- const re = safeRegExp("/^" + pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^?]*") + "$/");
2591
- return re ? re.test(path7) : false;
2653
+ const target = foldPathForScope(path7);
2654
+ const folded = foldPathForScope(pattern);
2655
+ if (folded.includes("*")) {
2656
+ const trailing = folded.endsWith("/*");
2657
+ const body = (trailing ? folded.slice(0, -2) : folded).replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^?]*");
2658
+ const re = safeRegExp("/^" + body + (trailing ? "(?:/[^?]*)?" : "") + "$/");
2659
+ return re ? re.test(target) : false;
2592
2660
  }
2593
- return path7 === pattern;
2661
+ return target === folded;
2594
2662
  }
2595
2663
  function normalizeDefaultPort(host) {
2596
2664
  return String(host).toLowerCase().replace(/:(?:80|443)$/, "");
@@ -2743,6 +2811,8 @@ function matchValue(type, value, matchVal, matchObj) {
2743
2811
  const stripped = strValue.replace(/[ _\-,]/g, "");
2744
2812
  return ctypeResult(strValue, /^[\w$€-￿]*$/.test(stripped), matchVal);
2745
2813
  }
2814
+ case "jwt_claim_equals":
2815
+ return jwtClaimSpans(strValue, matchObj?.claim, matchVal).length > 0;
2746
2816
  case "array_key_value":
2747
2817
  return arrayKeyValue(value, matchObj);
2748
2818
  // file_contains (uploaded-file content scanning) is NOT WordPress-specific, but is
@@ -3509,13 +3579,43 @@ var DEFAULT_RESPONSE_RULES = [
3509
3579
  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])/" } }]
3510
3580
  },
3511
3581
  {
3512
- id: "resp-jwt",
3513
- title: "JWT in response body",
3582
+ id: "resp-supabase-secret-key",
3583
+ title: "Supabase secret key in response body",
3514
3584
  phase: "response",
3515
3585
  category: "secret-exposure",
3516
3586
  action: "redact",
3587
+ // Supabase's opaque key format, which is not a JWT and so is not covered by the JWT rule below.
3588
+ // `sb_secret_` is the elevated one — documented as full access, bypassing Row Level Security,
3589
+ // backend-only — while `sb_publishable_` is meant for public clients and must pass untouched.
3590
+ // Both key systems are live at once: new keys sit alongside the legacy `anon` / `service_role`
3591
+ // JWTs rather than replacing them, so this rule and the JWT rule cover different halves.
3592
+ //
3593
+ // The documented grammar is the prefix, 22 base64url characters, `_`, then an 8-character
3594
+ // base64url checksum. Matching it exactly is what keeps the mask on the key and off the response
3595
+ // around it: a variable-length suffix would either run past the key into adjacent syntax or stop
3596
+ // short and leave the tail of a real key visible. The trailing guard rejects a longer run of key
3597
+ // characters, which is not this format.
3598
+ prefilter: ["sb_secret_"],
3599
+ 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_-])/" } }]
3600
+ },
3601
+ {
3602
+ id: "resp-supabase-service-role-key",
3603
+ title: "Supabase service_role key in response body",
3604
+ phase: "response",
3605
+ category: "secret-exposure",
3606
+ action: "redact",
3607
+ // Scoped to the one role that must never leave: `service_role` bypasses Row Level Security
3608
+ // entirely. A JWT in a response body is not inherently a leak — the Supabase `anon` key is public
3609
+ // by design and a login response carries the user's own access token — so this decides on the
3610
+ // decoded payload rather than on the shape of the token.
3611
+ //
3612
+ // `jwt_claim_equals` also yields the matching token spans, which is what lets `redact` mask those
3613
+ // tokens and serve the rest of the response.
3614
+ //
3615
+ // A broad any-JWT rule is a reasonable thing to want; it belongs in the managed hardening bundle as
3616
+ // an opt-in, observe-only rule rather than an on-by-default mask.
3517
3617
  prefilter: ["eyJ"],
3518
- 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/" } }]
3618
+ rule_v2: [{ parameter: "response.body", match: { type: "jwt_claim_equals", claim: "role", value: "service_role" } }]
3519
3619
  },
3520
3620
  {
3521
3621
  id: "resp-db-connection-string",
@@ -3798,6 +3898,7 @@ var MATCH_TYPES = Object.freeze([
3798
3898
  "internal_host",
3799
3899
  "is_numeric",
3800
3900
  "isset",
3901
+ "jwt_claim_equals",
3801
3902
  "less_than",
3802
3903
  "more_than",
3803
3904
  "not_contains",
@@ -3823,6 +3924,8 @@ var OPERANDLESS_MATCH_TYPES = Object.freeze([
3823
3924
  "cors_reflected"
3824
3925
  ]);
3825
3926
  var OPERAND_SHAPES = Object.freeze([
3927
+ "positive-number",
3928
+ "boolean",
3826
3929
  "non-empty-string",
3827
3930
  "regex-literal",
3828
3931
  "string-or-string-list",
@@ -3847,6 +3950,14 @@ var MATCH_OPERANDS = Object.freeze({
3847
3950
  required: Object.freeze(["key", "match"]),
3848
3951
  shapes: Object.freeze({ key: "string-or-string-list", match: "object" })
3849
3952
  }),
3953
+ // Decides on a JWT's DECODED payload rather than its shape, and yields the matching token spans so
3954
+ // `redact` masks only those tokens. `claim` names an own, top-level claim; `value` is compared with
3955
+ // exact string equality. Anything not positively identified — undecodable, not JSON, not an object,
3956
+ // oversized, claim missing, claim not a string, or a different value — is a no-match.
3957
+ jwt_claim_equals: Object.freeze({
3958
+ required: Object.freeze(["claim", "value"]),
3959
+ shapes: Object.freeze({ claim: "non-empty-string", value: "non-empty-string" })
3960
+ }),
3850
3961
  regex: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "regex-literal" }) }),
3851
3962
  contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
3852
3963
  stripos: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
@@ -3872,6 +3983,10 @@ function operandShapeProblem(shape, value) {
3872
3983
  const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
3873
3984
  const isHeaderName = (v) => typeof v === "string" && HEADER_NAME.test(v);
3874
3985
  switch (shape) {
3986
+ case "positive-number":
3987
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? null : "must be a finite positive number";
3988
+ case "boolean":
3989
+ return typeof value === "boolean" ? null : "must be true or false";
3875
3990
  case "non-empty-string":
3876
3991
  return isFilledString(value) ? null : "must be a non-empty string";
3877
3992
  case "regex-literal":
@@ -3954,6 +4069,13 @@ var ACTIONS = Object.freeze([
3954
4069
  "remove-header",
3955
4070
  "harden-cookie"
3956
4071
  ]);
4072
+ var RULE_PROPERTY_SHAPES = Object.freeze({
4073
+ max_bytes: "positive-number",
4074
+ bypass_limit: "boolean",
4075
+ ensure: "boolean",
4076
+ prefilter: "non-empty-string-list"
4077
+ });
4078
+ var ENFORCEMENT_VALUES = Object.freeze(["dry-run"]);
3957
4079
  var WHEN_KEYS = Object.freeze(["path", "method"]);
3958
4080
  var RULE_PROPERTIES = Object.freeze([
3959
4081
  "id",
@@ -4129,6 +4251,24 @@ function whenProblem(when) {
4129
4251
  function isNonEmptyString(value) {
4130
4252
  return typeof value === "string" && value.trim() !== "";
4131
4253
  }
4254
+ function nullPropertyProblem(rule) {
4255
+ if (!rule || typeof rule !== "object") return null;
4256
+ for (const property of RULE_PROPERTIES) {
4257
+ if (property in rule && rule[property] === null) {
4258
+ 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`;
4259
+ }
4260
+ }
4261
+ return null;
4262
+ }
4263
+ function rulePropertyProblem(rule) {
4264
+ if (!rule || typeof rule !== "object") return null;
4265
+ for (const [property, shape] of Object.entries(RULE_PROPERTY_SHAPES)) {
4266
+ if (rule[property] === void 0 || rule[property] === null) continue;
4267
+ const problem = operandShapeProblem(shape, rule[property]);
4268
+ if (problem) return `"${property}" ${problem}`;
4269
+ }
4270
+ return null;
4271
+ }
4132
4272
  function actionProblem(action, rule) {
4133
4273
  if (action === void 0 || action === null) return null;
4134
4274
  if (!ACTIONS.includes(action)) return `unknown action "${action}"`;
@@ -4193,13 +4333,15 @@ function idOf(rule) {
4193
4333
  }
4194
4334
  function ruleProblem(rule) {
4195
4335
  if (!rule || typeof rule !== "object") return "not an object";
4336
+ const nullReason = nullPropertyProblem(rule);
4337
+ if (nullReason) return nullReason;
4196
4338
  if (rule.phase !== void 0 && !PHASES2.has(rule.phase)) return `unknown phase "${rule.phase}"`;
4197
4339
  const actionReason = actionProblem(rule.action, rule);
4198
4340
  if (actionReason) return actionReason;
4199
4341
  const scopeReason = whenProblem(rule.when);
4200
4342
  if (scopeReason) return scopeReason;
4201
- const capOverride = rule.max_bytes;
4202
- if (capOverride !== void 0 && !(Number(capOverride) > 0)) return "max_bytes must be a positive number";
4343
+ const propertyReason = rulePropertyProblem(rule);
4344
+ if (propertyReason) return propertyReason;
4203
4345
  return conditionsProblem(rule.rule_v2);
4204
4346
  }
4205
4347
  function conditionsProblem(conditions, depth = 0) {
@@ -5491,6 +5633,8 @@ function extractRedactors(rule) {
5491
5633
  }
5492
5634
  } else if ((m.type === "contains" || m.type === "stripos") && m.value != null) {
5493
5635
  out.push({ literal: String(m.value) });
5636
+ } else if (m.type === "jwt_claim_equals" && typeof m.claim === "string") {
5637
+ out.push({ jwtClaim: { claim: m.claim, value: String(m.value ?? "") } });
5494
5638
  } else if (m.type === "array_key_value" && m.match && isBodyParam(c.parameter)) {
5495
5639
  const keys = Array.isArray(m.key) ? m.key : [m.key];
5496
5640
  for (const key of keys) {
@@ -5509,7 +5653,11 @@ function applyRedactors(body, redactors, mask, transform) {
5509
5653
  let out = body;
5510
5654
  for (const r of redactors) {
5511
5655
  if (r.re) out = out.replace(r.re, transform ? (m) => transform(m) : mask);
5512
- else if (r.literal) {
5656
+ else if (r.jwtClaim) {
5657
+ for (const token of jwtClaimSpans(out, r.jwtClaim.claim, r.jwtClaim.value)) {
5658
+ out = out.split(token).join(transform ? transform(token) : mask);
5659
+ }
5660
+ } else if (r.literal) {
5513
5661
  const re = new RegExp(r.literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
5514
5662
  out = out.replace(re, (m) => transform ? transform(m) : mask);
5515
5663
  }