@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.js CHANGED
@@ -2162,18 +2162,10 @@ function conditionsProblem(conditions, depth = 0) {
2162
2162
  return null;
2163
2163
  }
2164
2164
 
2165
- // src/protect/capture-plan.js
2166
- var NEVER_CAPTURABLE = /* @__PURE__ */ new Set(["response"]);
2167
- var CAPTURE_LIMITS = Object.freeze({
2168
- /** Named and prefix values in one event, together. Raw has its own allowance. */
2169
- capturedValues: 10,
2170
- /** Characters of any single captured value. */
2171
- valueChars: 512,
2172
- /** Resolved values one prefix permission may contribute. */
2173
- prefixValues: 5
2174
- });
2175
- function parametersOf(rule) {
2165
+ // src/protect/rule-parameters.js
2166
+ function readRuleParameters(rule) {
2176
2167
  const out = /* @__PURE__ */ new Set();
2168
+ let complete = true;
2177
2169
  const add = (parameter) => {
2178
2170
  if (typeof parameter === "string" && parameter !== "rules") out.add(parameter);
2179
2171
  };
@@ -2185,7 +2177,11 @@ function parametersOf(rule) {
2185
2177
  add(parameter);
2186
2178
  };
2187
2179
  const walk = (conditions, depth) => {
2188
- if (!Array.isArray(conditions) || depth > 20) return;
2180
+ if (!Array.isArray(conditions)) return;
2181
+ if (depth > LIMITS.maxNestingDepth) {
2182
+ complete = false;
2183
+ return;
2184
+ }
2189
2185
  for (const condition of conditions) {
2190
2186
  if (!condition || typeof condition !== "object") continue;
2191
2187
  collect(condition.parameter);
@@ -2193,8 +2189,22 @@ function parametersOf(rule) {
2193
2189
  }
2194
2190
  };
2195
2191
  walk(rule?.rule_v2, 0);
2196
- return [...out];
2192
+ return { parameters: [...out], complete };
2193
+ }
2194
+ function ruleParameters(rule) {
2195
+ return readRuleParameters(rule).parameters;
2197
2196
  }
2197
+
2198
+ // src/protect/capture-plan.js
2199
+ var NEVER_CAPTURABLE = /* @__PURE__ */ new Set(["response"]);
2200
+ var CAPTURE_LIMITS = Object.freeze({
2201
+ /** Named and prefix values in one event, together. Raw has its own allowance. */
2202
+ capturedValues: 10,
2203
+ /** Characters of any single captured value. */
2204
+ valueChars: 512,
2205
+ /** Resolved values one prefix permission may contribute. */
2206
+ prefixValues: 5
2207
+ });
2198
2208
  function rawOptIn(rule) {
2199
2209
  if (!rule || typeof rule !== "object" || !Object.hasOwn(rule, "capture")) return null;
2200
2210
  const capture = rule.capture;
@@ -2212,7 +2222,7 @@ function derivePlan(rule) {
2212
2222
  const named = /* @__PURE__ */ new Set();
2213
2223
  const prefixes = /* @__PURE__ */ new Set();
2214
2224
  if (enforceableRuleProblem(rule) !== null) return NOTHING;
2215
- const parameters = parametersOf(rule).filter((parameter) => parameterProblem(parameter) === null);
2225
+ const parameters = ruleParameters(rule).filter((parameter) => parameterProblem(parameter) === null);
2216
2226
  for (const parameter of parameters) {
2217
2227
  const dot = parameter.indexOf(".");
2218
2228
  if (dot === -1) continue;
@@ -2923,9 +2933,23 @@ var DEFAULT_RESPONSE_RULES = [
2923
2933
  title: "Private key in response body",
2924
2934
  phase: "response",
2925
2935
  category: "secret-exposure",
2926
- action: "redact",
2936
+ // Withheld: the key material sits after the marker and this pattern does not delimit it.
2937
+ action: "block",
2927
2938
  prefilter: ["PRIVATE KEY"],
2928
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/" } }]
2939
+ // Matches a PEM BEGIN line whose label contains `PRIVATE KEY`, with up to 32 further label
2940
+ // characters — uppercase letters, digits, spaces and hyphens — on either side of it. That covers
2941
+ // the enumerated types (`RSA`, `EC`, `OPENSSH`, `DSA`, `ENCRYPTED`), a label carrying words after
2942
+ // `PRIVATE KEY` (`PGP PRIVATE KEY BLOCK`), a hyphenated or otherwise unlisted type, and a bare
2943
+ // `-----BEGIN PRIVATE KEY-----`. `PUBLIC KEY` and `CERTIFICATE` do not match, and neither does
2944
+ // prose that mentions a private key without a BEGIN line.
2945
+ //
2946
+ // No footer required: a truncated response or an absent END marker does not make the material
2947
+ // above it less of a key. Two bounded character classes rather than a repeated group — a
2948
+ // quantifier inside a quantified group is the shape the engine refuses as a backtracking risk, and
2949
+ // a refused pattern is a rule that never fires.
2950
+ //
2951
+ // A lowercase label, or one longer than 32 characters on either side, is not matched.
2952
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/-----BEGIN [A-Z0-9 -]{0,32}PRIVATE KEY[A-Z0-9 -]{0,32}-----/" } }]
2929
2953
  },
2930
2954
  {
2931
2955
  id: "resp-aws-access-key",
@@ -3002,26 +3026,65 @@ var DEFAULT_RESPONSE_RULES = [
3002
3026
  title: "Database connection string with credentials in response body",
3003
3027
  phase: "response",
3004
3028
  category: "secret-exposure",
3005
- action: "redact",
3029
+ // Withheld. The credentials are only the first half of the disclosure — the host, port, database
3030
+ // name and query name the system they open — and a URI's own grammar admits commas, parentheses
3031
+ // and semicolons, so no end-of-URI character class delimits it in free text without either
3032
+ // stopping inside a real URI or consuming the punctuation around it. The pattern therefore
3033
+ // identifies the URI and the response is withheld rather than partly rewritten.
3034
+ action: "block",
3006
3035
  prefilter: ["mongodb", "postgres", "mysql", "redis", "amqp"],
3007
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[^\\s:@\\/]+:[^\\s:@\\/]+@/i" } }]
3036
+ // A scheme this application connects with, a user, a password and an `@`. Credentials are
3037
+ // required, so a URL without them is not matched.
3038
+ //
3039
+ // The username is the userinfo grammar without `:`; the password is the same grammar with it. Every
3040
+ // other character either class admits may appear raw in a real credential, so a narrower one turns
3041
+ // a live credential into a rule that says nothing — `postgres://user:p;ss@host` is an ordinary DSN.
3042
+ //
3043
+ // The first raw colon separates username from password. Excluding it from the username makes the
3044
+ // separator unambiguous and keeps matching linear; the password continues to admit raw colons. A
3045
+ // username that contains a colon carries it as `%3A`.
3046
+ //
3047
+ // Both classes admitting `:` would leave every colon available as the separator, so a candidate
3048
+ // run with no `@` is re-split at every position. The screening cap does not bound that: a rule may
3049
+ // raise it with `max_bytes` or remove it with `bypass_limit`.
3050
+ //
3051
+ // What terminates a candidate is everything the grammar excludes: whitespace, `/`, `?`, `#`, `@`,
3052
+ // quotes, backslashes, angle and square brackets and braces. That is what keeps the run inside one
3053
+ // value. Expressed as "anything but `:`, `@`, `/` and whitespace" it crosses structure instead: in
3054
+ // `{"docs":"postgres://db.internal","contact":"user@example.com"}` it consumes the closing quote,
3055
+ // the comma and the next key, reaching the `:` and `@` of an unrelated property and withholding a
3056
+ // response that discloses nothing.
3057
+ //
3058
+ // The consequence is deliberate: a run of punctuation-joined text that parses as a credential URI
3059
+ // is treated as one. `postgres://db.internal;contact:admin@example.com` has username
3060
+ // `db.internal;contact`, password `admin` and host `example.com` — indistinguishable from a leak,
3061
+ // so it is withheld. Ordinary prose separates with whitespace, which terminates the candidate.
3062
+ 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" } }]
3008
3063
  },
3009
3064
  {
3010
3065
  id: "resp-stack-trace",
3011
3066
  title: "Node stack trace leaking in response body",
3012
3067
  phase: "response",
3013
3068
  category: "info-exposure",
3014
- action: "redact",
3069
+ // Withheld. A frame is recognised by its shape and the trace has no end the pattern can rely on,
3070
+ // so masking the frames it happens to match leaves the message, the remaining frames and every
3071
+ // path and line number in them.
3072
+ action: "block",
3015
3073
  // No prefilter: a Node stack frame has no single distinctive literal (` at ` is too common to
3016
3074
  // gate on). The pattern is linearly bounded per line, so it runs on every screened body.
3017
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\n\\s+at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
3075
+ //
3076
+ // Accepts a real newline and a JSON-escaped one. Most traces reach a client inside a JSON error
3077
+ // body, where the newline is the two characters `\` and `n`.
3078
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/(?:\\n|\\\\n)\\s*at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
3018
3079
  },
3019
3080
  {
3020
3081
  id: "resp-sql-error",
3021
3082
  title: "SQL / ORM error disclosure in response body",
3022
3083
  phase: "response",
3023
3084
  category: "info-exposure",
3024
- action: "redact",
3085
+ // Withheld. The signature is the start of the disclosure: masking `SQLSTATE[23000]` and serving
3086
+ // the constraint name, the column and the offending value discloses the schema anyway.
3087
+ action: "block",
3025
3088
  prefilter: ["SQLSTATE", "Sequelize", "ER_", "ORA-", "PG::", "SQLITE_ERROR", "SQL syntax"],
3026
3089
  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" } }]
3027
3090
  },
@@ -3030,7 +3093,9 @@ var DEFAULT_RESPONSE_RULES = [
3030
3093
  title: "Backend exception / stack trace disclosure in response body",
3031
3094
  phase: "response",
3032
3095
  category: "info-exposure",
3033
- action: "redact",
3096
+ // Withheld, for the same reason as the trace above: the marker opens the dump and the file names,
3097
+ // line numbers and frames after it are the disclosure.
3098
+ action: "block",
3034
3099
  // Multi-language exception/traceback signatures a normal API response never carries:
3035
3100
  // Python traceback, Java "Exception in thread", .NET System.*Exception, JVM stack frames,
3036
3101
  // Go goroutine dumps. (Node `at fn (file:line:col)` frames are handled by resp-stack-trace.)
@@ -3044,6 +3109,11 @@ var DEFAULT_EGRESS_RULES = [
3044
3109
  title: "Outbound request to an internal / metadata address (SSRF)",
3045
3110
  phase: "egress",
3046
3111
  category: "ssrf",
3112
+ // Declared, not implied. Nothing in the egress path reads it — a match refuses the call, and the
3113
+ // rule's mode decides whether it actually did — but the action is part of how a rule describes
3114
+ // itself to whatever reports on it, and a rule that declares nothing is reported as a rule nobody
3115
+ // can classify.
3116
+ action: "block",
3047
3117
  rule_v2: [{ parameter: "egress.host", match: { type: "internal_host" } }]
3048
3118
  }
3049
3119
  ];
@@ -3410,10 +3480,25 @@ function worthRetrying(status) {
3410
3480
  }
3411
3481
  var ATTEMPT_TIMEOUT_MS = 1e4;
3412
3482
  var STOP_BUDGET_MS = 5e3;
3483
+ var CATEGORY_PATTERN = /^[a-z][a-z0-9-]*$/;
3484
+ var RESERVED_CATEGORY = "unknown";
3485
+ function declaredClass(declared, recognised) {
3486
+ if (typeof declared !== "string" || declared === "") return { value: null, dropped: false };
3487
+ if (!recognised(declared)) return { value: null, dropped: false };
3488
+ if (declared.length > MAX_CLASS_CHARS) return { value: null, dropped: true };
3489
+ return { value: declared, dropped: false };
3490
+ }
3491
+ var recognisedCategory = (value) => value !== RESERVED_CATEGORY && CATEGORY_PATTERN.test(value);
3492
+ var recognisedAction = (value) => ACTIONS.includes(value);
3413
3493
  var MAX_ROUTE_CHARS = 256;
3414
3494
  var MAX_PARAMETERS = 25;
3415
3495
  var MAX_PARAMETER_CHARS = 64;
3416
3496
  var MAX_IDENTIFIER_CHARS = 256;
3497
+ var MAX_CLASS_CHARS = 64;
3498
+ var EVENT_PATTERN = /^[0-9a-f]{32}$/;
3499
+ function eventIdentity(value) {
3500
+ return typeof value === "string" && EVENT_PATTERN.test(value) ? value : null;
3501
+ }
3417
3502
  var MAX_CAPTURED_VALUES = 10;
3418
3503
  var MAX_CAPTURED_VALUE_CHARS = 512;
3419
3504
  var MAX_QUERY_KEYS = 10;
@@ -3558,21 +3643,6 @@ function unattended(timer) {
3558
3643
  if (timer && typeof timer.unref === "function") timer.unref();
3559
3644
  return timer;
3560
3645
  }
3561
- function ruleParameters(rule) {
3562
- const out = /* @__PURE__ */ new Set();
3563
- const walk = (conditions) => {
3564
- if (!Array.isArray(conditions)) return;
3565
- for (const condition of conditions) {
3566
- if (!condition || typeof condition !== "object") continue;
3567
- if (typeof condition.parameter === "string" && condition.parameter !== "rules") {
3568
- out.add(condition.parameter);
3569
- }
3570
- if (Array.isArray(condition.rules)) walk(condition.rules);
3571
- }
3572
- };
3573
- walk(rule?.rule_v2);
3574
- return [...out];
3575
- }
3576
3646
  function revisionOf(rule) {
3577
3647
  const revision = rule?.source_revision;
3578
3648
  if (typeof revision === "string" && revision !== "") return revision;
@@ -3873,7 +3943,7 @@ function createDetectionReporter(opts) {
3873
3943
  const capture = boundCapture(detection.capture);
3874
3944
  const rawRoute = routeOf(detection.path);
3875
3945
  const route = typeof rawRoute === "string" ? capText(rawRoute, MAX_ROUTE_CHARS) : { value: rawRoute, truncated: false };
3876
- const allParameters = ruleParameters(detection.rule);
3946
+ const { parameters: allParameters, complete: sawEveryParameter } = readRuleParameters(detection.rule);
3877
3947
  const parameters = allParameters.slice(0, MAX_PARAMETERS).map((name) => capText(name, MAX_PARAMETER_CHARS));
3878
3948
  const truncated = [];
3879
3949
  if (route.truncated) truncated.push("route");
@@ -3882,12 +3952,14 @@ function createDetectionReporter(opts) {
3882
3952
  if (query.total > queryKeys.length || queryKeys.some((entry) => entry.truncated)) {
3883
3953
  truncated.push("query_keys");
3884
3954
  }
3885
- if (allParameters.length > MAX_PARAMETERS || parameters.some((entry) => entry.truncated)) {
3955
+ if (!sawEveryParameter || allParameters.length > MAX_PARAMETERS || parameters.some((entry) => entry.truncated)) {
3886
3956
  truncated.push("parameters");
3887
3957
  }
3888
3958
  const id = capText(String(ruleId), MAX_IDENTIFIER_CHARS);
3889
3959
  const revision = capText(revisionOf(detection.rule) ?? "", MAX_IDENTIFIER_CHARS);
3890
3960
  const etag = capText(rulesEtag ?? "", MAX_IDENTIFIER_CHARS);
3961
+ const classCategory = declaredClass(detection.rule?.category, recognisedCategory);
3962
+ const classAction = declaredClass(detection.rule?.action, recognisedAction);
3891
3963
  for (const [name, field] of [
3892
3964
  ["rule_id", id],
3893
3965
  ["rule_revision", revision],
@@ -3895,6 +3967,12 @@ function createDetectionReporter(opts) {
3895
3967
  ]) {
3896
3968
  if (field.truncated) truncated.push(name);
3897
3969
  }
3970
+ for (const [name, field] of [
3971
+ ["category", classCategory],
3972
+ ["action", classAction]
3973
+ ]) {
3974
+ if (field.dropped) truncated.push(name);
3975
+ }
3898
3976
  queue.push({
3899
3977
  rule_id: id.value,
3900
3978
  route: route.value,
@@ -3903,7 +3981,10 @@ function createDetectionReporter(opts) {
3903
3981
  ...truncated.length > 0 ? { truncated } : {},
3904
3982
  // Only when parameters were actually left out. Reporting a total because some OTHER field was
3905
3983
  // shortened states that parameters were omitted when none were.
3906
- ...truncated.includes("parameters") ? { parameters_total: allParameters.length } : {},
3984
+ // Only when the walk saw the whole rule. Shortened by the cap, this is the true total; cut short
3985
+ // by the nesting bound, it is the count of what was seen, and sending that as the total states a
3986
+ // size nobody established. The mark travels either way, so a short list is always marked as one.
3987
+ ...truncated.includes("parameters") && sawEveryParameter ? { parameters_total: allParameters.length } : {},
3907
3988
  method: method === null ? null : method.value,
3908
3989
  // The rest of the URL, as names only. `route` is the path; together they say what was requested
3909
3990
  // without saying what was in it.
@@ -3912,7 +3993,32 @@ function createDetectionReporter(opts) {
3912
3993
  ...query.total > queryKeys.length ? { query_keys_total: query.total } : {},
3913
3994
  // Who asked. Capped, since it is client-supplied text and this is an event with a size bound.
3914
3995
  user_agent: userAgent === null ? null : userAgent.value,
3996
+ // What KIND of match this was: which phase it happened in, what class of thing the rule is for,
3997
+ // and what the rule DECLARES it does about it.
3998
+ //
3999
+ // Three separate facts, and none of them is `enforced` below. A rule declaring `block` while
4000
+ // observing reports exactly that — `action: 'block'`, `enforced: false` — which is what a
4001
+ // dry-run window consists of. Reading either off the other would describe such a window as
4002
+ // protection that never happened, or as rules that do nothing.
4003
+ //
4004
+ // `null` where the rule says nothing, never a guess. A consumer can tell "this rule is for
4005
+ // secret exposure" from "we cannot say what this rule is for", and a filled-in value would take
4006
+ // that distinction away for the sake of a tidier field.
3915
4007
  phase: detection.phase ?? null,
4008
+ // `null` where the rule declared nothing, and also where what it declared could not be carried:
4009
+ // both are "we cannot say what this rule is for", which is a different fact from a class we do
4010
+ // know, and `truncated` distinguishes the second from the first.
4011
+ category: classCategory.value,
4012
+ action: classAction.value,
4013
+ // Which call this detection belongs to, so a consumer can tell one call two rules saw from two
4014
+ // separate calls. Two rules matching one call is the ordinary case — a rule that enforces and a
4015
+ // rule that only observes are meant to match the same thing — so without this, adding these up
4016
+ // reports one call more than once.
4017
+ //
4018
+ // Nothing about the request goes into it: it is only ever compared with other identities, so
4019
+ // deriving it from the address or the path would carry something about whoever made the request
4020
+ // into a place nothing needs it. That is a property of how it is minted, not of its shape.
4021
+ event: eventIdentity(detection.event),
3916
4022
  // The state this detection was handled under, which is the whole point: `false` is a rule that
3917
4023
  // saw traffic it would have stopped.
3918
4024
  enforced: detection.mode === "block",
@@ -4083,6 +4189,19 @@ function reportingState(input) {
4083
4189
  return { state: "on", reports: true };
4084
4190
  }
4085
4191
 
4192
+ // src/protect/response-hardening.js
4193
+ var BODY_INDEPENDENT = Object.freeze(["response.status", "response.headers"]);
4194
+ var BODY_INDEPENDENT_PREFIX = "response.header.";
4195
+ var HEADER_ACTIONS = Object.freeze(["set-header", "remove-header", "harden-cookie"]);
4196
+ function hardensWithoutBody(rule) {
4197
+ if (!rule || !HEADER_ACTIONS.includes(rule.action)) return false;
4198
+ const { parameters, complete } = readRuleParameters(rule);
4199
+ if (!complete) return false;
4200
+ return parameters.every(
4201
+ (parameter) => BODY_INDEPENDENT.includes(parameter) || parameter.startsWith(BODY_INDEPENDENT_PREFIX)
4202
+ );
4203
+ }
4204
+
4086
4205
  // src/protect/firewall-log.js
4087
4206
  var DEFAULT_API_BASE = "https://api.patchstack.com";
4088
4207
  var STOP_BUDGET_MS2 = 5e3;
@@ -4466,7 +4585,7 @@ async function createProtection(options = {}) {
4466
4585
  let screenCap;
4467
4586
  let engine;
4468
4587
  let responseRuleSet;
4469
- let egressEngine;
4588
+ let egressRuleSet;
4470
4589
  const applyBundle = (delivered) => {
4471
4590
  const incoming = delivered.firewall ?? [];
4472
4591
  requestRules = byPhase(incoming, "request");
@@ -4493,7 +4612,10 @@ async function createProtection(options = {}) {
4493
4612
  // the common case — cutting CPU/latency and shrinking the regex/ReDoS surface. Case-insensitive.
4494
4613
  prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
4495
4614
  }));
4496
- egressEngine = new RuleEngine({ firewall: egressRules, onError });
4615
+ egressRuleSet = egressRules.map((rule) => ({
4616
+ rule,
4617
+ engine: new RuleEngine({ firewall: [rule], onError })
4618
+ }));
4497
4619
  };
4498
4620
  applyBundle(bundle);
4499
4621
  const skipCounts = /* @__PURE__ */ Object.create(null);
@@ -4518,8 +4640,9 @@ async function createProtection(options = {}) {
4518
4640
  if (!permitsAnything(entry.plan)) return { plan: entry.reference };
4519
4641
  return { plan: entry.reference, ...captureValues(entry.plan, result.resolver) };
4520
4642
  };
4521
- const decide = (phase, result, block, allow2, ctx = {}) => {
4643
+ const decide = (phase, result, block, allow2, describe = () => ({})) => {
4522
4644
  if (!result || !result.blocked) return allow2();
4645
+ const ctx = describe() ?? {};
4523
4646
  const effectiveMode = ruleMode(result.rule);
4524
4647
  onDetect({
4525
4648
  phase,
@@ -4532,6 +4655,9 @@ async function createProtection(options = {}) {
4532
4655
  method: ctx.method,
4533
4656
  path: ctx.path,
4534
4657
  ip: ctx.ip,
4658
+ // Which call this was. Named here rather than spread from `ctx` for the same reason as everything
4659
+ // else in this payload: a field reaches the wire because someone listed it.
4660
+ event: ctx.event ?? null,
4535
4661
  // Provenance travels with the address. Without it a consumer cannot tell an observed peer from a
4536
4662
  // value read out of a forwarded header, and `null` from "there was no address to establish".
4537
4663
  clientIpSource: ctx.clientIpSource,
@@ -4541,12 +4667,13 @@ async function createProtection(options = {}) {
4541
4667
  });
4542
4668
  return effectiveMode === "block" ? block() : allow2();
4543
4669
  };
4544
- const screenText = (text, meta, reqCtx) => {
4670
+ const screenText = (text, meta, reqCtx, only) => {
4545
4671
  let blockRule = null;
4546
4672
  const redactions = [];
4547
4673
  const headerMutations = [];
4548
4674
  let lowerText = null;
4549
4675
  for (const { rule, engine: re, redactors, prefilter, mutatedSpan } of responseRuleSet) {
4676
+ if (only && !only(rule)) continue;
4550
4677
  if (prefilter) {
4551
4678
  if (lowerText === null) lowerText = text.toLowerCase();
4552
4679
  if (!prefilter.some((p) => lowerText.includes(p))) continue;
@@ -4662,7 +4789,7 @@ async function createProtection(options = {}) {
4662
4789
  result,
4663
4790
  () => blockResponse(result, request),
4664
4791
  () => null,
4665
- requestMeta(shaped, request)
4792
+ () => requestMeta(shaped, request)
4666
4793
  );
4667
4794
  return { blocked, client: shaped?._clientIp };
4668
4795
  };
@@ -4677,7 +4804,12 @@ async function createProtection(options = {}) {
4677
4804
  originalUrl: u.pathname + u.search,
4678
4805
  headers,
4679
4806
  ip: resolved.ip ?? "",
4680
- _clientIp: resolved
4807
+ _clientIp: resolved,
4808
+ // The request itself, so the identity can be asked for LATER. This context is built on every
4809
+ // request whether or not anything matches, so asking here would mint one for every request —
4810
+ // and asking of the context rather than of the request would give the response a different
4811
+ // identity from the request that caused it, reporting one call as two.
4812
+ _eventOf: request
4681
4813
  };
4682
4814
  } catch {
4683
4815
  return void 0;
@@ -4688,13 +4820,17 @@ async function createProtection(options = {}) {
4688
4820
  originalUrl: req.url,
4689
4821
  headers: req.headers || {},
4690
4822
  ip: client?.ip ?? "",
4691
- _clientIp: client ?? { ip: null, source: "unavailable" }
4823
+ _clientIp: client ?? { ip: null, source: "unavailable" },
4824
+ // The request itself, for the same reasons as on the fetch path above: asked for later, and
4825
+ // asked of the request rather than of this context.
4826
+ _eventOf: req
4692
4827
  } : void 0;
4693
4828
  const screenResp = async (response, reqCtx) => {
4694
4829
  const read = await readTextResponse(response, screenCap);
4695
4830
  if (read.skip) {
4696
4831
  if (read.skip !== "not-a-response") recordSkip("response", read.skip, { status: response?.status });
4697
- return response;
4832
+ if (read.skip === "not-a-response") return response;
4833
+ return hardenHeadersOnly(response, reqCtx);
4698
4834
  }
4699
4835
  const text = read.text;
4700
4836
  const r = screenText(text, { status: response.status, headers: headerObject(response.headers) }, reqCtx);
@@ -4702,6 +4838,74 @@ async function createProtection(options = {}) {
4702
4838
  if (r.verdict === "redact") return rebuildResponse(response, r.body, r.headers);
4703
4839
  return response;
4704
4840
  };
4841
+ const headerValueChanged = (was, value) => {
4842
+ const had = was !== void 0 && was !== null;
4843
+ if (value === null || value === void 0) return had;
4844
+ if (!had) return true;
4845
+ if (Array.isArray(value) || Array.isArray(was)) {
4846
+ const a = Array.isArray(was) ? was : [was];
4847
+ const b = Array.isArray(value) ? value : [value];
4848
+ return a.length !== b.length || b.some((item, i) => String(item) !== String(a[i]));
4849
+ }
4850
+ return String(value) !== String(was);
4851
+ };
4852
+ const headersChanged = (before, after) => Object.entries(after).some(([name, value]) => headerValueChanged(before[name], value));
4853
+ const writeHeadHeaderIndex = (args) => {
4854
+ const at = typeof args[1] === "string" ? 2 : 1;
4855
+ const value = args[at];
4856
+ return value !== null && typeof value === "object" ? at : -1;
4857
+ };
4858
+ const writeHeadEntries = (headers) => {
4859
+ if (!Array.isArray(headers)) return Object.entries(headers);
4860
+ if (headers.length && headers.every((entry) => Array.isArray(entry) && entry.length === 2)) {
4861
+ return headers.map(([name, value]) => [String(name), value]);
4862
+ }
4863
+ const entries = [];
4864
+ for (let i = 0; i + 1 < headers.length; i += 2) entries.push([String(headers[i]), headers[i + 1]]);
4865
+ return entries;
4866
+ };
4867
+ const writeHeadHeaderObject = (headers) => {
4868
+ const out = {};
4869
+ for (const [name, value] of writeHeadEntries(headers)) {
4870
+ const key = name.toLowerCase();
4871
+ out[key] = Object.hasOwn(out, key) ? [].concat(out[key], value) : value;
4872
+ }
4873
+ return out;
4874
+ };
4875
+ const rewriteWriteHeadHeaders = (headers, changed) => {
4876
+ const replaced = /* @__PURE__ */ new Set();
4877
+ const entries = [];
4878
+ for (const [name, value] of writeHeadEntries(headers)) {
4879
+ const key = name.toLowerCase();
4880
+ if (!changed.has(key)) {
4881
+ entries.push([name, value]);
4882
+ continue;
4883
+ }
4884
+ if (replaced.has(key)) continue;
4885
+ replaced.add(key);
4886
+ const replacement = changed.get(key);
4887
+ if (replacement === null || replacement === void 0) continue;
4888
+ entries.push([name, replacement]);
4889
+ }
4890
+ if (!Array.isArray(headers)) return Object.fromEntries(entries);
4891
+ const paired = headers.length > 0 && headers.every((entry) => Array.isArray(entry) && entry.length === 2);
4892
+ const expanded = entries.flatMap(
4893
+ ([name, value]) => Array.isArray(value) ? value.map((item) => [name, item]) : [[name, value]]
4894
+ );
4895
+ return paired ? expanded : expanded.flat();
4896
+ };
4897
+ const hardenHeadersOnly = (response, reqCtx) => {
4898
+ try {
4899
+ const meta = { status: response.status, headers: headerObject(response.headers) };
4900
+ const r = screenText("", meta, reqCtx, hardensWithoutBody);
4901
+ if (r.verdict !== "redact" || !r.headers) return response;
4902
+ if (!headersChanged(meta.headers, r.headers)) return response;
4903
+ return rebuildResponse(response, response.body, r.headers);
4904
+ } catch (err) {
4905
+ notify(onError, err, "onError");
4906
+ return response;
4907
+ }
4908
+ };
4705
4909
  const wrapNodeResponse = (res, reqCtx) => {
4706
4910
  const origWrite = res.write.bind(res);
4707
4911
  const origEnd = res.end.bind(res);
@@ -4709,6 +4913,51 @@ async function createProtection(options = {}) {
4709
4913
  let size = 0;
4710
4914
  let overflow = false;
4711
4915
  const MAX = screenCap;
4916
+ let hardened = false;
4917
+ let answeredWithoutBody = false;
4918
+ const hardenBeforeFlush = (effective) => {
4919
+ if (hardened) return null;
4920
+ hardened = true;
4921
+ try {
4922
+ if (res.headersSent || typeof res.setHeader !== "function") return null;
4923
+ const set = typeof res.getHeaders === "function" ? res.getHeaders() : {};
4924
+ const headers = effective && effective.headers ? { ...set, ...effective.headers } : set;
4925
+ const status = effective && effective.status !== void 0 ? effective.status : res.statusCode;
4926
+ const r = screenText("", { status, headers }, reqCtx, hardensWithoutBody);
4927
+ answeredWithoutBody = true;
4928
+ if (r.verdict !== "redact" || !r.headers) return null;
4929
+ const changed = /* @__PURE__ */ new Map();
4930
+ for (const [name, value] of Object.entries(r.headers)) {
4931
+ if (!headerValueChanged(headers[name], value)) continue;
4932
+ changed.set(name.toLowerCase(), value);
4933
+ try {
4934
+ if (value === null || value === void 0) res.removeHeader?.(name);
4935
+ else res.setHeader(name, value);
4936
+ } catch {
4937
+ }
4938
+ }
4939
+ return changed.size ? changed : null;
4940
+ } catch (err) {
4941
+ notify(onError, err, "onError");
4942
+ return null;
4943
+ }
4944
+ };
4945
+ const stillToAnswer = (rule) => !hardensWithoutBody(rule);
4946
+ if (typeof res.writeHead === "function") {
4947
+ const origWriteHead = res.writeHead.bind(res);
4948
+ res.writeHead = function(...args) {
4949
+ const at = writeHeadHeaderIndex(args);
4950
+ const given = at === -1 ? null : args[at];
4951
+ const changed = hardenBeforeFlush({
4952
+ status: typeof args[0] === "number" ? args[0] : void 0,
4953
+ headers: given ? writeHeadHeaderObject(given) : null
4954
+ });
4955
+ if (!changed || !given) return origWriteHead(...args);
4956
+ const rewritten = [...args];
4957
+ rewritten[at] = rewriteWriteHeadHeaders(given, changed);
4958
+ return origWriteHead(...rewritten);
4959
+ };
4960
+ }
4712
4961
  const collect = (chunk, enc) => {
4713
4962
  if (chunk == null) return;
4714
4963
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof enc === "string" ? enc : "utf8");
@@ -4724,6 +4973,7 @@ async function createProtection(options = {}) {
4724
4973
  chunks.push(buf);
4725
4974
  };
4726
4975
  res.write = function(chunk, enc, cb) {
4976
+ hardenBeforeFlush();
4727
4977
  if (overflow) return origWrite(chunk, enc, cb);
4728
4978
  collect(chunk, enc);
4729
4979
  if (typeof enc === "function") enc();
@@ -4731,6 +4981,7 @@ async function createProtection(options = {}) {
4731
4981
  return true;
4732
4982
  };
4733
4983
  res.end = function(chunk, enc, cb) {
4984
+ hardenBeforeFlush();
4734
4985
  if (typeof chunk === "function") {
4735
4986
  cb = chunk;
4736
4987
  chunk = void 0;
@@ -4757,7 +5008,12 @@ async function createProtection(options = {}) {
4757
5008
  const text = buffer.toString("utf8");
4758
5009
  let r;
4759
5010
  try {
4760
- r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} }, reqCtx);
5011
+ r = screenText(
5012
+ text,
5013
+ { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} },
5014
+ reqCtx,
5015
+ answeredWithoutBody ? stillToAnswer : void 0
5016
+ );
4761
5017
  } catch (err) {
4762
5018
  notify(onError, err, "onError");
4763
5019
  for (const c of chunks) origWrite(c);
@@ -4807,15 +5063,6 @@ async function createProtection(options = {}) {
4807
5063
  const allow = new Set((options.allowHosts ?? []).map((h) => String(h).toLowerCase()));
4808
5064
  const egressShouldBlock = (url, host, method) => {
4809
5065
  if (host && allow.has(host.toLowerCase())) return false;
4810
- let result;
4811
- try {
4812
- result = egressEngine.evaluate({ _egress: { url, host, method } });
4813
- } catch (err) {
4814
- notify(onError, err, "onError");
4815
- return false;
4816
- }
4817
- if (!result.blocked) return false;
4818
- const egressMode = ruleMode(result.rule);
4819
5066
  let egressPath = null;
4820
5067
  try {
4821
5068
  const u = new URL(url);
@@ -4823,17 +5070,39 @@ async function createProtection(options = {}) {
4823
5070
  } catch {
4824
5071
  egressPath = typeof url === "string" ? url : null;
4825
5072
  }
4826
- onDetect({
4827
- phase: "egress",
4828
- mode: egressMode,
4829
- category: result.rule?.category,
4830
- rule: result.rule,
4831
- message: result.message,
4832
- method: typeof method === "string" ? method : null,
4833
- path: egressPath,
4834
- capture: evidenceFrom(result)
4835
- });
4836
- return egressMode === "block";
5073
+ const egressEvent = mintEvent();
5074
+ let block = false;
5075
+ for (const { rule, engine: re } of egressRuleSet) {
5076
+ let result;
5077
+ try {
5078
+ result = re.evaluate({ _egress: { url, host, method } });
5079
+ } catch (err) {
5080
+ notify(onError, err, "onError");
5081
+ continue;
5082
+ }
5083
+ if (!result.blocked) continue;
5084
+ const egressMode = ruleMode(rule);
5085
+ onDetect({
5086
+ phase: "egress",
5087
+ mode: egressMode,
5088
+ category: rule?.category,
5089
+ rule,
5090
+ message: result.message,
5091
+ // One identity for this outbound call, shared by every rule that matches it — the egress phase
5092
+ // evaluates all of them rather than stopping at the first, so without this two rules refusing one
5093
+ // call would count as two calls refused.
5094
+ //
5095
+ // Its own identity, not the identity of whatever request the application was serving when it made
5096
+ // the call. An outbound attempt is a thing that happened in its own right, and a call made outside
5097
+ // any request — a job, a timer — has no request to belong to.
5098
+ event: egressEvent,
5099
+ method: typeof method === "string" ? method : null,
5100
+ path: egressPath,
5101
+ capture: evidenceFrom(result)
5102
+ });
5103
+ if (egressMode === "block") block = true;
5104
+ }
5105
+ return block;
4837
5106
  };
4838
5107
  const protection = {
4839
5108
  get mode() {
@@ -4902,7 +5171,7 @@ async function createProtection(options = {}) {
4902
5171
  if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
4903
5172
  next();
4904
5173
  },
4905
- nodeRequestMeta(req, client)
5174
+ () => nodeRequestMeta(req, client)
4906
5175
  );
4907
5176
  };
4908
5177
  },
@@ -4966,7 +5235,7 @@ async function createProtection(options = {}) {
4966
5235
  if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, shaped?._clientIp));
4967
5236
  next();
4968
5237
  },
4969
- nodeRequestMeta(req, shaped?._clientIp)
5238
+ () => nodeRequestMeta(req, shaped?._clientIp)
4970
5239
  );
4971
5240
  }
4972
5241
  }
@@ -5440,11 +5709,37 @@ function defaultOnDetect({ phase, mode, category, rule, message }) {
5440
5709
  const tag = mode === "block" ? "BLOCK" : "DETECT (dry-run)";
5441
5710
  console.warn(`[patchstack] ${tag} phase=${phase ?? "request"} category=${category ?? "?"} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
5442
5711
  }
5712
+ var eventIdentities = /* @__PURE__ */ new WeakMap();
5713
+ function mintEvent() {
5714
+ try {
5715
+ const bytes = globalThis.crypto?.getRandomValues?.(new Uint8Array(16));
5716
+ if (bytes) return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
5717
+ } catch {
5718
+ }
5719
+ try {
5720
+ let hex = Date.now().toString(16);
5721
+ while (hex.length < 32) hex += Math.floor(Math.random() * 4294967295).toString(16).padStart(8, "0");
5722
+ return hex.slice(0, 32);
5723
+ } catch {
5724
+ return null;
5725
+ }
5726
+ }
5727
+ function eventFor(request) {
5728
+ if (request === null || typeof request !== "object") return mintEvent();
5729
+ const existing = eventIdentities.get(request);
5730
+ if (existing !== void 0) return existing;
5731
+ const minted = mintEvent();
5732
+ if (minted !== null) eventIdentities.set(request, minted);
5733
+ return minted;
5734
+ }
5443
5735
  function requestMetaFromContext(reqCtx) {
5444
5736
  if (!reqCtx) return {};
5445
5737
  const client = reqCtx._clientIp ?? { ip: null, source: "unavailable" };
5446
5738
  return {
5447
5739
  method: reqCtx.method ?? null,
5740
+ // The originating request's identity, so a response detection and the request detection for the same
5741
+ // request are one event rather than two. Asked for here, which is inside a detection being raised.
5742
+ event: eventFor(reqCtx._eventOf),
5448
5743
  // Path AND query. The reporter is what drops the query's VALUES, keeping its parameter names, so
5449
5744
  // trimming it here would leave a Fetch or response detection unable to say what was requested.
5450
5745
  path: typeof reqCtx.originalUrl === "string" ? reqCtx.originalUrl : null,
@@ -5468,7 +5763,7 @@ function requestMeta(shaped, request) {
5468
5763
  method = request.method ?? null;
5469
5764
  userAgent = request.headers?.get?.("user-agent") ?? null;
5470
5765
  }
5471
- return { method, path, ip: client.ip, clientIpSource: client.source, userAgent };
5766
+ return { method, path, ip: client.ip, clientIpSource: client.source, userAgent, event: eventFor(request) };
5472
5767
  }
5473
5768
  function nodeRequestMeta(req, client) {
5474
5769
  if (!req) return {};
@@ -5482,7 +5777,8 @@ function nodeRequestMeta(req, client) {
5482
5777
  // verified, and a second derivation could disagree with the one the engine evaluated.
5483
5778
  ip: resolved.ip,
5484
5779
  clientIpSource: resolved.source,
5485
- userAgent: typeof ua === "string" ? ua : Array.isArray(ua) ? ua[0] : null
5780
+ userAgent: typeof ua === "string" ? ua : Array.isArray(ua) ? ua[0] : null,
5781
+ event: eventFor(req)
5486
5782
  };
5487
5783
  }
5488
5784
  export {