@patchstack/connect 0.4.0 → 0.4.2

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
@@ -3109,6 +3109,11 @@ var DEFAULT_EGRESS_RULES = [
3109
3109
  title: "Outbound request to an internal / metadata address (SSRF)",
3110
3110
  phase: "egress",
3111
3111
  category: "ssrf",
3112
+ // Declared, not implied. Nothing in the egress path reads it — a match refuses the call, and the
3113
+ // rule's mode decides whether it actually did — but the action is part of how a rule describes
3114
+ // itself to whatever reports on it, and a rule that declares nothing is reported as a rule nobody
3115
+ // can classify.
3116
+ action: "block",
3112
3117
  rule_v2: [{ parameter: "egress.host", match: { type: "internal_host" } }]
3113
3118
  }
3114
3119
  ];
@@ -3475,10 +3480,25 @@ function worthRetrying(status) {
3475
3480
  }
3476
3481
  var ATTEMPT_TIMEOUT_MS = 1e4;
3477
3482
  var STOP_BUDGET_MS = 5e3;
3483
+ var CATEGORY_PATTERN = /^[a-z][a-z0-9-]*$/;
3484
+ var RESERVED_CATEGORY = "unknown";
3485
+ function declaredClass(declared, recognised) {
3486
+ if (typeof declared !== "string" || declared === "") return { value: null, dropped: false };
3487
+ if (!recognised(declared)) return { value: null, dropped: false };
3488
+ if (declared.length > MAX_CLASS_CHARS) return { value: null, dropped: true };
3489
+ return { value: declared, dropped: false };
3490
+ }
3491
+ var recognisedCategory = (value) => value !== RESERVED_CATEGORY && CATEGORY_PATTERN.test(value);
3492
+ var recognisedAction = (value) => ACTIONS.includes(value);
3478
3493
  var MAX_ROUTE_CHARS = 256;
3479
3494
  var MAX_PARAMETERS = 25;
3480
3495
  var MAX_PARAMETER_CHARS = 64;
3481
3496
  var MAX_IDENTIFIER_CHARS = 256;
3497
+ var MAX_CLASS_CHARS = 64;
3498
+ var EVENT_PATTERN = /^[0-9a-f]{32}$/;
3499
+ function eventIdentity(value) {
3500
+ return typeof value === "string" && EVENT_PATTERN.test(value) ? value : null;
3501
+ }
3482
3502
  var MAX_CAPTURED_VALUES = 10;
3483
3503
  var MAX_CAPTURED_VALUE_CHARS = 512;
3484
3504
  var MAX_QUERY_KEYS = 10;
@@ -3938,6 +3958,8 @@ function createDetectionReporter(opts) {
3938
3958
  const id = capText(String(ruleId), MAX_IDENTIFIER_CHARS);
3939
3959
  const revision = capText(revisionOf(detection.rule) ?? "", MAX_IDENTIFIER_CHARS);
3940
3960
  const etag = capText(rulesEtag ?? "", MAX_IDENTIFIER_CHARS);
3961
+ const classCategory = declaredClass(detection.rule?.category, recognisedCategory);
3962
+ const classAction = declaredClass(detection.rule?.action, recognisedAction);
3941
3963
  for (const [name, field] of [
3942
3964
  ["rule_id", id],
3943
3965
  ["rule_revision", revision],
@@ -3945,6 +3967,12 @@ function createDetectionReporter(opts) {
3945
3967
  ]) {
3946
3968
  if (field.truncated) truncated.push(name);
3947
3969
  }
3970
+ for (const [name, field] of [
3971
+ ["category", classCategory],
3972
+ ["action", classAction]
3973
+ ]) {
3974
+ if (field.dropped) truncated.push(name);
3975
+ }
3948
3976
  queue.push({
3949
3977
  rule_id: id.value,
3950
3978
  route: route.value,
@@ -3965,7 +3993,32 @@ function createDetectionReporter(opts) {
3965
3993
  ...query.total > queryKeys.length ? { query_keys_total: query.total } : {},
3966
3994
  // Who asked. Capped, since it is client-supplied text and this is an event with a size bound.
3967
3995
  user_agent: userAgent === null ? null : userAgent.value,
3996
+ // What KIND of match this was: which phase it happened in, what class of thing the rule is for,
3997
+ // and what the rule DECLARES it does about it.
3998
+ //
3999
+ // Three separate facts, and none of them is `enforced` below. A rule declaring `block` while
4000
+ // observing reports exactly that — `action: 'block'`, `enforced: false` — which is what a
4001
+ // dry-run window consists of. Reading either off the other would describe such a window as
4002
+ // protection that never happened, or as rules that do nothing.
4003
+ //
4004
+ // `null` where the rule says nothing, never a guess. A consumer can tell "this rule is for
4005
+ // secret exposure" from "we cannot say what this rule is for", and a filled-in value would take
4006
+ // that distinction away for the sake of a tidier field.
3968
4007
  phase: detection.phase ?? null,
4008
+ // `null` where the rule declared nothing, and also where what it declared could not be carried:
4009
+ // both are "we cannot say what this rule is for", which is a different fact from a class we do
4010
+ // know, and `truncated` distinguishes the second from the first.
4011
+ category: classCategory.value,
4012
+ action: classAction.value,
4013
+ // Which call this detection belongs to, so a consumer can tell one call two rules saw from two
4014
+ // separate calls. Two rules matching one call is the ordinary case — a rule that enforces and a
4015
+ // rule that only observes are meant to match the same thing — so without this, adding these up
4016
+ // reports one call more than once.
4017
+ //
4018
+ // Nothing about the request goes into it: it is only ever compared with other identities, so
4019
+ // deriving it from the address or the path would carry something about whoever made the request
4020
+ // into a place nothing needs it. That is a property of how it is minted, not of its shape.
4021
+ event: eventIdentity(detection.event),
3969
4022
  // The state this detection was handled under, which is the whole point: `false` is a rule that
3970
4023
  // saw traffic it would have stopped.
3971
4024
  enforced: detection.mode === "block",
@@ -4587,8 +4640,9 @@ async function createProtection(options = {}) {
4587
4640
  if (!permitsAnything(entry.plan)) return { plan: entry.reference };
4588
4641
  return { plan: entry.reference, ...captureValues(entry.plan, result.resolver) };
4589
4642
  };
4590
- const decide = (phase, result, block, allow2, ctx = {}) => {
4643
+ const decide = (phase, result, block, allow2, describe = () => ({})) => {
4591
4644
  if (!result || !result.blocked) return allow2();
4645
+ const ctx = describe() ?? {};
4592
4646
  const effectiveMode = ruleMode(result.rule);
4593
4647
  onDetect({
4594
4648
  phase,
@@ -4601,6 +4655,9 @@ async function createProtection(options = {}) {
4601
4655
  method: ctx.method,
4602
4656
  path: ctx.path,
4603
4657
  ip: ctx.ip,
4658
+ // Which call this was. Named here rather than spread from `ctx` for the same reason as everything
4659
+ // else in this payload: a field reaches the wire because someone listed it.
4660
+ event: ctx.event ?? null,
4604
4661
  // Provenance travels with the address. Without it a consumer cannot tell an observed peer from a
4605
4662
  // value read out of a forwarded header, and `null` from "there was no address to establish".
4606
4663
  clientIpSource: ctx.clientIpSource,
@@ -4732,7 +4789,7 @@ async function createProtection(options = {}) {
4732
4789
  result,
4733
4790
  () => blockResponse(result, request),
4734
4791
  () => null,
4735
- requestMeta(shaped, request)
4792
+ () => requestMeta(shaped, request)
4736
4793
  );
4737
4794
  return { blocked, client: shaped?._clientIp };
4738
4795
  };
@@ -4747,7 +4804,12 @@ async function createProtection(options = {}) {
4747
4804
  originalUrl: u.pathname + u.search,
4748
4805
  headers,
4749
4806
  ip: resolved.ip ?? "",
4750
- _clientIp: resolved
4807
+ _clientIp: resolved,
4808
+ // The request itself, so the identity can be asked for LATER. This context is built on every
4809
+ // request whether or not anything matches, so asking here would mint one for every request —
4810
+ // and asking of the context rather than of the request would give the response a different
4811
+ // identity from the request that caused it, reporting one call as two.
4812
+ _eventOf: request
4751
4813
  };
4752
4814
  } catch {
4753
4815
  return void 0;
@@ -4758,7 +4820,10 @@ async function createProtection(options = {}) {
4758
4820
  originalUrl: req.url,
4759
4821
  headers: req.headers || {},
4760
4822
  ip: client?.ip ?? "",
4761
- _clientIp: client ?? { ip: null, source: "unavailable" }
4823
+ _clientIp: client ?? { ip: null, source: "unavailable" },
4824
+ // The request itself, for the same reasons as on the fetch path above: asked for later, and
4825
+ // asked of the request rather than of this context.
4826
+ _eventOf: req
4762
4827
  } : void 0;
4763
4828
  const screenResp = async (response, reqCtx) => {
4764
4829
  const read = await readTextResponse(response, screenCap);
@@ -5005,6 +5070,7 @@ async function createProtection(options = {}) {
5005
5070
  } catch {
5006
5071
  egressPath = typeof url === "string" ? url : null;
5007
5072
  }
5073
+ const egressEvent = mintEvent();
5008
5074
  let block = false;
5009
5075
  for (const { rule, engine: re } of egressRuleSet) {
5010
5076
  let result;
@@ -5022,6 +5088,14 @@ async function createProtection(options = {}) {
5022
5088
  category: rule?.category,
5023
5089
  rule,
5024
5090
  message: result.message,
5091
+ // One identity for this outbound call, shared by every rule that matches it — the egress phase
5092
+ // evaluates all of them rather than stopping at the first, so without this two rules refusing one
5093
+ // call would count as two calls refused.
5094
+ //
5095
+ // Its own identity, not the identity of whatever request the application was serving when it made
5096
+ // the call. An outbound attempt is a thing that happened in its own right, and a call made outside
5097
+ // any request — a job, a timer — has no request to belong to.
5098
+ event: egressEvent,
5025
5099
  method: typeof method === "string" ? method : null,
5026
5100
  path: egressPath,
5027
5101
  capture: evidenceFrom(result)
@@ -5097,7 +5171,7 @@ async function createProtection(options = {}) {
5097
5171
  if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
5098
5172
  next();
5099
5173
  },
5100
- nodeRequestMeta(req, client)
5174
+ () => nodeRequestMeta(req, client)
5101
5175
  );
5102
5176
  };
5103
5177
  },
@@ -5161,7 +5235,7 @@ async function createProtection(options = {}) {
5161
5235
  if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, shaped?._clientIp));
5162
5236
  next();
5163
5237
  },
5164
- nodeRequestMeta(req, shaped?._clientIp)
5238
+ () => nodeRequestMeta(req, shaped?._clientIp)
5165
5239
  );
5166
5240
  }
5167
5241
  }
@@ -5184,7 +5258,7 @@ async function createProtection(options = {}) {
5184
5258
  let reporter = null;
5185
5259
  if (refreshable && options.siteUuid && options.reportManifest !== false && cwd) {
5186
5260
  try {
5187
- ({ reportManifest: reporter } = await import("./refresh-manifest-SWCPQ52Z.js"));
5261
+ ({ reportManifest: reporter } = await import("./refresh-manifest-JNCTIAM5.js"));
5188
5262
  } catch (err) {
5189
5263
  notify(onError, err, "onError");
5190
5264
  }
@@ -5635,11 +5709,37 @@ function defaultOnDetect({ phase, mode, category, rule, message }) {
5635
5709
  const tag = mode === "block" ? "BLOCK" : "DETECT (dry-run)";
5636
5710
  console.warn(`[patchstack] ${tag} phase=${phase ?? "request"} category=${category ?? "?"} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
5637
5711
  }
5712
+ var eventIdentities = /* @__PURE__ */ new WeakMap();
5713
+ function mintEvent() {
5714
+ try {
5715
+ const bytes = globalThis.crypto?.getRandomValues?.(new Uint8Array(16));
5716
+ if (bytes) return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
5717
+ } catch {
5718
+ }
5719
+ try {
5720
+ let hex = Date.now().toString(16);
5721
+ while (hex.length < 32) hex += Math.floor(Math.random() * 4294967295).toString(16).padStart(8, "0");
5722
+ return hex.slice(0, 32);
5723
+ } catch {
5724
+ return null;
5725
+ }
5726
+ }
5727
+ function eventFor(request) {
5728
+ if (request === null || typeof request !== "object") return mintEvent();
5729
+ const existing = eventIdentities.get(request);
5730
+ if (existing !== void 0) return existing;
5731
+ const minted = mintEvent();
5732
+ if (minted !== null) eventIdentities.set(request, minted);
5733
+ return minted;
5734
+ }
5638
5735
  function requestMetaFromContext(reqCtx) {
5639
5736
  if (!reqCtx) return {};
5640
5737
  const client = reqCtx._clientIp ?? { ip: null, source: "unavailable" };
5641
5738
  return {
5642
5739
  method: reqCtx.method ?? null,
5740
+ // The originating request's identity, so a response detection and the request detection for the same
5741
+ // request are one event rather than two. Asked for here, which is inside a detection being raised.
5742
+ event: eventFor(reqCtx._eventOf),
5643
5743
  // Path AND query. The reporter is what drops the query's VALUES, keeping its parameter names, so
5644
5744
  // trimming it here would leave a Fetch or response detection unable to say what was requested.
5645
5745
  path: typeof reqCtx.originalUrl === "string" ? reqCtx.originalUrl : null,
@@ -5663,7 +5763,7 @@ function requestMeta(shaped, request) {
5663
5763
  method = request.method ?? null;
5664
5764
  userAgent = request.headers?.get?.("user-agent") ?? null;
5665
5765
  }
5666
- return { method, path, ip: client.ip, clientIpSource: client.source, userAgent };
5766
+ return { method, path, ip: client.ip, clientIpSource: client.source, userAgent, event: eventFor(request) };
5667
5767
  }
5668
5768
  function nodeRequestMeta(req, client) {
5669
5769
  if (!req) return {};
@@ -5677,7 +5777,8 @@ function nodeRequestMeta(req, client) {
5677
5777
  // verified, and a second derivation could disagree with the one the engine evaluated.
5678
5778
  ip: resolved.ip,
5679
5779
  clientIpSource: resolved.source,
5680
- userAgent: typeof ua === "string" ? ua : Array.isArray(ua) ? ua[0] : null
5780
+ userAgent: typeof ua === "string" ? ua : Array.isArray(ua) ? ua[0] : null,
5781
+ event: eventFor(req)
5681
5782
  };
5682
5783
  }
5683
5784
  export {