@patchstack/connect 0.3.33 → 0.4.0

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 };
2229
2225
  }
2226
+ function ruleParameters(rule) {
2227
+ return readRuleParameters(rule).parameters;
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.)
@@ -3672,21 +3737,6 @@ function unattended(timer) {
3672
3737
  if (timer && typeof timer.unref === "function") timer.unref();
3673
3738
  return timer;
3674
3739
  }
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
3740
  function revisionOf(rule) {
3691
3741
  const revision = rule?.source_revision;
3692
3742
  if (typeof revision === "string" && revision !== "") return revision;
@@ -3987,7 +4037,7 @@ function createDetectionReporter(opts) {
3987
4037
  const capture = boundCapture(detection.capture);
3988
4038
  const rawRoute = routeOf(detection.path);
3989
4039
  const route = typeof rawRoute === "string" ? capText(rawRoute, MAX_ROUTE_CHARS) : { value: rawRoute, truncated: false };
3990
- const allParameters = ruleParameters(detection.rule);
4040
+ const { parameters: allParameters, complete: sawEveryParameter } = readRuleParameters(detection.rule);
3991
4041
  const parameters = allParameters.slice(0, MAX_PARAMETERS).map((name) => capText(name, MAX_PARAMETER_CHARS));
3992
4042
  const truncated = [];
3993
4043
  if (route.truncated) truncated.push("route");
@@ -3996,7 +4046,7 @@ function createDetectionReporter(opts) {
3996
4046
  if (query.total > queryKeys.length || queryKeys.some((entry) => entry.truncated)) {
3997
4047
  truncated.push("query_keys");
3998
4048
  }
3999
- if (allParameters.length > MAX_PARAMETERS || parameters.some((entry) => entry.truncated)) {
4049
+ if (!sawEveryParameter || allParameters.length > MAX_PARAMETERS || parameters.some((entry) => entry.truncated)) {
4000
4050
  truncated.push("parameters");
4001
4051
  }
4002
4052
  const id = capText(String(ruleId), MAX_IDENTIFIER_CHARS);
@@ -4017,7 +4067,10 @@ function createDetectionReporter(opts) {
4017
4067
  ...truncated.length > 0 ? { truncated } : {},
4018
4068
  // Only when parameters were actually left out. Reporting a total because some OTHER field was
4019
4069
  // shortened states that parameters were omitted when none were.
4020
- ...truncated.includes("parameters") ? { parameters_total: allParameters.length } : {},
4070
+ // Only when the walk saw the whole rule. Shortened by the cap, this is the true total; cut short
4071
+ // by the nesting bound, it is the count of what was seen, and sending that as the total states a
4072
+ // size nobody established. The mark travels either way, so a short list is always marked as one.
4073
+ ...truncated.includes("parameters") && sawEveryParameter ? { parameters_total: allParameters.length } : {},
4021
4074
  method: method === null ? null : method.value,
4022
4075
  // The rest of the URL, as names only. `route` is the path; together they say what was requested
4023
4076
  // without saying what was in it.
@@ -4197,6 +4250,19 @@ function reportingState(input) {
4197
4250
  return { state: "on", reports: true };
4198
4251
  }
4199
4252
 
4253
+ // src/protect/response-hardening.js
4254
+ var BODY_INDEPENDENT = Object.freeze(["response.status", "response.headers"]);
4255
+ var BODY_INDEPENDENT_PREFIX = "response.header.";
4256
+ var HEADER_ACTIONS = Object.freeze(["set-header", "remove-header", "harden-cookie"]);
4257
+ function hardensWithoutBody(rule) {
4258
+ if (!rule || !HEADER_ACTIONS.includes(rule.action)) return false;
4259
+ const { parameters, complete } = readRuleParameters(rule);
4260
+ if (!complete) return false;
4261
+ return parameters.every(
4262
+ (parameter) => BODY_INDEPENDENT.includes(parameter) || parameter.startsWith(BODY_INDEPENDENT_PREFIX)
4263
+ );
4264
+ }
4265
+
4200
4266
  // src/protect/firewall-log.js
4201
4267
  var DEFAULT_API_BASE = "https://api.patchstack.com";
4202
4268
  var STOP_BUDGET_MS2 = 5e3;
@@ -4580,7 +4646,7 @@ async function createProtection(options = {}) {
4580
4646
  let screenCap;
4581
4647
  let engine;
4582
4648
  let responseRuleSet;
4583
- let egressEngine;
4649
+ let egressRuleSet;
4584
4650
  const applyBundle = (delivered) => {
4585
4651
  const incoming = delivered.firewall ?? [];
4586
4652
  requestRules = byPhase(incoming, "request");
@@ -4607,7 +4673,10 @@ async function createProtection(options = {}) {
4607
4673
  // the common case — cutting CPU/latency and shrinking the regex/ReDoS surface. Case-insensitive.
4608
4674
  prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
4609
4675
  }));
4610
- egressEngine = new RuleEngine({ firewall: egressRules, onError });
4676
+ egressRuleSet = egressRules.map((rule) => ({
4677
+ rule,
4678
+ engine: new RuleEngine({ firewall: [rule], onError })
4679
+ }));
4611
4680
  };
4612
4681
  applyBundle(bundle);
4613
4682
  const skipCounts = /* @__PURE__ */ Object.create(null);
@@ -4655,12 +4724,13 @@ async function createProtection(options = {}) {
4655
4724
  });
4656
4725
  return effectiveMode === "block" ? block() : allow2();
4657
4726
  };
4658
- const screenText = (text, meta, reqCtx) => {
4727
+ const screenText = (text, meta, reqCtx, only) => {
4659
4728
  let blockRule = null;
4660
4729
  const redactions = [];
4661
4730
  const headerMutations = [];
4662
4731
  let lowerText = null;
4663
4732
  for (const { rule, engine: re, redactors, prefilter, mutatedSpan } of responseRuleSet) {
4733
+ if (only && !only(rule)) continue;
4664
4734
  if (prefilter) {
4665
4735
  if (lowerText === null) lowerText = text.toLowerCase();
4666
4736
  if (!prefilter.some((p) => lowerText.includes(p))) continue;
@@ -4808,7 +4878,8 @@ async function createProtection(options = {}) {
4808
4878
  const read = await readTextResponse(response, screenCap);
4809
4879
  if (read.skip) {
4810
4880
  if (read.skip !== "not-a-response") recordSkip("response", read.skip, { status: response?.status });
4811
- return response;
4881
+ if (read.skip === "not-a-response") return response;
4882
+ return hardenHeadersOnly(response, reqCtx);
4812
4883
  }
4813
4884
  const text = read.text;
4814
4885
  const r = screenText(text, { status: response.status, headers: headerObject(response.headers) }, reqCtx);
@@ -4816,6 +4887,74 @@ async function createProtection(options = {}) {
4816
4887
  if (r.verdict === "redact") return rebuildResponse(response, r.body, r.headers);
4817
4888
  return response;
4818
4889
  };
4890
+ const headerValueChanged = (was, value) => {
4891
+ const had = was !== void 0 && was !== null;
4892
+ if (value === null || value === void 0) return had;
4893
+ if (!had) return true;
4894
+ if (Array.isArray(value) || Array.isArray(was)) {
4895
+ const a = Array.isArray(was) ? was : [was];
4896
+ const b = Array.isArray(value) ? value : [value];
4897
+ return a.length !== b.length || b.some((item, i) => String(item) !== String(a[i]));
4898
+ }
4899
+ return String(value) !== String(was);
4900
+ };
4901
+ const headersChanged = (before, after) => Object.entries(after).some(([name, value]) => headerValueChanged(before[name], value));
4902
+ const writeHeadHeaderIndex = (args) => {
4903
+ const at = typeof args[1] === "string" ? 2 : 1;
4904
+ const value = args[at];
4905
+ return value !== null && typeof value === "object" ? at : -1;
4906
+ };
4907
+ const writeHeadEntries = (headers) => {
4908
+ if (!Array.isArray(headers)) return Object.entries(headers);
4909
+ if (headers.length && headers.every((entry) => Array.isArray(entry) && entry.length === 2)) {
4910
+ return headers.map(([name, value]) => [String(name), value]);
4911
+ }
4912
+ const entries = [];
4913
+ for (let i = 0; i + 1 < headers.length; i += 2) entries.push([String(headers[i]), headers[i + 1]]);
4914
+ return entries;
4915
+ };
4916
+ const writeHeadHeaderObject = (headers) => {
4917
+ const out = {};
4918
+ for (const [name, value] of writeHeadEntries(headers)) {
4919
+ const key = name.toLowerCase();
4920
+ out[key] = Object.hasOwn(out, key) ? [].concat(out[key], value) : value;
4921
+ }
4922
+ return out;
4923
+ };
4924
+ const rewriteWriteHeadHeaders = (headers, changed) => {
4925
+ const replaced = /* @__PURE__ */ new Set();
4926
+ const entries = [];
4927
+ for (const [name, value] of writeHeadEntries(headers)) {
4928
+ const key = name.toLowerCase();
4929
+ if (!changed.has(key)) {
4930
+ entries.push([name, value]);
4931
+ continue;
4932
+ }
4933
+ if (replaced.has(key)) continue;
4934
+ replaced.add(key);
4935
+ const replacement = changed.get(key);
4936
+ if (replacement === null || replacement === void 0) continue;
4937
+ entries.push([name, replacement]);
4938
+ }
4939
+ if (!Array.isArray(headers)) return Object.fromEntries(entries);
4940
+ const paired = headers.length > 0 && headers.every((entry) => Array.isArray(entry) && entry.length === 2);
4941
+ const expanded = entries.flatMap(
4942
+ ([name, value]) => Array.isArray(value) ? value.map((item) => [name, item]) : [[name, value]]
4943
+ );
4944
+ return paired ? expanded : expanded.flat();
4945
+ };
4946
+ const hardenHeadersOnly = (response, reqCtx) => {
4947
+ try {
4948
+ const meta = { status: response.status, headers: headerObject(response.headers) };
4949
+ const r = screenText("", meta, reqCtx, hardensWithoutBody);
4950
+ if (r.verdict !== "redact" || !r.headers) return response;
4951
+ if (!headersChanged(meta.headers, r.headers)) return response;
4952
+ return rebuildResponse(response, response.body, r.headers);
4953
+ } catch (err) {
4954
+ notify(onError, err, "onError");
4955
+ return response;
4956
+ }
4957
+ };
4819
4958
  const wrapNodeResponse = (res, reqCtx) => {
4820
4959
  const origWrite = res.write.bind(res);
4821
4960
  const origEnd = res.end.bind(res);
@@ -4823,6 +4962,51 @@ async function createProtection(options = {}) {
4823
4962
  let size = 0;
4824
4963
  let overflow = false;
4825
4964
  const MAX = screenCap;
4965
+ let hardened = false;
4966
+ let answeredWithoutBody = false;
4967
+ const hardenBeforeFlush = (effective) => {
4968
+ if (hardened) return null;
4969
+ hardened = true;
4970
+ try {
4971
+ if (res.headersSent || typeof res.setHeader !== "function") return null;
4972
+ const set = typeof res.getHeaders === "function" ? res.getHeaders() : {};
4973
+ const headers = effective && effective.headers ? { ...set, ...effective.headers } : set;
4974
+ const status = effective && effective.status !== void 0 ? effective.status : res.statusCode;
4975
+ const r = screenText("", { status, headers }, reqCtx, hardensWithoutBody);
4976
+ answeredWithoutBody = true;
4977
+ if (r.verdict !== "redact" || !r.headers) return null;
4978
+ const changed = /* @__PURE__ */ new Map();
4979
+ for (const [name, value] of Object.entries(r.headers)) {
4980
+ if (!headerValueChanged(headers[name], value)) continue;
4981
+ changed.set(name.toLowerCase(), value);
4982
+ try {
4983
+ if (value === null || value === void 0) res.removeHeader?.(name);
4984
+ else res.setHeader(name, value);
4985
+ } catch {
4986
+ }
4987
+ }
4988
+ return changed.size ? changed : null;
4989
+ } catch (err) {
4990
+ notify(onError, err, "onError");
4991
+ return null;
4992
+ }
4993
+ };
4994
+ const stillToAnswer = (rule) => !hardensWithoutBody(rule);
4995
+ if (typeof res.writeHead === "function") {
4996
+ const origWriteHead = res.writeHead.bind(res);
4997
+ res.writeHead = function(...args) {
4998
+ const at = writeHeadHeaderIndex(args);
4999
+ const given = at === -1 ? null : args[at];
5000
+ const changed = hardenBeforeFlush({
5001
+ status: typeof args[0] === "number" ? args[0] : void 0,
5002
+ headers: given ? writeHeadHeaderObject(given) : null
5003
+ });
5004
+ if (!changed || !given) return origWriteHead(...args);
5005
+ const rewritten = [...args];
5006
+ rewritten[at] = rewriteWriteHeadHeaders(given, changed);
5007
+ return origWriteHead(...rewritten);
5008
+ };
5009
+ }
4826
5010
  const collect = (chunk, enc) => {
4827
5011
  if (chunk == null) return;
4828
5012
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof enc === "string" ? enc : "utf8");
@@ -4838,6 +5022,7 @@ async function createProtection(options = {}) {
4838
5022
  chunks.push(buf);
4839
5023
  };
4840
5024
  res.write = function(chunk, enc, cb) {
5025
+ hardenBeforeFlush();
4841
5026
  if (overflow) return origWrite(chunk, enc, cb);
4842
5027
  collect(chunk, enc);
4843
5028
  if (typeof enc === "function") enc();
@@ -4845,6 +5030,7 @@ async function createProtection(options = {}) {
4845
5030
  return true;
4846
5031
  };
4847
5032
  res.end = function(chunk, enc, cb) {
5033
+ hardenBeforeFlush();
4848
5034
  if (typeof chunk === "function") {
4849
5035
  cb = chunk;
4850
5036
  chunk = void 0;
@@ -4871,7 +5057,12 @@ async function createProtection(options = {}) {
4871
5057
  const text = buffer.toString("utf8");
4872
5058
  let r;
4873
5059
  try {
4874
- r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} }, reqCtx);
5060
+ r = screenText(
5061
+ text,
5062
+ { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} },
5063
+ reqCtx,
5064
+ answeredWithoutBody ? stillToAnswer : void 0
5065
+ );
4875
5066
  } catch (err) {
4876
5067
  notify(onError, err, "onError");
4877
5068
  for (const c of chunks) origWrite(c);
@@ -4921,15 +5112,6 @@ async function createProtection(options = {}) {
4921
5112
  const allow = new Set((options.allowHosts ?? []).map((h) => String(h).toLowerCase()));
4922
5113
  const egressShouldBlock = (url, host, method) => {
4923
5114
  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
5115
  let egressPath = null;
4934
5116
  try {
4935
5117
  const u = new URL(url);
@@ -4937,17 +5119,30 @@ async function createProtection(options = {}) {
4937
5119
  } catch {
4938
5120
  egressPath = typeof url === "string" ? url : null;
4939
5121
  }
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";
5122
+ let block = false;
5123
+ for (const { rule, engine: re } of egressRuleSet) {
5124
+ let result;
5125
+ try {
5126
+ result = re.evaluate({ _egress: { url, host, method } });
5127
+ } catch (err) {
5128
+ notify(onError, err, "onError");
5129
+ continue;
5130
+ }
5131
+ if (!result.blocked) continue;
5132
+ const egressMode = ruleMode(rule);
5133
+ onDetect({
5134
+ phase: "egress",
5135
+ mode: egressMode,
5136
+ category: rule?.category,
5137
+ rule,
5138
+ message: result.message,
5139
+ method: typeof method === "string" ? method : null,
5140
+ path: egressPath,
5141
+ capture: evidenceFrom(result)
5142
+ });
5143
+ if (egressMode === "block") block = true;
5144
+ }
5145
+ return block;
4951
5146
  };
4952
5147
  const protection = {
4953
5148
  get mode() {