@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.
@@ -2194,18 +2194,10 @@ function conditionsProblem(conditions, depth = 0) {
2194
2194
  return null;
2195
2195
  }
2196
2196
 
2197
- // src/protect/capture-plan.js
2198
- var NEVER_CAPTURABLE = /* @__PURE__ */ new Set(["response"]);
2199
- var CAPTURE_LIMITS = Object.freeze({
2200
- /** Named and prefix values in one event, together. Raw has its own allowance. */
2201
- capturedValues: 10,
2202
- /** Characters of any single captured value. */
2203
- valueChars: 512,
2204
- /** Resolved values one prefix permission may contribute. */
2205
- prefixValues: 5
2206
- });
2207
- function parametersOf(rule) {
2197
+ // src/protect/rule-parameters.js
2198
+ function readRuleParameters(rule) {
2208
2199
  const out = /* @__PURE__ */ new Set();
2200
+ let complete = true;
2209
2201
  const add = (parameter) => {
2210
2202
  if (typeof parameter === "string" && parameter !== "rules") out.add(parameter);
2211
2203
  };
@@ -2217,7 +2209,11 @@ function parametersOf(rule) {
2217
2209
  add(parameter);
2218
2210
  };
2219
2211
  const walk = (conditions, depth) => {
2220
- if (!Array.isArray(conditions) || depth > 20) return;
2212
+ if (!Array.isArray(conditions)) return;
2213
+ if (depth > LIMITS.maxNestingDepth) {
2214
+ complete = false;
2215
+ return;
2216
+ }
2221
2217
  for (const condition of conditions) {
2222
2218
  if (!condition || typeof condition !== "object") continue;
2223
2219
  collect(condition.parameter);
@@ -2225,8 +2221,22 @@ function parametersOf(rule) {
2225
2221
  }
2226
2222
  };
2227
2223
  walk(rule?.rule_v2, 0);
2228
- return [...out];
2224
+ return { parameters: [...out], complete };
2225
+ }
2226
+ function ruleParameters(rule) {
2227
+ return readRuleParameters(rule).parameters;
2229
2228
  }
2229
+
2230
+ // src/protect/capture-plan.js
2231
+ var NEVER_CAPTURABLE = /* @__PURE__ */ new Set(["response"]);
2232
+ var CAPTURE_LIMITS = Object.freeze({
2233
+ /** Named and prefix values in one event, together. Raw has its own allowance. */
2234
+ capturedValues: 10,
2235
+ /** Characters of any single captured value. */
2236
+ valueChars: 512,
2237
+ /** Resolved values one prefix permission may contribute. */
2238
+ prefixValues: 5
2239
+ });
2230
2240
  function rawOptIn(rule) {
2231
2241
  if (!rule || typeof rule !== "object" || !Object.hasOwn(rule, "capture")) return null;
2232
2242
  const capture = rule.capture;
@@ -2244,7 +2254,7 @@ function derivePlan(rule) {
2244
2254
  const named = /* @__PURE__ */ new Set();
2245
2255
  const prefixes = /* @__PURE__ */ new Set();
2246
2256
  if (enforceableRuleProblem(rule) !== null) return NOTHING;
2247
- const parameters = parametersOf(rule).filter((parameter) => parameterProblem(parameter) === null);
2257
+ const parameters = ruleParameters(rule).filter((parameter) => parameterProblem(parameter) === null);
2248
2258
  for (const parameter of parameters) {
2249
2259
  const dot = parameter.indexOf(".");
2250
2260
  if (dot === -1) continue;
@@ -3037,9 +3047,23 @@ var DEFAULT_RESPONSE_RULES = [
3037
3047
  title: "Private key in response body",
3038
3048
  phase: "response",
3039
3049
  category: "secret-exposure",
3040
- action: "redact",
3050
+ // Withheld: the key material sits after the marker and this pattern does not delimit it.
3051
+ action: "block",
3041
3052
  prefilter: ["PRIVATE KEY"],
3042
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/" } }]
3053
+ // Matches a PEM BEGIN line whose label contains `PRIVATE KEY`, with up to 32 further label
3054
+ // characters — uppercase letters, digits, spaces and hyphens — on either side of it. That covers
3055
+ // the enumerated types (`RSA`, `EC`, `OPENSSH`, `DSA`, `ENCRYPTED`), a label carrying words after
3056
+ // `PRIVATE KEY` (`PGP PRIVATE KEY BLOCK`), a hyphenated or otherwise unlisted type, and a bare
3057
+ // `-----BEGIN PRIVATE KEY-----`. `PUBLIC KEY` and `CERTIFICATE` do not match, and neither does
3058
+ // prose that mentions a private key without a BEGIN line.
3059
+ //
3060
+ // No footer required: a truncated response or an absent END marker does not make the material
3061
+ // above it less of a key. Two bounded character classes rather than a repeated group — a
3062
+ // quantifier inside a quantified group is the shape the engine refuses as a backtracking risk, and
3063
+ // a refused pattern is a rule that never fires.
3064
+ //
3065
+ // A lowercase label, or one longer than 32 characters on either side, is not matched.
3066
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/-----BEGIN [A-Z0-9 -]{0,32}PRIVATE KEY[A-Z0-9 -]{0,32}-----/" } }]
3043
3067
  },
3044
3068
  {
3045
3069
  id: "resp-aws-access-key",
@@ -3116,26 +3140,65 @@ var DEFAULT_RESPONSE_RULES = [
3116
3140
  title: "Database connection string with credentials in response body",
3117
3141
  phase: "response",
3118
3142
  category: "secret-exposure",
3119
- action: "redact",
3143
+ // Withheld. The credentials are only the first half of the disclosure — the host, port, database
3144
+ // name and query name the system they open — and a URI's own grammar admits commas, parentheses
3145
+ // and semicolons, so no end-of-URI character class delimits it in free text without either
3146
+ // stopping inside a real URI or consuming the punctuation around it. The pattern therefore
3147
+ // identifies the URI and the response is withheld rather than partly rewritten.
3148
+ action: "block",
3120
3149
  prefilter: ["mongodb", "postgres", "mysql", "redis", "amqp"],
3121
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[^\\s:@\\/]+:[^\\s:@\\/]+@/i" } }]
3150
+ // A scheme this application connects with, a user, a password and an `@`. Credentials are
3151
+ // required, so a URL without them is not matched.
3152
+ //
3153
+ // The username is the userinfo grammar without `:`; the password is the same grammar with it. Every
3154
+ // other character either class admits may appear raw in a real credential, so a narrower one turns
3155
+ // a live credential into a rule that says nothing — `postgres://user:p;ss@host` is an ordinary DSN.
3156
+ //
3157
+ // The first raw colon separates username from password. Excluding it from the username makes the
3158
+ // separator unambiguous and keeps matching linear; the password continues to admit raw colons. A
3159
+ // username that contains a colon carries it as `%3A`.
3160
+ //
3161
+ // Both classes admitting `:` would leave every colon available as the separator, so a candidate
3162
+ // run with no `@` is re-split at every position. The screening cap does not bound that: a rule may
3163
+ // raise it with `max_bytes` or remove it with `bypass_limit`.
3164
+ //
3165
+ // What terminates a candidate is everything the grammar excludes: whitespace, `/`, `?`, `#`, `@`,
3166
+ // quotes, backslashes, angle and square brackets and braces. That is what keeps the run inside one
3167
+ // value. Expressed as "anything but `:`, `@`, `/` and whitespace" it crosses structure instead: in
3168
+ // `{"docs":"postgres://db.internal","contact":"user@example.com"}` it consumes the closing quote,
3169
+ // the comma and the next key, reaching the `:` and `@` of an unrelated property and withholding a
3170
+ // response that discloses nothing.
3171
+ //
3172
+ // The consequence is deliberate: a run of punctuation-joined text that parses as a credential URI
3173
+ // is treated as one. `postgres://db.internal;contact:admin@example.com` has username
3174
+ // `db.internal;contact`, password `admin` and host `example.com` — indistinguishable from a leak,
3175
+ // so it is withheld. Ordinary prose separates with whitespace, which terminates the candidate.
3176
+ 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" } }]
3122
3177
  },
3123
3178
  {
3124
3179
  id: "resp-stack-trace",
3125
3180
  title: "Node stack trace leaking in response body",
3126
3181
  phase: "response",
3127
3182
  category: "info-exposure",
3128
- action: "redact",
3183
+ // Withheld. A frame is recognised by its shape and the trace has no end the pattern can rely on,
3184
+ // so masking the frames it happens to match leaves the message, the remaining frames and every
3185
+ // path and line number in them.
3186
+ action: "block",
3129
3187
  // No prefilter: a Node stack frame has no single distinctive literal (` at ` is too common to
3130
3188
  // gate on). The pattern is linearly bounded per line, so it runs on every screened body.
3131
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\n\\s+at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
3189
+ //
3190
+ // Accepts a real newline and a JSON-escaped one. Most traces reach a client inside a JSON error
3191
+ // body, where the newline is the two characters `\` and `n`.
3192
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/(?:\\n|\\\\n)\\s*at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
3132
3193
  },
3133
3194
  {
3134
3195
  id: "resp-sql-error",
3135
3196
  title: "SQL / ORM error disclosure in response body",
3136
3197
  phase: "response",
3137
3198
  category: "info-exposure",
3138
- action: "redact",
3199
+ // Withheld. The signature is the start of the disclosure: masking `SQLSTATE[23000]` and serving
3200
+ // the constraint name, the column and the offending value discloses the schema anyway.
3201
+ action: "block",
3139
3202
  prefilter: ["SQLSTATE", "Sequelize", "ER_", "ORA-", "PG::", "SQLITE_ERROR", "SQL syntax"],
3140
3203
  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" } }]
3141
3204
  },
@@ -3144,7 +3207,9 @@ var DEFAULT_RESPONSE_RULES = [
3144
3207
  title: "Backend exception / stack trace disclosure in response body",
3145
3208
  phase: "response",
3146
3209
  category: "info-exposure",
3147
- action: "redact",
3210
+ // Withheld, for the same reason as the trace above: the marker opens the dump and the file names,
3211
+ // line numbers and frames after it are the disclosure.
3212
+ action: "block",
3148
3213
  // Multi-language exception/traceback signatures a normal API response never carries:
3149
3214
  // Python traceback, Java "Exception in thread", .NET System.*Exception, JVM stack frames,
3150
3215
  // Go goroutine dumps. (Node `at fn (file:line:col)` frames are handled by resp-stack-trace.)
@@ -3158,6 +3223,11 @@ var DEFAULT_EGRESS_RULES = [
3158
3223
  title: "Outbound request to an internal / metadata address (SSRF)",
3159
3224
  phase: "egress",
3160
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",
3161
3231
  rule_v2: [{ parameter: "egress.host", match: { type: "internal_host" } }]
3162
3232
  }
3163
3233
  ];
@@ -3524,10 +3594,25 @@ function worthRetrying(status) {
3524
3594
  }
3525
3595
  var ATTEMPT_TIMEOUT_MS = 1e4;
3526
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);
3527
3607
  var MAX_ROUTE_CHARS = 256;
3528
3608
  var MAX_PARAMETERS = 25;
3529
3609
  var MAX_PARAMETER_CHARS = 64;
3530
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
+ }
3531
3616
  var MAX_CAPTURED_VALUES = 10;
3532
3617
  var MAX_CAPTURED_VALUE_CHARS = 512;
3533
3618
  var MAX_QUERY_KEYS = 10;
@@ -3672,21 +3757,6 @@ function unattended(timer) {
3672
3757
  if (timer && typeof timer.unref === "function") timer.unref();
3673
3758
  return timer;
3674
3759
  }
3675
- function ruleParameters(rule) {
3676
- const out = /* @__PURE__ */ new Set();
3677
- const walk = (conditions) => {
3678
- if (!Array.isArray(conditions)) return;
3679
- for (const condition of conditions) {
3680
- if (!condition || typeof condition !== "object") continue;
3681
- if (typeof condition.parameter === "string" && condition.parameter !== "rules") {
3682
- out.add(condition.parameter);
3683
- }
3684
- if (Array.isArray(condition.rules)) walk(condition.rules);
3685
- }
3686
- };
3687
- walk(rule?.rule_v2);
3688
- return [...out];
3689
- }
3690
3760
  function revisionOf(rule) {
3691
3761
  const revision = rule?.source_revision;
3692
3762
  if (typeof revision === "string" && revision !== "") return revision;
@@ -3987,7 +4057,7 @@ function createDetectionReporter(opts) {
3987
4057
  const capture = boundCapture(detection.capture);
3988
4058
  const rawRoute = routeOf(detection.path);
3989
4059
  const route = typeof rawRoute === "string" ? capText(rawRoute, MAX_ROUTE_CHARS) : { value: rawRoute, truncated: false };
3990
- const allParameters = ruleParameters(detection.rule);
4060
+ const { parameters: allParameters, complete: sawEveryParameter } = readRuleParameters(detection.rule);
3991
4061
  const parameters = allParameters.slice(0, MAX_PARAMETERS).map((name) => capText(name, MAX_PARAMETER_CHARS));
3992
4062
  const truncated = [];
3993
4063
  if (route.truncated) truncated.push("route");
@@ -3996,12 +4066,14 @@ function createDetectionReporter(opts) {
3996
4066
  if (query.total > queryKeys.length || queryKeys.some((entry) => entry.truncated)) {
3997
4067
  truncated.push("query_keys");
3998
4068
  }
3999
- if (allParameters.length > MAX_PARAMETERS || parameters.some((entry) => entry.truncated)) {
4069
+ if (!sawEveryParameter || allParameters.length > MAX_PARAMETERS || parameters.some((entry) => entry.truncated)) {
4000
4070
  truncated.push("parameters");
4001
4071
  }
4002
4072
  const id = capText(String(ruleId), MAX_IDENTIFIER_CHARS);
4003
4073
  const revision = capText(revisionOf(detection.rule) ?? "", MAX_IDENTIFIER_CHARS);
4004
4074
  const etag = capText(rulesEtag ?? "", MAX_IDENTIFIER_CHARS);
4075
+ const classCategory = declaredClass(detection.rule?.category, recognisedCategory);
4076
+ const classAction = declaredClass(detection.rule?.action, recognisedAction);
4005
4077
  for (const [name, field] of [
4006
4078
  ["rule_id", id],
4007
4079
  ["rule_revision", revision],
@@ -4009,6 +4081,12 @@ function createDetectionReporter(opts) {
4009
4081
  ]) {
4010
4082
  if (field.truncated) truncated.push(name);
4011
4083
  }
4084
+ for (const [name, field] of [
4085
+ ["category", classCategory],
4086
+ ["action", classAction]
4087
+ ]) {
4088
+ if (field.dropped) truncated.push(name);
4089
+ }
4012
4090
  queue.push({
4013
4091
  rule_id: id.value,
4014
4092
  route: route.value,
@@ -4017,7 +4095,10 @@ function createDetectionReporter(opts) {
4017
4095
  ...truncated.length > 0 ? { truncated } : {},
4018
4096
  // Only when parameters were actually left out. Reporting a total because some OTHER field was
4019
4097
  // shortened states that parameters were omitted when none were.
4020
- ...truncated.includes("parameters") ? { parameters_total: allParameters.length } : {},
4098
+ // Only when the walk saw the whole rule. Shortened by the cap, this is the true total; cut short
4099
+ // by the nesting bound, it is the count of what was seen, and sending that as the total states a
4100
+ // size nobody established. The mark travels either way, so a short list is always marked as one.
4101
+ ...truncated.includes("parameters") && sawEveryParameter ? { parameters_total: allParameters.length } : {},
4021
4102
  method: method === null ? null : method.value,
4022
4103
  // The rest of the URL, as names only. `route` is the path; together they say what was requested
4023
4104
  // without saying what was in it.
@@ -4026,7 +4107,32 @@ function createDetectionReporter(opts) {
4026
4107
  ...query.total > queryKeys.length ? { query_keys_total: query.total } : {},
4027
4108
  // Who asked. Capped, since it is client-supplied text and this is an event with a size bound.
4028
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.
4029
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),
4030
4136
  // The state this detection was handled under, which is the whole point: `false` is a rule that
4031
4137
  // saw traffic it would have stopped.
4032
4138
  enforced: detection.mode === "block",
@@ -4197,6 +4303,19 @@ function reportingState(input) {
4197
4303
  return { state: "on", reports: true };
4198
4304
  }
4199
4305
 
4306
+ // src/protect/response-hardening.js
4307
+ var BODY_INDEPENDENT = Object.freeze(["response.status", "response.headers"]);
4308
+ var BODY_INDEPENDENT_PREFIX = "response.header.";
4309
+ var HEADER_ACTIONS = Object.freeze(["set-header", "remove-header", "harden-cookie"]);
4310
+ function hardensWithoutBody(rule) {
4311
+ if (!rule || !HEADER_ACTIONS.includes(rule.action)) return false;
4312
+ const { parameters, complete } = readRuleParameters(rule);
4313
+ if (!complete) return false;
4314
+ return parameters.every(
4315
+ (parameter) => BODY_INDEPENDENT.includes(parameter) || parameter.startsWith(BODY_INDEPENDENT_PREFIX)
4316
+ );
4317
+ }
4318
+
4200
4319
  // src/protect/firewall-log.js
4201
4320
  var DEFAULT_API_BASE = "https://api.patchstack.com";
4202
4321
  var STOP_BUDGET_MS2 = 5e3;
@@ -4580,7 +4699,7 @@ async function createProtection(options = {}) {
4580
4699
  let screenCap;
4581
4700
  let engine;
4582
4701
  let responseRuleSet;
4583
- let egressEngine;
4702
+ let egressRuleSet;
4584
4703
  const applyBundle = (delivered) => {
4585
4704
  const incoming = delivered.firewall ?? [];
4586
4705
  requestRules = byPhase(incoming, "request");
@@ -4607,7 +4726,10 @@ async function createProtection(options = {}) {
4607
4726
  // the common case — cutting CPU/latency and shrinking the regex/ReDoS surface. Case-insensitive.
4608
4727
  prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
4609
4728
  }));
4610
- egressEngine = new RuleEngine({ firewall: egressRules, onError });
4729
+ egressRuleSet = egressRules.map((rule) => ({
4730
+ rule,
4731
+ engine: new RuleEngine({ firewall: [rule], onError })
4732
+ }));
4611
4733
  };
4612
4734
  applyBundle(bundle);
4613
4735
  const skipCounts = /* @__PURE__ */ Object.create(null);
@@ -4632,8 +4754,9 @@ async function createProtection(options = {}) {
4632
4754
  if (!permitsAnything(entry.plan)) return { plan: entry.reference };
4633
4755
  return { plan: entry.reference, ...captureValues(entry.plan, result.resolver) };
4634
4756
  };
4635
- const decide = (phase, result, block, allow2, ctx = {}) => {
4757
+ const decide = (phase, result, block, allow2, describe = () => ({})) => {
4636
4758
  if (!result || !result.blocked) return allow2();
4759
+ const ctx = describe() ?? {};
4637
4760
  const effectiveMode = ruleMode(result.rule);
4638
4761
  onDetect({
4639
4762
  phase,
@@ -4646,6 +4769,9 @@ async function createProtection(options = {}) {
4646
4769
  method: ctx.method,
4647
4770
  path: ctx.path,
4648
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,
4649
4775
  // Provenance travels with the address. Without it a consumer cannot tell an observed peer from a
4650
4776
  // value read out of a forwarded header, and `null` from "there was no address to establish".
4651
4777
  clientIpSource: ctx.clientIpSource,
@@ -4655,12 +4781,13 @@ async function createProtection(options = {}) {
4655
4781
  });
4656
4782
  return effectiveMode === "block" ? block() : allow2();
4657
4783
  };
4658
- const screenText = (text, meta, reqCtx) => {
4784
+ const screenText = (text, meta, reqCtx, only) => {
4659
4785
  let blockRule = null;
4660
4786
  const redactions = [];
4661
4787
  const headerMutations = [];
4662
4788
  let lowerText = null;
4663
4789
  for (const { rule, engine: re, redactors, prefilter, mutatedSpan } of responseRuleSet) {
4790
+ if (only && !only(rule)) continue;
4664
4791
  if (prefilter) {
4665
4792
  if (lowerText === null) lowerText = text.toLowerCase();
4666
4793
  if (!prefilter.some((p) => lowerText.includes(p))) continue;
@@ -4776,7 +4903,7 @@ async function createProtection(options = {}) {
4776
4903
  result,
4777
4904
  () => blockResponse(result, request),
4778
4905
  () => null,
4779
- requestMeta(shaped, request)
4906
+ () => requestMeta(shaped, request)
4780
4907
  );
4781
4908
  return { blocked, client: shaped?._clientIp };
4782
4909
  };
@@ -4791,7 +4918,12 @@ async function createProtection(options = {}) {
4791
4918
  originalUrl: u.pathname + u.search,
4792
4919
  headers,
4793
4920
  ip: resolved.ip ?? "",
4794
- _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
4795
4927
  };
4796
4928
  } catch {
4797
4929
  return void 0;
@@ -4802,13 +4934,17 @@ async function createProtection(options = {}) {
4802
4934
  originalUrl: req.url,
4803
4935
  headers: req.headers || {},
4804
4936
  ip: client?.ip ?? "",
4805
- _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
4806
4941
  } : void 0;
4807
4942
  const screenResp = async (response, reqCtx) => {
4808
4943
  const read = await readTextResponse(response, screenCap);
4809
4944
  if (read.skip) {
4810
4945
  if (read.skip !== "not-a-response") recordSkip("response", read.skip, { status: response?.status });
4811
- return response;
4946
+ if (read.skip === "not-a-response") return response;
4947
+ return hardenHeadersOnly(response, reqCtx);
4812
4948
  }
4813
4949
  const text = read.text;
4814
4950
  const r = screenText(text, { status: response.status, headers: headerObject(response.headers) }, reqCtx);
@@ -4816,6 +4952,74 @@ async function createProtection(options = {}) {
4816
4952
  if (r.verdict === "redact") return rebuildResponse(response, r.body, r.headers);
4817
4953
  return response;
4818
4954
  };
4955
+ const headerValueChanged = (was, value) => {
4956
+ const had = was !== void 0 && was !== null;
4957
+ if (value === null || value === void 0) return had;
4958
+ if (!had) return true;
4959
+ if (Array.isArray(value) || Array.isArray(was)) {
4960
+ const a = Array.isArray(was) ? was : [was];
4961
+ const b = Array.isArray(value) ? value : [value];
4962
+ return a.length !== b.length || b.some((item, i) => String(item) !== String(a[i]));
4963
+ }
4964
+ return String(value) !== String(was);
4965
+ };
4966
+ const headersChanged = (before, after) => Object.entries(after).some(([name, value]) => headerValueChanged(before[name], value));
4967
+ const writeHeadHeaderIndex = (args) => {
4968
+ const at = typeof args[1] === "string" ? 2 : 1;
4969
+ const value = args[at];
4970
+ return value !== null && typeof value === "object" ? at : -1;
4971
+ };
4972
+ const writeHeadEntries = (headers) => {
4973
+ if (!Array.isArray(headers)) return Object.entries(headers);
4974
+ if (headers.length && headers.every((entry) => Array.isArray(entry) && entry.length === 2)) {
4975
+ return headers.map(([name, value]) => [String(name), value]);
4976
+ }
4977
+ const entries = [];
4978
+ for (let i = 0; i + 1 < headers.length; i += 2) entries.push([String(headers[i]), headers[i + 1]]);
4979
+ return entries;
4980
+ };
4981
+ const writeHeadHeaderObject = (headers) => {
4982
+ const out = {};
4983
+ for (const [name, value] of writeHeadEntries(headers)) {
4984
+ const key = name.toLowerCase();
4985
+ out[key] = Object.hasOwn(out, key) ? [].concat(out[key], value) : value;
4986
+ }
4987
+ return out;
4988
+ };
4989
+ const rewriteWriteHeadHeaders = (headers, changed) => {
4990
+ const replaced = /* @__PURE__ */ new Set();
4991
+ const entries = [];
4992
+ for (const [name, value] of writeHeadEntries(headers)) {
4993
+ const key = name.toLowerCase();
4994
+ if (!changed.has(key)) {
4995
+ entries.push([name, value]);
4996
+ continue;
4997
+ }
4998
+ if (replaced.has(key)) continue;
4999
+ replaced.add(key);
5000
+ const replacement = changed.get(key);
5001
+ if (replacement === null || replacement === void 0) continue;
5002
+ entries.push([name, replacement]);
5003
+ }
5004
+ if (!Array.isArray(headers)) return Object.fromEntries(entries);
5005
+ const paired = headers.length > 0 && headers.every((entry) => Array.isArray(entry) && entry.length === 2);
5006
+ const expanded = entries.flatMap(
5007
+ ([name, value]) => Array.isArray(value) ? value.map((item) => [name, item]) : [[name, value]]
5008
+ );
5009
+ return paired ? expanded : expanded.flat();
5010
+ };
5011
+ const hardenHeadersOnly = (response, reqCtx) => {
5012
+ try {
5013
+ const meta = { status: response.status, headers: headerObject(response.headers) };
5014
+ const r = screenText("", meta, reqCtx, hardensWithoutBody);
5015
+ if (r.verdict !== "redact" || !r.headers) return response;
5016
+ if (!headersChanged(meta.headers, r.headers)) return response;
5017
+ return rebuildResponse(response, response.body, r.headers);
5018
+ } catch (err) {
5019
+ notify(onError, err, "onError");
5020
+ return response;
5021
+ }
5022
+ };
4819
5023
  const wrapNodeResponse = (res, reqCtx) => {
4820
5024
  const origWrite = res.write.bind(res);
4821
5025
  const origEnd = res.end.bind(res);
@@ -4823,6 +5027,51 @@ async function createProtection(options = {}) {
4823
5027
  let size = 0;
4824
5028
  let overflow = false;
4825
5029
  const MAX = screenCap;
5030
+ let hardened = false;
5031
+ let answeredWithoutBody = false;
5032
+ const hardenBeforeFlush = (effective) => {
5033
+ if (hardened) return null;
5034
+ hardened = true;
5035
+ try {
5036
+ if (res.headersSent || typeof res.setHeader !== "function") return null;
5037
+ const set = typeof res.getHeaders === "function" ? res.getHeaders() : {};
5038
+ const headers = effective && effective.headers ? { ...set, ...effective.headers } : set;
5039
+ const status = effective && effective.status !== void 0 ? effective.status : res.statusCode;
5040
+ const r = screenText("", { status, headers }, reqCtx, hardensWithoutBody);
5041
+ answeredWithoutBody = true;
5042
+ if (r.verdict !== "redact" || !r.headers) return null;
5043
+ const changed = /* @__PURE__ */ new Map();
5044
+ for (const [name, value] of Object.entries(r.headers)) {
5045
+ if (!headerValueChanged(headers[name], value)) continue;
5046
+ changed.set(name.toLowerCase(), value);
5047
+ try {
5048
+ if (value === null || value === void 0) res.removeHeader?.(name);
5049
+ else res.setHeader(name, value);
5050
+ } catch {
5051
+ }
5052
+ }
5053
+ return changed.size ? changed : null;
5054
+ } catch (err) {
5055
+ notify(onError, err, "onError");
5056
+ return null;
5057
+ }
5058
+ };
5059
+ const stillToAnswer = (rule) => !hardensWithoutBody(rule);
5060
+ if (typeof res.writeHead === "function") {
5061
+ const origWriteHead = res.writeHead.bind(res);
5062
+ res.writeHead = function(...args) {
5063
+ const at = writeHeadHeaderIndex(args);
5064
+ const given = at === -1 ? null : args[at];
5065
+ const changed = hardenBeforeFlush({
5066
+ status: typeof args[0] === "number" ? args[0] : void 0,
5067
+ headers: given ? writeHeadHeaderObject(given) : null
5068
+ });
5069
+ if (!changed || !given) return origWriteHead(...args);
5070
+ const rewritten = [...args];
5071
+ rewritten[at] = rewriteWriteHeadHeaders(given, changed);
5072
+ return origWriteHead(...rewritten);
5073
+ };
5074
+ }
4826
5075
  const collect = (chunk, enc) => {
4827
5076
  if (chunk == null) return;
4828
5077
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof enc === "string" ? enc : "utf8");
@@ -4838,6 +5087,7 @@ async function createProtection(options = {}) {
4838
5087
  chunks.push(buf);
4839
5088
  };
4840
5089
  res.write = function(chunk, enc, cb) {
5090
+ hardenBeforeFlush();
4841
5091
  if (overflow) return origWrite(chunk, enc, cb);
4842
5092
  collect(chunk, enc);
4843
5093
  if (typeof enc === "function") enc();
@@ -4845,6 +5095,7 @@ async function createProtection(options = {}) {
4845
5095
  return true;
4846
5096
  };
4847
5097
  res.end = function(chunk, enc, cb) {
5098
+ hardenBeforeFlush();
4848
5099
  if (typeof chunk === "function") {
4849
5100
  cb = chunk;
4850
5101
  chunk = void 0;
@@ -4871,7 +5122,12 @@ async function createProtection(options = {}) {
4871
5122
  const text = buffer.toString("utf8");
4872
5123
  let r;
4873
5124
  try {
4874
- r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} }, reqCtx);
5125
+ r = screenText(
5126
+ text,
5127
+ { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} },
5128
+ reqCtx,
5129
+ answeredWithoutBody ? stillToAnswer : void 0
5130
+ );
4875
5131
  } catch (err) {
4876
5132
  notify(onError, err, "onError");
4877
5133
  for (const c of chunks) origWrite(c);
@@ -4921,15 +5177,6 @@ async function createProtection(options = {}) {
4921
5177
  const allow = new Set((options.allowHosts ?? []).map((h) => String(h).toLowerCase()));
4922
5178
  const egressShouldBlock = (url, host, method) => {
4923
5179
  if (host && allow.has(host.toLowerCase())) return false;
4924
- let result;
4925
- try {
4926
- result = egressEngine.evaluate({ _egress: { url, host, method } });
4927
- } catch (err) {
4928
- notify(onError, err, "onError");
4929
- return false;
4930
- }
4931
- if (!result.blocked) return false;
4932
- const egressMode = ruleMode(result.rule);
4933
5180
  let egressPath = null;
4934
5181
  try {
4935
5182
  const u = new URL(url);
@@ -4937,17 +5184,39 @@ async function createProtection(options = {}) {
4937
5184
  } catch {
4938
5185
  egressPath = typeof url === "string" ? url : null;
4939
5186
  }
4940
- onDetect({
4941
- phase: "egress",
4942
- mode: egressMode,
4943
- category: result.rule?.category,
4944
- rule: result.rule,
4945
- message: result.message,
4946
- method: typeof method === "string" ? method : null,
4947
- path: egressPath,
4948
- capture: evidenceFrom(result)
4949
- });
4950
- return egressMode === "block";
5187
+ const egressEvent = mintEvent();
5188
+ let block = false;
5189
+ for (const { rule, engine: re } of egressRuleSet) {
5190
+ let result;
5191
+ try {
5192
+ result = re.evaluate({ _egress: { url, host, method } });
5193
+ } catch (err) {
5194
+ notify(onError, err, "onError");
5195
+ continue;
5196
+ }
5197
+ if (!result.blocked) continue;
5198
+ const egressMode = ruleMode(rule);
5199
+ onDetect({
5200
+ phase: "egress",
5201
+ mode: egressMode,
5202
+ category: rule?.category,
5203
+ rule,
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,
5213
+ method: typeof method === "string" ? method : null,
5214
+ path: egressPath,
5215
+ capture: evidenceFrom(result)
5216
+ });
5217
+ if (egressMode === "block") block = true;
5218
+ }
5219
+ return block;
4951
5220
  };
4952
5221
  const protection = {
4953
5222
  get mode() {
@@ -5016,7 +5285,7 @@ async function createProtection(options = {}) {
5016
5285
  if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
5017
5286
  next();
5018
5287
  },
5019
- nodeRequestMeta(req, client)
5288
+ () => nodeRequestMeta(req, client)
5020
5289
  );
5021
5290
  };
5022
5291
  },
@@ -5080,7 +5349,7 @@ async function createProtection(options = {}) {
5080
5349
  if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, shaped?._clientIp));
5081
5350
  next();
5082
5351
  },
5083
- nodeRequestMeta(req, shaped?._clientIp)
5352
+ () => nodeRequestMeta(req, shaped?._clientIp)
5084
5353
  );
5085
5354
  }
5086
5355
  }
@@ -5554,11 +5823,37 @@ function defaultOnDetect({ phase, mode, category, rule, message }) {
5554
5823
  const tag = mode === "block" ? "BLOCK" : "DETECT (dry-run)";
5555
5824
  console.warn(`[patchstack] ${tag} phase=${phase ?? "request"} category=${category ?? "?"} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
5556
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
+ }
5557
5849
  function requestMetaFromContext(reqCtx) {
5558
5850
  if (!reqCtx) return {};
5559
5851
  const client = reqCtx._clientIp ?? { ip: null, source: "unavailable" };
5560
5852
  return {
5561
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),
5562
5857
  // Path AND query. The reporter is what drops the query's VALUES, keeping its parameter names, so
5563
5858
  // trimming it here would leave a Fetch or response detection unable to say what was requested.
5564
5859
  path: typeof reqCtx.originalUrl === "string" ? reqCtx.originalUrl : null,
@@ -5582,7 +5877,7 @@ function requestMeta(shaped, request) {
5582
5877
  method = request.method ?? null;
5583
5878
  userAgent = request.headers?.get?.("user-agent") ?? null;
5584
5879
  }
5585
- return { method, path, ip: client.ip, clientIpSource: client.source, userAgent };
5880
+ return { method, path, ip: client.ip, clientIpSource: client.source, userAgent, event: eventFor(request) };
5586
5881
  }
5587
5882
  function nodeRequestMeta(req, client) {
5588
5883
  if (!req) return {};
@@ -5596,7 +5891,8 @@ function nodeRequestMeta(req, client) {
5596
5891
  // verified, and a second derivation could disagree with the one the engine evaluated.
5597
5892
  ip: resolved.ip,
5598
5893
  clientIpSource: resolved.source,
5599
- 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)
5600
5896
  };
5601
5897
  }
5602
5898
  export {