@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.
@@ -168,8 +168,19 @@ export interface CreateProtectionOptions {
168
168
  *
169
169
  * What it sends on EVERY detection: the rule id and its revision, the request path, the query string's
170
170
  * parameter NAMES, the method, the parameters the rule reads, the phase, whether it was enforced, the
171
- * rule-bundle ETag, and a timestamp — plus the values of the parameters the matched rule names, under a
172
- * capture plan derived from that rule.
171
+ * rule-bundle ETag, a timestamp, the rule's category and the action it declares, and which call it
172
+ * belongs to — plus the values of the parameters the matched rule names, under a plan derived from
173
+ * that rule.
174
+ *
175
+ * The category and the declared action say what KIND of rule matched. Both are read from the rule
176
+ * itself and are `null` when it declares neither. Neither is the same as `enforced`:
177
+ * a rule declaring `block` while only observing reports that action with `enforced` false.
178
+ *
179
+ * Which call it belongs to is a token this guard mints and repeats on every rule that matched the same
180
+ * request or outbound call, so one call seen by two rules can be told from two calls. Nothing about
181
+ * the request goes into it — it is drawn from the runtime's randomness, or from the clock and
182
+ * `Math.random` where there is none. A request and its response share one; an outbound call gets its
183
+ * own. It is never a secret and never a boundary: what it has to do is not collide between two calls.
173
184
  *
174
185
  * Two fields depend on the phase. A request or response detection also carries the user agent and the
175
186
  * client address with its provenance. An egress detection carries neither: the call was the
package/dist/protect.d.ts CHANGED
@@ -168,8 +168,19 @@ export interface CreateProtectionOptions {
168
168
  *
169
169
  * What it sends on EVERY detection: the rule id and its revision, the request path, the query string's
170
170
  * parameter NAMES, the method, the parameters the rule reads, the phase, whether it was enforced, the
171
- * rule-bundle ETag, and a timestamp — plus the values of the parameters the matched rule names, under a
172
- * capture plan derived from that rule.
171
+ * rule-bundle ETag, a timestamp, the rule's category and the action it declares, and which call it
172
+ * belongs to — plus the values of the parameters the matched rule names, under a plan derived from
173
+ * that rule.
174
+ *
175
+ * The category and the declared action say what KIND of rule matched. Both are read from the rule
176
+ * itself and are `null` when it declares neither. Neither is the same as `enforced`:
177
+ * a rule declaring `block` while only observing reports that action with `enforced` false.
178
+ *
179
+ * Which call it belongs to is a token this guard mints and repeats on every rule that matched the same
180
+ * request or outbound call, so one call seen by two rules can be told from two calls. Nothing about
181
+ * the request goes into it — it is drawn from the runtime's randomness, or from the clock and
182
+ * `Math.random` where there is none. A request and its response share one; an outbound call gets its
183
+ * own. It is never a secret and never a boundary: what it has to do is not collide between two calls.
173
184
  *
174
185
  * Two fields depend on the phase. A request or response detection also carries the user agent and the
175
186
  * client address with its provenance. An egress detection carries neither: the call was the
@@ -3223,6 +3223,11 @@ var DEFAULT_EGRESS_RULES = [
3223
3223
  title: "Outbound request to an internal / metadata address (SSRF)",
3224
3224
  phase: "egress",
3225
3225
  category: "ssrf",
3226
+ // Declared, not implied. Nothing in the egress path reads it — a match refuses the call, and the
3227
+ // rule's mode decides whether it actually did — but the action is part of how a rule describes
3228
+ // itself to whatever reports on it, and a rule that declares nothing is reported as a rule nobody
3229
+ // can classify.
3230
+ action: "block",
3226
3231
  rule_v2: [{ parameter: "egress.host", match: { type: "internal_host" } }]
3227
3232
  }
3228
3233
  ];
@@ -3589,10 +3594,25 @@ function worthRetrying(status) {
3589
3594
  }
3590
3595
  var ATTEMPT_TIMEOUT_MS = 1e4;
3591
3596
  var STOP_BUDGET_MS = 5e3;
3597
+ var CATEGORY_PATTERN = /^[a-z][a-z0-9-]*$/;
3598
+ var RESERVED_CATEGORY = "unknown";
3599
+ function declaredClass(declared, recognised) {
3600
+ if (typeof declared !== "string" || declared === "") return { value: null, dropped: false };
3601
+ if (!recognised(declared)) return { value: null, dropped: false };
3602
+ if (declared.length > MAX_CLASS_CHARS) return { value: null, dropped: true };
3603
+ return { value: declared, dropped: false };
3604
+ }
3605
+ var recognisedCategory = (value) => value !== RESERVED_CATEGORY && CATEGORY_PATTERN.test(value);
3606
+ var recognisedAction = (value) => ACTIONS.includes(value);
3592
3607
  var MAX_ROUTE_CHARS = 256;
3593
3608
  var MAX_PARAMETERS = 25;
3594
3609
  var MAX_PARAMETER_CHARS = 64;
3595
3610
  var MAX_IDENTIFIER_CHARS = 256;
3611
+ var MAX_CLASS_CHARS = 64;
3612
+ var EVENT_PATTERN = /^[0-9a-f]{32}$/;
3613
+ function eventIdentity(value) {
3614
+ return typeof value === "string" && EVENT_PATTERN.test(value) ? value : null;
3615
+ }
3596
3616
  var MAX_CAPTURED_VALUES = 10;
3597
3617
  var MAX_CAPTURED_VALUE_CHARS = 512;
3598
3618
  var MAX_QUERY_KEYS = 10;
@@ -4052,6 +4072,8 @@ function createDetectionReporter(opts) {
4052
4072
  const id = capText(String(ruleId), MAX_IDENTIFIER_CHARS);
4053
4073
  const revision = capText(revisionOf(detection.rule) ?? "", MAX_IDENTIFIER_CHARS);
4054
4074
  const etag = capText(rulesEtag ?? "", MAX_IDENTIFIER_CHARS);
4075
+ const classCategory = declaredClass(detection.rule?.category, recognisedCategory);
4076
+ const classAction = declaredClass(detection.rule?.action, recognisedAction);
4055
4077
  for (const [name, field] of [
4056
4078
  ["rule_id", id],
4057
4079
  ["rule_revision", revision],
@@ -4059,6 +4081,12 @@ function createDetectionReporter(opts) {
4059
4081
  ]) {
4060
4082
  if (field.truncated) truncated.push(name);
4061
4083
  }
4084
+ for (const [name, field] of [
4085
+ ["category", classCategory],
4086
+ ["action", classAction]
4087
+ ]) {
4088
+ if (field.dropped) truncated.push(name);
4089
+ }
4062
4090
  queue.push({
4063
4091
  rule_id: id.value,
4064
4092
  route: route.value,
@@ -4079,7 +4107,32 @@ function createDetectionReporter(opts) {
4079
4107
  ...query.total > queryKeys.length ? { query_keys_total: query.total } : {},
4080
4108
  // Who asked. Capped, since it is client-supplied text and this is an event with a size bound.
4081
4109
  user_agent: userAgent === null ? null : userAgent.value,
4110
+ // What KIND of match this was: which phase it happened in, what class of thing the rule is for,
4111
+ // and what the rule DECLARES it does about it.
4112
+ //
4113
+ // Three separate facts, and none of them is `enforced` below. A rule declaring `block` while
4114
+ // observing reports exactly that — `action: 'block'`, `enforced: false` — which is what a
4115
+ // dry-run window consists of. Reading either off the other would describe such a window as
4116
+ // protection that never happened, or as rules that do nothing.
4117
+ //
4118
+ // `null` where the rule says nothing, never a guess. A consumer can tell "this rule is for
4119
+ // secret exposure" from "we cannot say what this rule is for", and a filled-in value would take
4120
+ // that distinction away for the sake of a tidier field.
4082
4121
  phase: detection.phase ?? null,
4122
+ // `null` where the rule declared nothing, and also where what it declared could not be carried:
4123
+ // both are "we cannot say what this rule is for", which is a different fact from a class we do
4124
+ // know, and `truncated` distinguishes the second from the first.
4125
+ category: classCategory.value,
4126
+ action: classAction.value,
4127
+ // Which call this detection belongs to, so a consumer can tell one call two rules saw from two
4128
+ // separate calls. Two rules matching one call is the ordinary case — a rule that enforces and a
4129
+ // rule that only observes are meant to match the same thing — so without this, adding these up
4130
+ // reports one call more than once.
4131
+ //
4132
+ // Nothing about the request goes into it: it is only ever compared with other identities, so
4133
+ // deriving it from the address or the path would carry something about whoever made the request
4134
+ // into a place nothing needs it. That is a property of how it is minted, not of its shape.
4135
+ event: eventIdentity(detection.event),
4083
4136
  // The state this detection was handled under, which is the whole point: `false` is a rule that
4084
4137
  // saw traffic it would have stopped.
4085
4138
  enforced: detection.mode === "block",
@@ -4701,8 +4754,9 @@ async function createProtection(options = {}) {
4701
4754
  if (!permitsAnything(entry.plan)) return { plan: entry.reference };
4702
4755
  return { plan: entry.reference, ...captureValues(entry.plan, result.resolver) };
4703
4756
  };
4704
- const decide = (phase, result, block, allow2, ctx = {}) => {
4757
+ const decide = (phase, result, block, allow2, describe = () => ({})) => {
4705
4758
  if (!result || !result.blocked) return allow2();
4759
+ const ctx = describe() ?? {};
4706
4760
  const effectiveMode = ruleMode(result.rule);
4707
4761
  onDetect({
4708
4762
  phase,
@@ -4715,6 +4769,9 @@ async function createProtection(options = {}) {
4715
4769
  method: ctx.method,
4716
4770
  path: ctx.path,
4717
4771
  ip: ctx.ip,
4772
+ // Which call this was. Named here rather than spread from `ctx` for the same reason as everything
4773
+ // else in this payload: a field reaches the wire because someone listed it.
4774
+ event: ctx.event ?? null,
4718
4775
  // Provenance travels with the address. Without it a consumer cannot tell an observed peer from a
4719
4776
  // value read out of a forwarded header, and `null` from "there was no address to establish".
4720
4777
  clientIpSource: ctx.clientIpSource,
@@ -4846,7 +4903,7 @@ async function createProtection(options = {}) {
4846
4903
  result,
4847
4904
  () => blockResponse(result, request),
4848
4905
  () => null,
4849
- requestMeta(shaped, request)
4906
+ () => requestMeta(shaped, request)
4850
4907
  );
4851
4908
  return { blocked, client: shaped?._clientIp };
4852
4909
  };
@@ -4861,7 +4918,12 @@ async function createProtection(options = {}) {
4861
4918
  originalUrl: u.pathname + u.search,
4862
4919
  headers,
4863
4920
  ip: resolved.ip ?? "",
4864
- _clientIp: resolved
4921
+ _clientIp: resolved,
4922
+ // The request itself, so the identity can be asked for LATER. This context is built on every
4923
+ // request whether or not anything matches, so asking here would mint one for every request —
4924
+ // and asking of the context rather than of the request would give the response a different
4925
+ // identity from the request that caused it, reporting one call as two.
4926
+ _eventOf: request
4865
4927
  };
4866
4928
  } catch {
4867
4929
  return void 0;
@@ -4872,7 +4934,10 @@ async function createProtection(options = {}) {
4872
4934
  originalUrl: req.url,
4873
4935
  headers: req.headers || {},
4874
4936
  ip: client?.ip ?? "",
4875
- _clientIp: client ?? { ip: null, source: "unavailable" }
4937
+ _clientIp: client ?? { ip: null, source: "unavailable" },
4938
+ // The request itself, for the same reasons as on the fetch path above: asked for later, and
4939
+ // asked of the request rather than of this context.
4940
+ _eventOf: req
4876
4941
  } : void 0;
4877
4942
  const screenResp = async (response, reqCtx) => {
4878
4943
  const read = await readTextResponse(response, screenCap);
@@ -5119,6 +5184,7 @@ async function createProtection(options = {}) {
5119
5184
  } catch {
5120
5185
  egressPath = typeof url === "string" ? url : null;
5121
5186
  }
5187
+ const egressEvent = mintEvent();
5122
5188
  let block = false;
5123
5189
  for (const { rule, engine: re } of egressRuleSet) {
5124
5190
  let result;
@@ -5136,6 +5202,14 @@ async function createProtection(options = {}) {
5136
5202
  category: rule?.category,
5137
5203
  rule,
5138
5204
  message: result.message,
5205
+ // One identity for this outbound call, shared by every rule that matches it — the egress phase
5206
+ // evaluates all of them rather than stopping at the first, so without this two rules refusing one
5207
+ // call would count as two calls refused.
5208
+ //
5209
+ // Its own identity, not the identity of whatever request the application was serving when it made
5210
+ // the call. An outbound attempt is a thing that happened in its own right, and a call made outside
5211
+ // any request — a job, a timer — has no request to belong to.
5212
+ event: egressEvent,
5139
5213
  method: typeof method === "string" ? method : null,
5140
5214
  path: egressPath,
5141
5215
  capture: evidenceFrom(result)
@@ -5211,7 +5285,7 @@ async function createProtection(options = {}) {
5211
5285
  if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
5212
5286
  next();
5213
5287
  },
5214
- nodeRequestMeta(req, client)
5288
+ () => nodeRequestMeta(req, client)
5215
5289
  );
5216
5290
  };
5217
5291
  },
@@ -5275,7 +5349,7 @@ async function createProtection(options = {}) {
5275
5349
  if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, shaped?._clientIp));
5276
5350
  next();
5277
5351
  },
5278
- nodeRequestMeta(req, shaped?._clientIp)
5352
+ () => nodeRequestMeta(req, shaped?._clientIp)
5279
5353
  );
5280
5354
  }
5281
5355
  }
@@ -5749,11 +5823,37 @@ function defaultOnDetect({ phase, mode, category, rule, message }) {
5749
5823
  const tag = mode === "block" ? "BLOCK" : "DETECT (dry-run)";
5750
5824
  console.warn(`[patchstack] ${tag} phase=${phase ?? "request"} category=${category ?? "?"} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
5751
5825
  }
5826
+ var eventIdentities = /* @__PURE__ */ new WeakMap();
5827
+ function mintEvent() {
5828
+ try {
5829
+ const bytes = globalThis.crypto?.getRandomValues?.(new Uint8Array(16));
5830
+ if (bytes) return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
5831
+ } catch {
5832
+ }
5833
+ try {
5834
+ let hex = Date.now().toString(16);
5835
+ while (hex.length < 32) hex += Math.floor(Math.random() * 4294967295).toString(16).padStart(8, "0");
5836
+ return hex.slice(0, 32);
5837
+ } catch {
5838
+ return null;
5839
+ }
5840
+ }
5841
+ function eventFor(request) {
5842
+ if (request === null || typeof request !== "object") return mintEvent();
5843
+ const existing = eventIdentities.get(request);
5844
+ if (existing !== void 0) return existing;
5845
+ const minted = mintEvent();
5846
+ if (minted !== null) eventIdentities.set(request, minted);
5847
+ return minted;
5848
+ }
5752
5849
  function requestMetaFromContext(reqCtx) {
5753
5850
  if (!reqCtx) return {};
5754
5851
  const client = reqCtx._clientIp ?? { ip: null, source: "unavailable" };
5755
5852
  return {
5756
5853
  method: reqCtx.method ?? null,
5854
+ // The originating request's identity, so a response detection and the request detection for the same
5855
+ // request are one event rather than two. Asked for here, which is inside a detection being raised.
5856
+ event: eventFor(reqCtx._eventOf),
5757
5857
  // Path AND query. The reporter is what drops the query's VALUES, keeping its parameter names, so
5758
5858
  // trimming it here would leave a Fetch or response detection unable to say what was requested.
5759
5859
  path: typeof reqCtx.originalUrl === "string" ? reqCtx.originalUrl : null,
@@ -5777,7 +5877,7 @@ function requestMeta(shaped, request) {
5777
5877
  method = request.method ?? null;
5778
5878
  userAgent = request.headers?.get?.("user-agent") ?? null;
5779
5879
  }
5780
- return { method, path, ip: client.ip, clientIpSource: client.source, userAgent };
5880
+ return { method, path, ip: client.ip, clientIpSource: client.source, userAgent, event: eventFor(request) };
5781
5881
  }
5782
5882
  function nodeRequestMeta(req, client) {
5783
5883
  if (!req) return {};
@@ -5791,7 +5891,8 @@ function nodeRequestMeta(req, client) {
5791
5891
  // verified, and a second derivation could disagree with the one the engine evaluated.
5792
5892
  ip: resolved.ip,
5793
5893
  clientIpSource: resolved.source,
5794
- userAgent: typeof ua === "string" ? ua : Array.isArray(ua) ? ua[0] : null
5894
+ userAgent: typeof ua === "string" ? ua : Array.isArray(ua) ? ua[0] : null,
5895
+ event: eventFor(req)
5795
5896
  };
5796
5897
  }
5797
5898
  export {