@patchstack/connect 0.4.0 → 0.4.1

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
@@ -4736,6 +4736,11 @@ var DEFAULT_EGRESS_RULES = [
4736
4736
  title: "Outbound request to an internal / metadata address (SSRF)",
4737
4737
  phase: "egress",
4738
4738
  category: "ssrf",
4739
+ // Declared, not implied. Nothing in the egress path reads it — a match refuses the call, and the
4740
+ // rule's mode decides whether it actually did — but the action is part of how a rule describes
4741
+ // itself to whatever reports on it, and a rule that declares nothing is reported as a rule nobody
4742
+ // can classify.
4743
+ action: "block",
4739
4744
  rule_v2: [{ parameter: "egress.host", match: { type: "internal_host" } }]
4740
4745
  }
4741
4746
  ];
@@ -5103,10 +5108,25 @@ function worthRetrying(status) {
5103
5108
  }
5104
5109
  var ATTEMPT_TIMEOUT_MS = 1e4;
5105
5110
  var STOP_BUDGET_MS = 5e3;
5111
+ var CATEGORY_PATTERN = /^[a-z][a-z0-9-]*$/;
5112
+ var RESERVED_CATEGORY = "unknown";
5113
+ function declaredClass(declared, recognised) {
5114
+ if (typeof declared !== "string" || declared === "") return { value: null, dropped: false };
5115
+ if (!recognised(declared)) return { value: null, dropped: false };
5116
+ if (declared.length > MAX_CLASS_CHARS) return { value: null, dropped: true };
5117
+ return { value: declared, dropped: false };
5118
+ }
5119
+ var recognisedCategory = (value) => value !== RESERVED_CATEGORY && CATEGORY_PATTERN.test(value);
5120
+ var recognisedAction = (value) => ACTIONS.includes(value);
5106
5121
  var MAX_ROUTE_CHARS = 256;
5107
5122
  var MAX_PARAMETERS = 25;
5108
5123
  var MAX_PARAMETER_CHARS = 64;
5109
5124
  var MAX_IDENTIFIER_CHARS = 256;
5125
+ var MAX_CLASS_CHARS = 64;
5126
+ var EVENT_PATTERN = /^[0-9a-f]{32}$/;
5127
+ function eventIdentity(value) {
5128
+ return typeof value === "string" && EVENT_PATTERN.test(value) ? value : null;
5129
+ }
5110
5130
  var MAX_CAPTURED_VALUES = 10;
5111
5131
  var MAX_CAPTURED_VALUE_CHARS = 512;
5112
5132
  var MAX_QUERY_KEYS = 10;
@@ -5566,6 +5586,8 @@ function createDetectionReporter(opts) {
5566
5586
  const id = capText(String(ruleId), MAX_IDENTIFIER_CHARS);
5567
5587
  const revision = capText(revisionOf(detection.rule) ?? "", MAX_IDENTIFIER_CHARS);
5568
5588
  const etag = capText(rulesEtag ?? "", MAX_IDENTIFIER_CHARS);
5589
+ const classCategory = declaredClass(detection.rule?.category, recognisedCategory);
5590
+ const classAction = declaredClass(detection.rule?.action, recognisedAction);
5569
5591
  for (const [name, field] of [
5570
5592
  ["rule_id", id],
5571
5593
  ["rule_revision", revision],
@@ -5573,6 +5595,12 @@ function createDetectionReporter(opts) {
5573
5595
  ]) {
5574
5596
  if (field.truncated) truncated.push(name);
5575
5597
  }
5598
+ for (const [name, field] of [
5599
+ ["category", classCategory],
5600
+ ["action", classAction]
5601
+ ]) {
5602
+ if (field.dropped) truncated.push(name);
5603
+ }
5576
5604
  queue.push({
5577
5605
  rule_id: id.value,
5578
5606
  route: route.value,
@@ -5593,7 +5621,32 @@ function createDetectionReporter(opts) {
5593
5621
  ...query.total > queryKeys.length ? { query_keys_total: query.total } : {},
5594
5622
  // Who asked. Capped, since it is client-supplied text and this is an event with a size bound.
5595
5623
  user_agent: userAgent === null ? null : userAgent.value,
5624
+ // What KIND of match this was: which phase it happened in, what class of thing the rule is for,
5625
+ // and what the rule DECLARES it does about it.
5626
+ //
5627
+ // Three separate facts, and none of them is `enforced` below. A rule declaring `block` while
5628
+ // observing reports exactly that — `action: 'block'`, `enforced: false` — which is what a
5629
+ // dry-run window consists of. Reading either off the other would describe such a window as
5630
+ // protection that never happened, or as rules that do nothing.
5631
+ //
5632
+ // `null` where the rule says nothing, never a guess. A consumer can tell "this rule is for
5633
+ // secret exposure" from "we cannot say what this rule is for", and a filled-in value would take
5634
+ // that distinction away for the sake of a tidier field.
5596
5635
  phase: detection.phase ?? null,
5636
+ // `null` where the rule declared nothing, and also where what it declared could not be carried:
5637
+ // both are "we cannot say what this rule is for", which is a different fact from a class we do
5638
+ // know, and `truncated` distinguishes the second from the first.
5639
+ category: classCategory.value,
5640
+ action: classAction.value,
5641
+ // Which call this detection belongs to, so a consumer can tell one call two rules saw from two
5642
+ // separate calls. Two rules matching one call is the ordinary case — a rule that enforces and a
5643
+ // rule that only observes are meant to match the same thing — so without this, adding these up
5644
+ // reports one call more than once.
5645
+ //
5646
+ // Nothing about the request goes into it: it is only ever compared with other identities, so
5647
+ // deriving it from the address or the path would carry something about whoever made the request
5648
+ // into a place nothing needs it. That is a property of how it is minted, not of its shape.
5649
+ event: eventIdentity(detection.event),
5597
5650
  // The state this detection was handled under, which is the whole point: `false` is a rule that
5598
5651
  // saw traffic it would have stopped.
5599
5652
  enforced: detection.mode === "block",
@@ -6215,8 +6268,9 @@ async function createProtection(options = {}) {
6215
6268
  if (!permitsAnything(entry.plan)) return { plan: entry.reference };
6216
6269
  return { plan: entry.reference, ...captureValues(entry.plan, result.resolver) };
6217
6270
  };
6218
- const decide = (phase, result, block, allow2, ctx = {}) => {
6271
+ const decide = (phase, result, block, allow2, describe = () => ({})) => {
6219
6272
  if (!result || !result.blocked) return allow2();
6273
+ const ctx = describe() ?? {};
6220
6274
  const effectiveMode = ruleMode(result.rule);
6221
6275
  onDetect({
6222
6276
  phase,
@@ -6229,6 +6283,9 @@ async function createProtection(options = {}) {
6229
6283
  method: ctx.method,
6230
6284
  path: ctx.path,
6231
6285
  ip: ctx.ip,
6286
+ // Which call this was. Named here rather than spread from `ctx` for the same reason as everything
6287
+ // else in this payload: a field reaches the wire because someone listed it.
6288
+ event: ctx.event ?? null,
6232
6289
  // Provenance travels with the address. Without it a consumer cannot tell an observed peer from a
6233
6290
  // value read out of a forwarded header, and `null` from "there was no address to establish".
6234
6291
  clientIpSource: ctx.clientIpSource,
@@ -6360,7 +6417,7 @@ async function createProtection(options = {}) {
6360
6417
  result,
6361
6418
  () => blockResponse(result, request),
6362
6419
  () => null,
6363
- requestMeta(shaped, request)
6420
+ () => requestMeta(shaped, request)
6364
6421
  );
6365
6422
  return { blocked, client: shaped?._clientIp };
6366
6423
  };
@@ -6375,7 +6432,12 @@ async function createProtection(options = {}) {
6375
6432
  originalUrl: u.pathname + u.search,
6376
6433
  headers,
6377
6434
  ip: resolved.ip ?? "",
6378
- _clientIp: resolved
6435
+ _clientIp: resolved,
6436
+ // The request itself, so the identity can be asked for LATER. This context is built on every
6437
+ // request whether or not anything matches, so asking here would mint one for every request —
6438
+ // and asking of the context rather than of the request would give the response a different
6439
+ // identity from the request that caused it, reporting one call as two.
6440
+ _eventOf: request
6379
6441
  };
6380
6442
  } catch {
6381
6443
  return void 0;
@@ -6386,7 +6448,10 @@ async function createProtection(options = {}) {
6386
6448
  originalUrl: req.url,
6387
6449
  headers: req.headers || {},
6388
6450
  ip: client?.ip ?? "",
6389
- _clientIp: client ?? { ip: null, source: "unavailable" }
6451
+ _clientIp: client ?? { ip: null, source: "unavailable" },
6452
+ // The request itself, for the same reasons as on the fetch path above: asked for later, and
6453
+ // asked of the request rather than of this context.
6454
+ _eventOf: req
6390
6455
  } : void 0;
6391
6456
  const screenResp = async (response, reqCtx) => {
6392
6457
  const read = await readTextResponse(response, screenCap);
@@ -6633,6 +6698,7 @@ async function createProtection(options = {}) {
6633
6698
  } catch {
6634
6699
  egressPath = typeof url === "string" ? url : null;
6635
6700
  }
6701
+ const egressEvent = mintEvent();
6636
6702
  let block = false;
6637
6703
  for (const { rule, engine: re } of egressRuleSet) {
6638
6704
  let result;
@@ -6650,6 +6716,14 @@ async function createProtection(options = {}) {
6650
6716
  category: rule?.category,
6651
6717
  rule,
6652
6718
  message: result.message,
6719
+ // One identity for this outbound call, shared by every rule that matches it — the egress phase
6720
+ // evaluates all of them rather than stopping at the first, so without this two rules refusing one
6721
+ // call would count as two calls refused.
6722
+ //
6723
+ // Its own identity, not the identity of whatever request the application was serving when it made
6724
+ // the call. An outbound attempt is a thing that happened in its own right, and a call made outside
6725
+ // any request — a job, a timer — has no request to belong to.
6726
+ event: egressEvent,
6653
6727
  method: typeof method === "string" ? method : null,
6654
6728
  path: egressPath,
6655
6729
  capture: evidenceFrom(result)
@@ -6725,7 +6799,7 @@ async function createProtection(options = {}) {
6725
6799
  if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
6726
6800
  next();
6727
6801
  },
6728
- nodeRequestMeta(req, client)
6802
+ () => nodeRequestMeta(req, client)
6729
6803
  );
6730
6804
  };
6731
6805
  },
@@ -6789,7 +6863,7 @@ async function createProtection(options = {}) {
6789
6863
  if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, shaped?._clientIp));
6790
6864
  next();
6791
6865
  },
6792
- nodeRequestMeta(req, shaped?._clientIp)
6866
+ () => nodeRequestMeta(req, shaped?._clientIp)
6793
6867
  );
6794
6868
  }
6795
6869
  }
@@ -7263,11 +7337,37 @@ function defaultOnDetect({ phase, mode, category, rule, message }) {
7263
7337
  const tag = mode === "block" ? "BLOCK" : "DETECT (dry-run)";
7264
7338
  console.warn(`[patchstack] ${tag} phase=${phase ?? "request"} category=${category ?? "?"} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
7265
7339
  }
7340
+ var eventIdentities = /* @__PURE__ */ new WeakMap();
7341
+ function mintEvent() {
7342
+ try {
7343
+ const bytes = globalThis.crypto?.getRandomValues?.(new Uint8Array(16));
7344
+ if (bytes) return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
7345
+ } catch {
7346
+ }
7347
+ try {
7348
+ let hex = Date.now().toString(16);
7349
+ while (hex.length < 32) hex += Math.floor(Math.random() * 4294967295).toString(16).padStart(8, "0");
7350
+ return hex.slice(0, 32);
7351
+ } catch {
7352
+ return null;
7353
+ }
7354
+ }
7355
+ function eventFor(request) {
7356
+ if (request === null || typeof request !== "object") return mintEvent();
7357
+ const existing = eventIdentities.get(request);
7358
+ if (existing !== void 0) return existing;
7359
+ const minted = mintEvent();
7360
+ if (minted !== null) eventIdentities.set(request, minted);
7361
+ return minted;
7362
+ }
7266
7363
  function requestMetaFromContext(reqCtx) {
7267
7364
  if (!reqCtx) return {};
7268
7365
  const client = reqCtx._clientIp ?? { ip: null, source: "unavailable" };
7269
7366
  return {
7270
7367
  method: reqCtx.method ?? null,
7368
+ // The originating request's identity, so a response detection and the request detection for the same
7369
+ // request are one event rather than two. Asked for here, which is inside a detection being raised.
7370
+ event: eventFor(reqCtx._eventOf),
7271
7371
  // Path AND query. The reporter is what drops the query's VALUES, keeping its parameter names, so
7272
7372
  // trimming it here would leave a Fetch or response detection unable to say what was requested.
7273
7373
  path: typeof reqCtx.originalUrl === "string" ? reqCtx.originalUrl : null,
@@ -7291,7 +7391,7 @@ function requestMeta(shaped, request) {
7291
7391
  method = request.method ?? null;
7292
7392
  userAgent = request.headers?.get?.("user-agent") ?? null;
7293
7393
  }
7294
- return { method, path: path7, ip: client.ip, clientIpSource: client.source, userAgent };
7394
+ return { method, path: path7, ip: client.ip, clientIpSource: client.source, userAgent, event: eventFor(request) };
7295
7395
  }
7296
7396
  function nodeRequestMeta(req, client) {
7297
7397
  if (!req) return {};
@@ -7305,7 +7405,8 @@ function nodeRequestMeta(req, client) {
7305
7405
  // verified, and a second derivation could disagree with the one the engine evaluated.
7306
7406
  ip: resolved.ip,
7307
7407
  clientIpSource: resolved.source,
7308
- userAgent: typeof ua === "string" ? ua : Array.isArray(ua) ? ua[0] : null
7408
+ userAgent: typeof ua === "string" ? ua : Array.isArray(ua) ? ua[0] : null,
7409
+ event: eventFor(req)
7309
7410
  };
7310
7411
  }
7311
7412
  // Annotate the CommonJS export names for ESM import in node: