@patchstack/connect 0.3.33 → 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
@@ -3788,18 +3788,10 @@ function conditionsProblem(conditions, depth = 0) {
3788
3788
  return null;
3789
3789
  }
3790
3790
 
3791
- // src/protect/capture-plan.js
3792
- var NEVER_CAPTURABLE = /* @__PURE__ */ new Set(["response"]);
3793
- var CAPTURE_LIMITS = Object.freeze({
3794
- /** Named and prefix values in one event, together. Raw has its own allowance. */
3795
- capturedValues: 10,
3796
- /** Characters of any single captured value. */
3797
- valueChars: 512,
3798
- /** Resolved values one prefix permission may contribute. */
3799
- prefixValues: 5
3800
- });
3801
- function parametersOf(rule) {
3791
+ // src/protect/rule-parameters.js
3792
+ function readRuleParameters(rule) {
3802
3793
  const out = /* @__PURE__ */ new Set();
3794
+ let complete = true;
3803
3795
  const add = (parameter) => {
3804
3796
  if (typeof parameter === "string" && parameter !== "rules") out.add(parameter);
3805
3797
  };
@@ -3811,7 +3803,11 @@ function parametersOf(rule) {
3811
3803
  add(parameter);
3812
3804
  };
3813
3805
  const walk2 = (conditions, depth) => {
3814
- if (!Array.isArray(conditions) || depth > 20) return;
3806
+ if (!Array.isArray(conditions)) return;
3807
+ if (depth > LIMITS.maxNestingDepth) {
3808
+ complete = false;
3809
+ return;
3810
+ }
3815
3811
  for (const condition of conditions) {
3816
3812
  if (!condition || typeof condition !== "object") continue;
3817
3813
  collect(condition.parameter);
@@ -3819,8 +3815,22 @@ function parametersOf(rule) {
3819
3815
  }
3820
3816
  };
3821
3817
  walk2(rule?.rule_v2, 0);
3822
- return [...out];
3818
+ return { parameters: [...out], complete };
3819
+ }
3820
+ function ruleParameters(rule) {
3821
+ return readRuleParameters(rule).parameters;
3823
3822
  }
3823
+
3824
+ // src/protect/capture-plan.js
3825
+ var NEVER_CAPTURABLE = /* @__PURE__ */ new Set(["response"]);
3826
+ var CAPTURE_LIMITS = Object.freeze({
3827
+ /** Named and prefix values in one event, together. Raw has its own allowance. */
3828
+ capturedValues: 10,
3829
+ /** Characters of any single captured value. */
3830
+ valueChars: 512,
3831
+ /** Resolved values one prefix permission may contribute. */
3832
+ prefixValues: 5
3833
+ });
3824
3834
  function rawOptIn(rule) {
3825
3835
  if (!rule || typeof rule !== "object" || !Object.hasOwn(rule, "capture")) return null;
3826
3836
  const capture = rule.capture;
@@ -3838,7 +3848,7 @@ function derivePlan(rule) {
3838
3848
  const named = /* @__PURE__ */ new Set();
3839
3849
  const prefixes = /* @__PURE__ */ new Set();
3840
3850
  if (enforceableRuleProblem(rule) !== null) return NOTHING;
3841
- const parameters = parametersOf(rule).filter((parameter) => parameterProblem(parameter) === null);
3851
+ const parameters = ruleParameters(rule).filter((parameter) => parameterProblem(parameter) === null);
3842
3852
  for (const parameter of parameters) {
3843
3853
  const dot = parameter.indexOf(".");
3844
3854
  if (dot === -1) continue;
@@ -4550,9 +4560,23 @@ var DEFAULT_RESPONSE_RULES = [
4550
4560
  title: "Private key in response body",
4551
4561
  phase: "response",
4552
4562
  category: "secret-exposure",
4553
- action: "redact",
4563
+ // Withheld: the key material sits after the marker and this pattern does not delimit it.
4564
+ action: "block",
4554
4565
  prefilter: ["PRIVATE KEY"],
4555
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/" } }]
4566
+ // Matches a PEM BEGIN line whose label contains `PRIVATE KEY`, with up to 32 further label
4567
+ // characters — uppercase letters, digits, spaces and hyphens — on either side of it. That covers
4568
+ // the enumerated types (`RSA`, `EC`, `OPENSSH`, `DSA`, `ENCRYPTED`), a label carrying words after
4569
+ // `PRIVATE KEY` (`PGP PRIVATE KEY BLOCK`), a hyphenated or otherwise unlisted type, and a bare
4570
+ // `-----BEGIN PRIVATE KEY-----`. `PUBLIC KEY` and `CERTIFICATE` do not match, and neither does
4571
+ // prose that mentions a private key without a BEGIN line.
4572
+ //
4573
+ // No footer required: a truncated response or an absent END marker does not make the material
4574
+ // above it less of a key. Two bounded character classes rather than a repeated group — a
4575
+ // quantifier inside a quantified group is the shape the engine refuses as a backtracking risk, and
4576
+ // a refused pattern is a rule that never fires.
4577
+ //
4578
+ // A lowercase label, or one longer than 32 characters on either side, is not matched.
4579
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/-----BEGIN [A-Z0-9 -]{0,32}PRIVATE KEY[A-Z0-9 -]{0,32}-----/" } }]
4556
4580
  },
4557
4581
  {
4558
4582
  id: "resp-aws-access-key",
@@ -4629,26 +4653,65 @@ var DEFAULT_RESPONSE_RULES = [
4629
4653
  title: "Database connection string with credentials in response body",
4630
4654
  phase: "response",
4631
4655
  category: "secret-exposure",
4632
- action: "redact",
4656
+ // Withheld. The credentials are only the first half of the disclosure — the host, port, database
4657
+ // name and query name the system they open — and a URI's own grammar admits commas, parentheses
4658
+ // and semicolons, so no end-of-URI character class delimits it in free text without either
4659
+ // stopping inside a real URI or consuming the punctuation around it. The pattern therefore
4660
+ // identifies the URI and the response is withheld rather than partly rewritten.
4661
+ action: "block",
4633
4662
  prefilter: ["mongodb", "postgres", "mysql", "redis", "amqp"],
4634
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[^\\s:@\\/]+:[^\\s:@\\/]+@/i" } }]
4663
+ // A scheme this application connects with, a user, a password and an `@`. Credentials are
4664
+ // required, so a URL without them is not matched.
4665
+ //
4666
+ // The username is the userinfo grammar without `:`; the password is the same grammar with it. Every
4667
+ // other character either class admits may appear raw in a real credential, so a narrower one turns
4668
+ // a live credential into a rule that says nothing — `postgres://user:p;ss@host` is an ordinary DSN.
4669
+ //
4670
+ // The first raw colon separates username from password. Excluding it from the username makes the
4671
+ // separator unambiguous and keeps matching linear; the password continues to admit raw colons. A
4672
+ // username that contains a colon carries it as `%3A`.
4673
+ //
4674
+ // Both classes admitting `:` would leave every colon available as the separator, so a candidate
4675
+ // run with no `@` is re-split at every position. The screening cap does not bound that: a rule may
4676
+ // raise it with `max_bytes` or remove it with `bypass_limit`.
4677
+ //
4678
+ // What terminates a candidate is everything the grammar excludes: whitespace, `/`, `?`, `#`, `@`,
4679
+ // quotes, backslashes, angle and square brackets and braces. That is what keeps the run inside one
4680
+ // value. Expressed as "anything but `:`, `@`, `/` and whitespace" it crosses structure instead: in
4681
+ // `{"docs":"postgres://db.internal","contact":"user@example.com"}` it consumes the closing quote,
4682
+ // the comma and the next key, reaching the `:` and `@` of an unrelated property and withholding a
4683
+ // response that discloses nothing.
4684
+ //
4685
+ // The consequence is deliberate: a run of punctuation-joined text that parses as a credential URI
4686
+ // is treated as one. `postgres://db.internal;contact:admin@example.com` has username
4687
+ // `db.internal;contact`, password `admin` and host `example.com` — indistinguishable from a leak,
4688
+ // so it is withheld. Ordinary prose separates with whitespace, which terminates the candidate.
4689
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[A-Za-z0-9._~%!$&'()*+,;=-]+:[A-Za-z0-9._~%!$&'()*+,;=:-]+@/i" } }]
4635
4690
  },
4636
4691
  {
4637
4692
  id: "resp-stack-trace",
4638
4693
  title: "Node stack trace leaking in response body",
4639
4694
  phase: "response",
4640
4695
  category: "info-exposure",
4641
- action: "redact",
4696
+ // Withheld. A frame is recognised by its shape and the trace has no end the pattern can rely on,
4697
+ // so masking the frames it happens to match leaves the message, the remaining frames and every
4698
+ // path and line number in them.
4699
+ action: "block",
4642
4700
  // No prefilter: a Node stack frame has no single distinctive literal (` at ` is too common to
4643
4701
  // gate on). The pattern is linearly bounded per line, so it runs on every screened body.
4644
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\n\\s+at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
4702
+ //
4703
+ // Accepts a real newline and a JSON-escaped one. Most traces reach a client inside a JSON error
4704
+ // body, where the newline is the two characters `\` and `n`.
4705
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/(?:\\n|\\\\n)\\s*at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
4645
4706
  },
4646
4707
  {
4647
4708
  id: "resp-sql-error",
4648
4709
  title: "SQL / ORM error disclosure in response body",
4649
4710
  phase: "response",
4650
4711
  category: "info-exposure",
4651
- action: "redact",
4712
+ // Withheld. The signature is the start of the disclosure: masking `SQLSTATE[23000]` and serving
4713
+ // the constraint name, the column and the offending value discloses the schema anyway.
4714
+ action: "block",
4652
4715
  prefilter: ["SQLSTATE", "Sequelize", "ER_", "ORA-", "PG::", "SQLITE_ERROR", "SQL syntax"],
4653
4716
  rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/(SQLSTATE\\[[0-9A-Z]+\\]|SequelizeDatabaseError|ER_[A-Z_]+|ORA-\\d{5}|PG::[A-Za-z]+Error|SQLITE_ERROR|You have an error in your SQL syntax)/i" } }]
4654
4717
  },
@@ -4657,7 +4720,9 @@ var DEFAULT_RESPONSE_RULES = [
4657
4720
  title: "Backend exception / stack trace disclosure in response body",
4658
4721
  phase: "response",
4659
4722
  category: "info-exposure",
4660
- action: "redact",
4723
+ // Withheld, for the same reason as the trace above: the marker opens the dump and the file names,
4724
+ // line numbers and frames after it are the disclosure.
4725
+ action: "block",
4661
4726
  // Multi-language exception/traceback signatures a normal API response never carries:
4662
4727
  // Python traceback, Java "Exception in thread", .NET System.*Exception, JVM stack frames,
4663
4728
  // Go goroutine dumps. (Node `at fn (file:line:col)` frames are handled by resp-stack-trace.)
@@ -4671,6 +4736,11 @@ var DEFAULT_EGRESS_RULES = [
4671
4736
  title: "Outbound request to an internal / metadata address (SSRF)",
4672
4737
  phase: "egress",
4673
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",
4674
4744
  rule_v2: [{ parameter: "egress.host", match: { type: "internal_host" } }]
4675
4745
  }
4676
4746
  ];
@@ -5038,10 +5108,25 @@ function worthRetrying(status) {
5038
5108
  }
5039
5109
  var ATTEMPT_TIMEOUT_MS = 1e4;
5040
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);
5041
5121
  var MAX_ROUTE_CHARS = 256;
5042
5122
  var MAX_PARAMETERS = 25;
5043
5123
  var MAX_PARAMETER_CHARS = 64;
5044
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
+ }
5045
5130
  var MAX_CAPTURED_VALUES = 10;
5046
5131
  var MAX_CAPTURED_VALUE_CHARS = 512;
5047
5132
  var MAX_QUERY_KEYS = 10;
@@ -5186,21 +5271,6 @@ function unattended(timer) {
5186
5271
  if (timer && typeof timer.unref === "function") timer.unref();
5187
5272
  return timer;
5188
5273
  }
5189
- function ruleParameters(rule) {
5190
- const out = /* @__PURE__ */ new Set();
5191
- const walk2 = (conditions) => {
5192
- if (!Array.isArray(conditions)) return;
5193
- for (const condition of conditions) {
5194
- if (!condition || typeof condition !== "object") continue;
5195
- if (typeof condition.parameter === "string" && condition.parameter !== "rules") {
5196
- out.add(condition.parameter);
5197
- }
5198
- if (Array.isArray(condition.rules)) walk2(condition.rules);
5199
- }
5200
- };
5201
- walk2(rule?.rule_v2);
5202
- return [...out];
5203
- }
5204
5274
  function revisionOf(rule) {
5205
5275
  const revision = rule?.source_revision;
5206
5276
  if (typeof revision === "string" && revision !== "") return revision;
@@ -5501,7 +5571,7 @@ function createDetectionReporter(opts) {
5501
5571
  const capture = boundCapture(detection.capture);
5502
5572
  const rawRoute = routeOf(detection.path);
5503
5573
  const route = typeof rawRoute === "string" ? capText(rawRoute, MAX_ROUTE_CHARS) : { value: rawRoute, truncated: false };
5504
- const allParameters = ruleParameters(detection.rule);
5574
+ const { parameters: allParameters, complete: sawEveryParameter } = readRuleParameters(detection.rule);
5505
5575
  const parameters = allParameters.slice(0, MAX_PARAMETERS).map((name) => capText(name, MAX_PARAMETER_CHARS));
5506
5576
  const truncated = [];
5507
5577
  if (route.truncated) truncated.push("route");
@@ -5510,12 +5580,14 @@ function createDetectionReporter(opts) {
5510
5580
  if (query.total > queryKeys.length || queryKeys.some((entry) => entry.truncated)) {
5511
5581
  truncated.push("query_keys");
5512
5582
  }
5513
- if (allParameters.length > MAX_PARAMETERS || parameters.some((entry) => entry.truncated)) {
5583
+ if (!sawEveryParameter || allParameters.length > MAX_PARAMETERS || parameters.some((entry) => entry.truncated)) {
5514
5584
  truncated.push("parameters");
5515
5585
  }
5516
5586
  const id = capText(String(ruleId), MAX_IDENTIFIER_CHARS);
5517
5587
  const revision = capText(revisionOf(detection.rule) ?? "", MAX_IDENTIFIER_CHARS);
5518
5588
  const etag = capText(rulesEtag ?? "", MAX_IDENTIFIER_CHARS);
5589
+ const classCategory = declaredClass(detection.rule?.category, recognisedCategory);
5590
+ const classAction = declaredClass(detection.rule?.action, recognisedAction);
5519
5591
  for (const [name, field] of [
5520
5592
  ["rule_id", id],
5521
5593
  ["rule_revision", revision],
@@ -5523,6 +5595,12 @@ function createDetectionReporter(opts) {
5523
5595
  ]) {
5524
5596
  if (field.truncated) truncated.push(name);
5525
5597
  }
5598
+ for (const [name, field] of [
5599
+ ["category", classCategory],
5600
+ ["action", classAction]
5601
+ ]) {
5602
+ if (field.dropped) truncated.push(name);
5603
+ }
5526
5604
  queue.push({
5527
5605
  rule_id: id.value,
5528
5606
  route: route.value,
@@ -5531,7 +5609,10 @@ function createDetectionReporter(opts) {
5531
5609
  ...truncated.length > 0 ? { truncated } : {},
5532
5610
  // Only when parameters were actually left out. Reporting a total because some OTHER field was
5533
5611
  // shortened states that parameters were omitted when none were.
5534
- ...truncated.includes("parameters") ? { parameters_total: allParameters.length } : {},
5612
+ // Only when the walk saw the whole rule. Shortened by the cap, this is the true total; cut short
5613
+ // by the nesting bound, it is the count of what was seen, and sending that as the total states a
5614
+ // size nobody established. The mark travels either way, so a short list is always marked as one.
5615
+ ...truncated.includes("parameters") && sawEveryParameter ? { parameters_total: allParameters.length } : {},
5535
5616
  method: method === null ? null : method.value,
5536
5617
  // The rest of the URL, as names only. `route` is the path; together they say what was requested
5537
5618
  // without saying what was in it.
@@ -5540,7 +5621,32 @@ function createDetectionReporter(opts) {
5540
5621
  ...query.total > queryKeys.length ? { query_keys_total: query.total } : {},
5541
5622
  // Who asked. Capped, since it is client-supplied text and this is an event with a size bound.
5542
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.
5543
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),
5544
5650
  // The state this detection was handled under, which is the whole point: `false` is a rule that
5545
5651
  // saw traffic it would have stopped.
5546
5652
  enforced: detection.mode === "block",
@@ -5711,6 +5817,19 @@ function reportingState(input) {
5711
5817
  return { state: "on", reports: true };
5712
5818
  }
5713
5819
 
5820
+ // src/protect/response-hardening.js
5821
+ var BODY_INDEPENDENT = Object.freeze(["response.status", "response.headers"]);
5822
+ var BODY_INDEPENDENT_PREFIX = "response.header.";
5823
+ var HEADER_ACTIONS = Object.freeze(["set-header", "remove-header", "harden-cookie"]);
5824
+ function hardensWithoutBody(rule) {
5825
+ if (!rule || !HEADER_ACTIONS.includes(rule.action)) return false;
5826
+ const { parameters, complete } = readRuleParameters(rule);
5827
+ if (!complete) return false;
5828
+ return parameters.every(
5829
+ (parameter) => BODY_INDEPENDENT.includes(parameter) || parameter.startsWith(BODY_INDEPENDENT_PREFIX)
5830
+ );
5831
+ }
5832
+
5714
5833
  // src/protect/firewall-log.js
5715
5834
  var DEFAULT_API_BASE = "https://api.patchstack.com";
5716
5835
  var STOP_BUDGET_MS2 = 5e3;
@@ -6094,7 +6213,7 @@ async function createProtection(options = {}) {
6094
6213
  let screenCap;
6095
6214
  let engine;
6096
6215
  let responseRuleSet;
6097
- let egressEngine;
6216
+ let egressRuleSet;
6098
6217
  const applyBundle = (delivered) => {
6099
6218
  const incoming = delivered.firewall ?? [];
6100
6219
  requestRules = byPhase(incoming, "request");
@@ -6121,7 +6240,10 @@ async function createProtection(options = {}) {
6121
6240
  // the common case — cutting CPU/latency and shrinking the regex/ReDoS surface. Case-insensitive.
6122
6241
  prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
6123
6242
  }));
6124
- egressEngine = new RuleEngine({ firewall: egressRules, onError });
6243
+ egressRuleSet = egressRules.map((rule) => ({
6244
+ rule,
6245
+ engine: new RuleEngine({ firewall: [rule], onError })
6246
+ }));
6125
6247
  };
6126
6248
  applyBundle(bundle);
6127
6249
  const skipCounts = /* @__PURE__ */ Object.create(null);
@@ -6146,8 +6268,9 @@ async function createProtection(options = {}) {
6146
6268
  if (!permitsAnything(entry.plan)) return { plan: entry.reference };
6147
6269
  return { plan: entry.reference, ...captureValues(entry.plan, result.resolver) };
6148
6270
  };
6149
- const decide = (phase, result, block, allow2, ctx = {}) => {
6271
+ const decide = (phase, result, block, allow2, describe = () => ({})) => {
6150
6272
  if (!result || !result.blocked) return allow2();
6273
+ const ctx = describe() ?? {};
6151
6274
  const effectiveMode = ruleMode(result.rule);
6152
6275
  onDetect({
6153
6276
  phase,
@@ -6160,6 +6283,9 @@ async function createProtection(options = {}) {
6160
6283
  method: ctx.method,
6161
6284
  path: ctx.path,
6162
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,
6163
6289
  // Provenance travels with the address. Without it a consumer cannot tell an observed peer from a
6164
6290
  // value read out of a forwarded header, and `null` from "there was no address to establish".
6165
6291
  clientIpSource: ctx.clientIpSource,
@@ -6169,12 +6295,13 @@ async function createProtection(options = {}) {
6169
6295
  });
6170
6296
  return effectiveMode === "block" ? block() : allow2();
6171
6297
  };
6172
- const screenText = (text, meta, reqCtx) => {
6298
+ const screenText = (text, meta, reqCtx, only) => {
6173
6299
  let blockRule = null;
6174
6300
  const redactions = [];
6175
6301
  const headerMutations = [];
6176
6302
  let lowerText = null;
6177
6303
  for (const { rule, engine: re, redactors, prefilter, mutatedSpan } of responseRuleSet) {
6304
+ if (only && !only(rule)) continue;
6178
6305
  if (prefilter) {
6179
6306
  if (lowerText === null) lowerText = text.toLowerCase();
6180
6307
  if (!prefilter.some((p) => lowerText.includes(p))) continue;
@@ -6290,7 +6417,7 @@ async function createProtection(options = {}) {
6290
6417
  result,
6291
6418
  () => blockResponse(result, request),
6292
6419
  () => null,
6293
- requestMeta(shaped, request)
6420
+ () => requestMeta(shaped, request)
6294
6421
  );
6295
6422
  return { blocked, client: shaped?._clientIp };
6296
6423
  };
@@ -6305,7 +6432,12 @@ async function createProtection(options = {}) {
6305
6432
  originalUrl: u.pathname + u.search,
6306
6433
  headers,
6307
6434
  ip: resolved.ip ?? "",
6308
- _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
6309
6441
  };
6310
6442
  } catch {
6311
6443
  return void 0;
@@ -6316,13 +6448,17 @@ async function createProtection(options = {}) {
6316
6448
  originalUrl: req.url,
6317
6449
  headers: req.headers || {},
6318
6450
  ip: client?.ip ?? "",
6319
- _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
6320
6455
  } : void 0;
6321
6456
  const screenResp = async (response, reqCtx) => {
6322
6457
  const read = await readTextResponse(response, screenCap);
6323
6458
  if (read.skip) {
6324
6459
  if (read.skip !== "not-a-response") recordSkip("response", read.skip, { status: response?.status });
6325
- return response;
6460
+ if (read.skip === "not-a-response") return response;
6461
+ return hardenHeadersOnly(response, reqCtx);
6326
6462
  }
6327
6463
  const text = read.text;
6328
6464
  const r = screenText(text, { status: response.status, headers: headerObject(response.headers) }, reqCtx);
@@ -6330,6 +6466,74 @@ async function createProtection(options = {}) {
6330
6466
  if (r.verdict === "redact") return rebuildResponse(response, r.body, r.headers);
6331
6467
  return response;
6332
6468
  };
6469
+ const headerValueChanged = (was, value) => {
6470
+ const had = was !== void 0 && was !== null;
6471
+ if (value === null || value === void 0) return had;
6472
+ if (!had) return true;
6473
+ if (Array.isArray(value) || Array.isArray(was)) {
6474
+ const a = Array.isArray(was) ? was : [was];
6475
+ const b = Array.isArray(value) ? value : [value];
6476
+ return a.length !== b.length || b.some((item, i) => String(item) !== String(a[i]));
6477
+ }
6478
+ return String(value) !== String(was);
6479
+ };
6480
+ const headersChanged = (before, after) => Object.entries(after).some(([name, value]) => headerValueChanged(before[name], value));
6481
+ const writeHeadHeaderIndex = (args) => {
6482
+ const at = typeof args[1] === "string" ? 2 : 1;
6483
+ const value = args[at];
6484
+ return value !== null && typeof value === "object" ? at : -1;
6485
+ };
6486
+ const writeHeadEntries = (headers) => {
6487
+ if (!Array.isArray(headers)) return Object.entries(headers);
6488
+ if (headers.length && headers.every((entry) => Array.isArray(entry) && entry.length === 2)) {
6489
+ return headers.map(([name, value]) => [String(name), value]);
6490
+ }
6491
+ const entries = [];
6492
+ for (let i = 0; i + 1 < headers.length; i += 2) entries.push([String(headers[i]), headers[i + 1]]);
6493
+ return entries;
6494
+ };
6495
+ const writeHeadHeaderObject = (headers) => {
6496
+ const out = {};
6497
+ for (const [name, value] of writeHeadEntries(headers)) {
6498
+ const key = name.toLowerCase();
6499
+ out[key] = Object.hasOwn(out, key) ? [].concat(out[key], value) : value;
6500
+ }
6501
+ return out;
6502
+ };
6503
+ const rewriteWriteHeadHeaders = (headers, changed) => {
6504
+ const replaced = /* @__PURE__ */ new Set();
6505
+ const entries = [];
6506
+ for (const [name, value] of writeHeadEntries(headers)) {
6507
+ const key = name.toLowerCase();
6508
+ if (!changed.has(key)) {
6509
+ entries.push([name, value]);
6510
+ continue;
6511
+ }
6512
+ if (replaced.has(key)) continue;
6513
+ replaced.add(key);
6514
+ const replacement = changed.get(key);
6515
+ if (replacement === null || replacement === void 0) continue;
6516
+ entries.push([name, replacement]);
6517
+ }
6518
+ if (!Array.isArray(headers)) return Object.fromEntries(entries);
6519
+ const paired = headers.length > 0 && headers.every((entry) => Array.isArray(entry) && entry.length === 2);
6520
+ const expanded = entries.flatMap(
6521
+ ([name, value]) => Array.isArray(value) ? value.map((item) => [name, item]) : [[name, value]]
6522
+ );
6523
+ return paired ? expanded : expanded.flat();
6524
+ };
6525
+ const hardenHeadersOnly = (response, reqCtx) => {
6526
+ try {
6527
+ const meta = { status: response.status, headers: headerObject(response.headers) };
6528
+ const r = screenText("", meta, reqCtx, hardensWithoutBody);
6529
+ if (r.verdict !== "redact" || !r.headers) return response;
6530
+ if (!headersChanged(meta.headers, r.headers)) return response;
6531
+ return rebuildResponse(response, response.body, r.headers);
6532
+ } catch (err) {
6533
+ notify(onError, err, "onError");
6534
+ return response;
6535
+ }
6536
+ };
6333
6537
  const wrapNodeResponse = (res, reqCtx) => {
6334
6538
  const origWrite = res.write.bind(res);
6335
6539
  const origEnd = res.end.bind(res);
@@ -6337,6 +6541,51 @@ async function createProtection(options = {}) {
6337
6541
  let size = 0;
6338
6542
  let overflow = false;
6339
6543
  const MAX = screenCap;
6544
+ let hardened = false;
6545
+ let answeredWithoutBody = false;
6546
+ const hardenBeforeFlush = (effective) => {
6547
+ if (hardened) return null;
6548
+ hardened = true;
6549
+ try {
6550
+ if (res.headersSent || typeof res.setHeader !== "function") return null;
6551
+ const set = typeof res.getHeaders === "function" ? res.getHeaders() : {};
6552
+ const headers = effective && effective.headers ? { ...set, ...effective.headers } : set;
6553
+ const status = effective && effective.status !== void 0 ? effective.status : res.statusCode;
6554
+ const r = screenText("", { status, headers }, reqCtx, hardensWithoutBody);
6555
+ answeredWithoutBody = true;
6556
+ if (r.verdict !== "redact" || !r.headers) return null;
6557
+ const changed = /* @__PURE__ */ new Map();
6558
+ for (const [name, value] of Object.entries(r.headers)) {
6559
+ if (!headerValueChanged(headers[name], value)) continue;
6560
+ changed.set(name.toLowerCase(), value);
6561
+ try {
6562
+ if (value === null || value === void 0) res.removeHeader?.(name);
6563
+ else res.setHeader(name, value);
6564
+ } catch {
6565
+ }
6566
+ }
6567
+ return changed.size ? changed : null;
6568
+ } catch (err) {
6569
+ notify(onError, err, "onError");
6570
+ return null;
6571
+ }
6572
+ };
6573
+ const stillToAnswer = (rule) => !hardensWithoutBody(rule);
6574
+ if (typeof res.writeHead === "function") {
6575
+ const origWriteHead = res.writeHead.bind(res);
6576
+ res.writeHead = function(...args) {
6577
+ const at = writeHeadHeaderIndex(args);
6578
+ const given = at === -1 ? null : args[at];
6579
+ const changed = hardenBeforeFlush({
6580
+ status: typeof args[0] === "number" ? args[0] : void 0,
6581
+ headers: given ? writeHeadHeaderObject(given) : null
6582
+ });
6583
+ if (!changed || !given) return origWriteHead(...args);
6584
+ const rewritten = [...args];
6585
+ rewritten[at] = rewriteWriteHeadHeaders(given, changed);
6586
+ return origWriteHead(...rewritten);
6587
+ };
6588
+ }
6340
6589
  const collect = (chunk, enc) => {
6341
6590
  if (chunk == null) return;
6342
6591
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof enc === "string" ? enc : "utf8");
@@ -6352,6 +6601,7 @@ async function createProtection(options = {}) {
6352
6601
  chunks.push(buf);
6353
6602
  };
6354
6603
  res.write = function(chunk, enc, cb) {
6604
+ hardenBeforeFlush();
6355
6605
  if (overflow) return origWrite(chunk, enc, cb);
6356
6606
  collect(chunk, enc);
6357
6607
  if (typeof enc === "function") enc();
@@ -6359,6 +6609,7 @@ async function createProtection(options = {}) {
6359
6609
  return true;
6360
6610
  };
6361
6611
  res.end = function(chunk, enc, cb) {
6612
+ hardenBeforeFlush();
6362
6613
  if (typeof chunk === "function") {
6363
6614
  cb = chunk;
6364
6615
  chunk = void 0;
@@ -6385,7 +6636,12 @@ async function createProtection(options = {}) {
6385
6636
  const text = buffer.toString("utf8");
6386
6637
  let r;
6387
6638
  try {
6388
- r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} }, reqCtx);
6639
+ r = screenText(
6640
+ text,
6641
+ { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} },
6642
+ reqCtx,
6643
+ answeredWithoutBody ? stillToAnswer : void 0
6644
+ );
6389
6645
  } catch (err) {
6390
6646
  notify(onError, err, "onError");
6391
6647
  for (const c of chunks) origWrite(c);
@@ -6435,15 +6691,6 @@ async function createProtection(options = {}) {
6435
6691
  const allow = new Set((options.allowHosts ?? []).map((h) => String(h).toLowerCase()));
6436
6692
  const egressShouldBlock = (url, host, method) => {
6437
6693
  if (host && allow.has(host.toLowerCase())) return false;
6438
- let result;
6439
- try {
6440
- result = egressEngine.evaluate({ _egress: { url, host, method } });
6441
- } catch (err) {
6442
- notify(onError, err, "onError");
6443
- return false;
6444
- }
6445
- if (!result.blocked) return false;
6446
- const egressMode = ruleMode(result.rule);
6447
6694
  let egressPath = null;
6448
6695
  try {
6449
6696
  const u = new URL(url);
@@ -6451,17 +6698,39 @@ async function createProtection(options = {}) {
6451
6698
  } catch {
6452
6699
  egressPath = typeof url === "string" ? url : null;
6453
6700
  }
6454
- onDetect({
6455
- phase: "egress",
6456
- mode: egressMode,
6457
- category: result.rule?.category,
6458
- rule: result.rule,
6459
- message: result.message,
6460
- method: typeof method === "string" ? method : null,
6461
- path: egressPath,
6462
- capture: evidenceFrom(result)
6463
- });
6464
- return egressMode === "block";
6701
+ const egressEvent = mintEvent();
6702
+ let block = false;
6703
+ for (const { rule, engine: re } of egressRuleSet) {
6704
+ let result;
6705
+ try {
6706
+ result = re.evaluate({ _egress: { url, host, method } });
6707
+ } catch (err) {
6708
+ notify(onError, err, "onError");
6709
+ continue;
6710
+ }
6711
+ if (!result.blocked) continue;
6712
+ const egressMode = ruleMode(rule);
6713
+ onDetect({
6714
+ phase: "egress",
6715
+ mode: egressMode,
6716
+ category: rule?.category,
6717
+ rule,
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,
6727
+ method: typeof method === "string" ? method : null,
6728
+ path: egressPath,
6729
+ capture: evidenceFrom(result)
6730
+ });
6731
+ if (egressMode === "block") block = true;
6732
+ }
6733
+ return block;
6465
6734
  };
6466
6735
  const protection = {
6467
6736
  get mode() {
@@ -6530,7 +6799,7 @@ async function createProtection(options = {}) {
6530
6799
  if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
6531
6800
  next();
6532
6801
  },
6533
- nodeRequestMeta(req, client)
6802
+ () => nodeRequestMeta(req, client)
6534
6803
  );
6535
6804
  };
6536
6805
  },
@@ -6594,7 +6863,7 @@ async function createProtection(options = {}) {
6594
6863
  if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, shaped?._clientIp));
6595
6864
  next();
6596
6865
  },
6597
- nodeRequestMeta(req, shaped?._clientIp)
6866
+ () => nodeRequestMeta(req, shaped?._clientIp)
6598
6867
  );
6599
6868
  }
6600
6869
  }
@@ -7068,11 +7337,37 @@ function defaultOnDetect({ phase, mode, category, rule, message }) {
7068
7337
  const tag = mode === "block" ? "BLOCK" : "DETECT (dry-run)";
7069
7338
  console.warn(`[patchstack] ${tag} phase=${phase ?? "request"} category=${category ?? "?"} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
7070
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
+ }
7071
7363
  function requestMetaFromContext(reqCtx) {
7072
7364
  if (!reqCtx) return {};
7073
7365
  const client = reqCtx._clientIp ?? { ip: null, source: "unavailable" };
7074
7366
  return {
7075
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),
7076
7371
  // Path AND query. The reporter is what drops the query's VALUES, keeping its parameter names, so
7077
7372
  // trimming it here would leave a Fetch or response detection unable to say what was requested.
7078
7373
  path: typeof reqCtx.originalUrl === "string" ? reqCtx.originalUrl : null,
@@ -7096,7 +7391,7 @@ function requestMeta(shaped, request) {
7096
7391
  method = request.method ?? null;
7097
7392
  userAgent = request.headers?.get?.("user-agent") ?? null;
7098
7393
  }
7099
- 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) };
7100
7395
  }
7101
7396
  function nodeRequestMeta(req, client) {
7102
7397
  if (!req) return {};
@@ -7110,7 +7405,8 @@ function nodeRequestMeta(req, client) {
7110
7405
  // verified, and a second derivation could disagree with the one the engine evaluated.
7111
7406
  ip: resolved.ip,
7112
7407
  clientIpSource: resolved.source,
7113
- 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)
7114
7410
  };
7115
7411
  }
7116
7412
  // Annotate the CommonJS export names for ESM import in node: