@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.
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 };
2197
2193
  }
2194
+ function ruleParameters(rule) {
2195
+ return readRuleParameters(rule).parameters;
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.)
@@ -3558,21 +3623,6 @@ function unattended(timer) {
3558
3623
  if (timer && typeof timer.unref === "function") timer.unref();
3559
3624
  return timer;
3560
3625
  }
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
3626
  function revisionOf(rule) {
3577
3627
  const revision = rule?.source_revision;
3578
3628
  if (typeof revision === "string" && revision !== "") return revision;
@@ -3873,7 +3923,7 @@ function createDetectionReporter(opts) {
3873
3923
  const capture = boundCapture(detection.capture);
3874
3924
  const rawRoute = routeOf(detection.path);
3875
3925
  const route = typeof rawRoute === "string" ? capText(rawRoute, MAX_ROUTE_CHARS) : { value: rawRoute, truncated: false };
3876
- const allParameters = ruleParameters(detection.rule);
3926
+ const { parameters: allParameters, complete: sawEveryParameter } = readRuleParameters(detection.rule);
3877
3927
  const parameters = allParameters.slice(0, MAX_PARAMETERS).map((name) => capText(name, MAX_PARAMETER_CHARS));
3878
3928
  const truncated = [];
3879
3929
  if (route.truncated) truncated.push("route");
@@ -3882,7 +3932,7 @@ function createDetectionReporter(opts) {
3882
3932
  if (query.total > queryKeys.length || queryKeys.some((entry) => entry.truncated)) {
3883
3933
  truncated.push("query_keys");
3884
3934
  }
3885
- if (allParameters.length > MAX_PARAMETERS || parameters.some((entry) => entry.truncated)) {
3935
+ if (!sawEveryParameter || allParameters.length > MAX_PARAMETERS || parameters.some((entry) => entry.truncated)) {
3886
3936
  truncated.push("parameters");
3887
3937
  }
3888
3938
  const id = capText(String(ruleId), MAX_IDENTIFIER_CHARS);
@@ -3903,7 +3953,10 @@ function createDetectionReporter(opts) {
3903
3953
  ...truncated.length > 0 ? { truncated } : {},
3904
3954
  // Only when parameters were actually left out. Reporting a total because some OTHER field was
3905
3955
  // shortened states that parameters were omitted when none were.
3906
- ...truncated.includes("parameters") ? { parameters_total: allParameters.length } : {},
3956
+ // Only when the walk saw the whole rule. Shortened by the cap, this is the true total; cut short
3957
+ // by the nesting bound, it is the count of what was seen, and sending that as the total states a
3958
+ // size nobody established. The mark travels either way, so a short list is always marked as one.
3959
+ ...truncated.includes("parameters") && sawEveryParameter ? { parameters_total: allParameters.length } : {},
3907
3960
  method: method === null ? null : method.value,
3908
3961
  // The rest of the URL, as names only. `route` is the path; together they say what was requested
3909
3962
  // without saying what was in it.
@@ -4083,6 +4136,19 @@ function reportingState(input) {
4083
4136
  return { state: "on", reports: true };
4084
4137
  }
4085
4138
 
4139
+ // src/protect/response-hardening.js
4140
+ var BODY_INDEPENDENT = Object.freeze(["response.status", "response.headers"]);
4141
+ var BODY_INDEPENDENT_PREFIX = "response.header.";
4142
+ var HEADER_ACTIONS = Object.freeze(["set-header", "remove-header", "harden-cookie"]);
4143
+ function hardensWithoutBody(rule) {
4144
+ if (!rule || !HEADER_ACTIONS.includes(rule.action)) return false;
4145
+ const { parameters, complete } = readRuleParameters(rule);
4146
+ if (!complete) return false;
4147
+ return parameters.every(
4148
+ (parameter) => BODY_INDEPENDENT.includes(parameter) || parameter.startsWith(BODY_INDEPENDENT_PREFIX)
4149
+ );
4150
+ }
4151
+
4086
4152
  // src/protect/firewall-log.js
4087
4153
  var DEFAULT_API_BASE = "https://api.patchstack.com";
4088
4154
  var STOP_BUDGET_MS2 = 5e3;
@@ -4466,7 +4532,7 @@ async function createProtection(options = {}) {
4466
4532
  let screenCap;
4467
4533
  let engine;
4468
4534
  let responseRuleSet;
4469
- let egressEngine;
4535
+ let egressRuleSet;
4470
4536
  const applyBundle = (delivered) => {
4471
4537
  const incoming = delivered.firewall ?? [];
4472
4538
  requestRules = byPhase(incoming, "request");
@@ -4493,7 +4559,10 @@ async function createProtection(options = {}) {
4493
4559
  // the common case — cutting CPU/latency and shrinking the regex/ReDoS surface. Case-insensitive.
4494
4560
  prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
4495
4561
  }));
4496
- egressEngine = new RuleEngine({ firewall: egressRules, onError });
4562
+ egressRuleSet = egressRules.map((rule) => ({
4563
+ rule,
4564
+ engine: new RuleEngine({ firewall: [rule], onError })
4565
+ }));
4497
4566
  };
4498
4567
  applyBundle(bundle);
4499
4568
  const skipCounts = /* @__PURE__ */ Object.create(null);
@@ -4541,12 +4610,13 @@ async function createProtection(options = {}) {
4541
4610
  });
4542
4611
  return effectiveMode === "block" ? block() : allow2();
4543
4612
  };
4544
- const screenText = (text, meta, reqCtx) => {
4613
+ const screenText = (text, meta, reqCtx, only) => {
4545
4614
  let blockRule = null;
4546
4615
  const redactions = [];
4547
4616
  const headerMutations = [];
4548
4617
  let lowerText = null;
4549
4618
  for (const { rule, engine: re, redactors, prefilter, mutatedSpan } of responseRuleSet) {
4619
+ if (only && !only(rule)) continue;
4550
4620
  if (prefilter) {
4551
4621
  if (lowerText === null) lowerText = text.toLowerCase();
4552
4622
  if (!prefilter.some((p) => lowerText.includes(p))) continue;
@@ -4694,7 +4764,8 @@ async function createProtection(options = {}) {
4694
4764
  const read = await readTextResponse(response, screenCap);
4695
4765
  if (read.skip) {
4696
4766
  if (read.skip !== "not-a-response") recordSkip("response", read.skip, { status: response?.status });
4697
- return response;
4767
+ if (read.skip === "not-a-response") return response;
4768
+ return hardenHeadersOnly(response, reqCtx);
4698
4769
  }
4699
4770
  const text = read.text;
4700
4771
  const r = screenText(text, { status: response.status, headers: headerObject(response.headers) }, reqCtx);
@@ -4702,6 +4773,74 @@ async function createProtection(options = {}) {
4702
4773
  if (r.verdict === "redact") return rebuildResponse(response, r.body, r.headers);
4703
4774
  return response;
4704
4775
  };
4776
+ const headerValueChanged = (was, value) => {
4777
+ const had = was !== void 0 && was !== null;
4778
+ if (value === null || value === void 0) return had;
4779
+ if (!had) return true;
4780
+ if (Array.isArray(value) || Array.isArray(was)) {
4781
+ const a = Array.isArray(was) ? was : [was];
4782
+ const b = Array.isArray(value) ? value : [value];
4783
+ return a.length !== b.length || b.some((item, i) => String(item) !== String(a[i]));
4784
+ }
4785
+ return String(value) !== String(was);
4786
+ };
4787
+ const headersChanged = (before, after) => Object.entries(after).some(([name, value]) => headerValueChanged(before[name], value));
4788
+ const writeHeadHeaderIndex = (args) => {
4789
+ const at = typeof args[1] === "string" ? 2 : 1;
4790
+ const value = args[at];
4791
+ return value !== null && typeof value === "object" ? at : -1;
4792
+ };
4793
+ const writeHeadEntries = (headers) => {
4794
+ if (!Array.isArray(headers)) return Object.entries(headers);
4795
+ if (headers.length && headers.every((entry) => Array.isArray(entry) && entry.length === 2)) {
4796
+ return headers.map(([name, value]) => [String(name), value]);
4797
+ }
4798
+ const entries = [];
4799
+ for (let i = 0; i + 1 < headers.length; i += 2) entries.push([String(headers[i]), headers[i + 1]]);
4800
+ return entries;
4801
+ };
4802
+ const writeHeadHeaderObject = (headers) => {
4803
+ const out = {};
4804
+ for (const [name, value] of writeHeadEntries(headers)) {
4805
+ const key = name.toLowerCase();
4806
+ out[key] = Object.hasOwn(out, key) ? [].concat(out[key], value) : value;
4807
+ }
4808
+ return out;
4809
+ };
4810
+ const rewriteWriteHeadHeaders = (headers, changed) => {
4811
+ const replaced = /* @__PURE__ */ new Set();
4812
+ const entries = [];
4813
+ for (const [name, value] of writeHeadEntries(headers)) {
4814
+ const key = name.toLowerCase();
4815
+ if (!changed.has(key)) {
4816
+ entries.push([name, value]);
4817
+ continue;
4818
+ }
4819
+ if (replaced.has(key)) continue;
4820
+ replaced.add(key);
4821
+ const replacement = changed.get(key);
4822
+ if (replacement === null || replacement === void 0) continue;
4823
+ entries.push([name, replacement]);
4824
+ }
4825
+ if (!Array.isArray(headers)) return Object.fromEntries(entries);
4826
+ const paired = headers.length > 0 && headers.every((entry) => Array.isArray(entry) && entry.length === 2);
4827
+ const expanded = entries.flatMap(
4828
+ ([name, value]) => Array.isArray(value) ? value.map((item) => [name, item]) : [[name, value]]
4829
+ );
4830
+ return paired ? expanded : expanded.flat();
4831
+ };
4832
+ const hardenHeadersOnly = (response, reqCtx) => {
4833
+ try {
4834
+ const meta = { status: response.status, headers: headerObject(response.headers) };
4835
+ const r = screenText("", meta, reqCtx, hardensWithoutBody);
4836
+ if (r.verdict !== "redact" || !r.headers) return response;
4837
+ if (!headersChanged(meta.headers, r.headers)) return response;
4838
+ return rebuildResponse(response, response.body, r.headers);
4839
+ } catch (err) {
4840
+ notify(onError, err, "onError");
4841
+ return response;
4842
+ }
4843
+ };
4705
4844
  const wrapNodeResponse = (res, reqCtx) => {
4706
4845
  const origWrite = res.write.bind(res);
4707
4846
  const origEnd = res.end.bind(res);
@@ -4709,6 +4848,51 @@ async function createProtection(options = {}) {
4709
4848
  let size = 0;
4710
4849
  let overflow = false;
4711
4850
  const MAX = screenCap;
4851
+ let hardened = false;
4852
+ let answeredWithoutBody = false;
4853
+ const hardenBeforeFlush = (effective) => {
4854
+ if (hardened) return null;
4855
+ hardened = true;
4856
+ try {
4857
+ if (res.headersSent || typeof res.setHeader !== "function") return null;
4858
+ const set = typeof res.getHeaders === "function" ? res.getHeaders() : {};
4859
+ const headers = effective && effective.headers ? { ...set, ...effective.headers } : set;
4860
+ const status = effective && effective.status !== void 0 ? effective.status : res.statusCode;
4861
+ const r = screenText("", { status, headers }, reqCtx, hardensWithoutBody);
4862
+ answeredWithoutBody = true;
4863
+ if (r.verdict !== "redact" || !r.headers) return null;
4864
+ const changed = /* @__PURE__ */ new Map();
4865
+ for (const [name, value] of Object.entries(r.headers)) {
4866
+ if (!headerValueChanged(headers[name], value)) continue;
4867
+ changed.set(name.toLowerCase(), value);
4868
+ try {
4869
+ if (value === null || value === void 0) res.removeHeader?.(name);
4870
+ else res.setHeader(name, value);
4871
+ } catch {
4872
+ }
4873
+ }
4874
+ return changed.size ? changed : null;
4875
+ } catch (err) {
4876
+ notify(onError, err, "onError");
4877
+ return null;
4878
+ }
4879
+ };
4880
+ const stillToAnswer = (rule) => !hardensWithoutBody(rule);
4881
+ if (typeof res.writeHead === "function") {
4882
+ const origWriteHead = res.writeHead.bind(res);
4883
+ res.writeHead = function(...args) {
4884
+ const at = writeHeadHeaderIndex(args);
4885
+ const given = at === -1 ? null : args[at];
4886
+ const changed = hardenBeforeFlush({
4887
+ status: typeof args[0] === "number" ? args[0] : void 0,
4888
+ headers: given ? writeHeadHeaderObject(given) : null
4889
+ });
4890
+ if (!changed || !given) return origWriteHead(...args);
4891
+ const rewritten = [...args];
4892
+ rewritten[at] = rewriteWriteHeadHeaders(given, changed);
4893
+ return origWriteHead(...rewritten);
4894
+ };
4895
+ }
4712
4896
  const collect = (chunk, enc) => {
4713
4897
  if (chunk == null) return;
4714
4898
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof enc === "string" ? enc : "utf8");
@@ -4724,6 +4908,7 @@ async function createProtection(options = {}) {
4724
4908
  chunks.push(buf);
4725
4909
  };
4726
4910
  res.write = function(chunk, enc, cb) {
4911
+ hardenBeforeFlush();
4727
4912
  if (overflow) return origWrite(chunk, enc, cb);
4728
4913
  collect(chunk, enc);
4729
4914
  if (typeof enc === "function") enc();
@@ -4731,6 +4916,7 @@ async function createProtection(options = {}) {
4731
4916
  return true;
4732
4917
  };
4733
4918
  res.end = function(chunk, enc, cb) {
4919
+ hardenBeforeFlush();
4734
4920
  if (typeof chunk === "function") {
4735
4921
  cb = chunk;
4736
4922
  chunk = void 0;
@@ -4757,7 +4943,12 @@ async function createProtection(options = {}) {
4757
4943
  const text = buffer.toString("utf8");
4758
4944
  let r;
4759
4945
  try {
4760
- r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} }, reqCtx);
4946
+ r = screenText(
4947
+ text,
4948
+ { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} },
4949
+ reqCtx,
4950
+ answeredWithoutBody ? stillToAnswer : void 0
4951
+ );
4761
4952
  } catch (err) {
4762
4953
  notify(onError, err, "onError");
4763
4954
  for (const c of chunks) origWrite(c);
@@ -4807,15 +4998,6 @@ async function createProtection(options = {}) {
4807
4998
  const allow = new Set((options.allowHosts ?? []).map((h) => String(h).toLowerCase()));
4808
4999
  const egressShouldBlock = (url, host, method) => {
4809
5000
  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
5001
  let egressPath = null;
4820
5002
  try {
4821
5003
  const u = new URL(url);
@@ -4823,17 +5005,30 @@ async function createProtection(options = {}) {
4823
5005
  } catch {
4824
5006
  egressPath = typeof url === "string" ? url : null;
4825
5007
  }
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";
5008
+ let block = false;
5009
+ for (const { rule, engine: re } of egressRuleSet) {
5010
+ let result;
5011
+ try {
5012
+ result = re.evaluate({ _egress: { url, host, method } });
5013
+ } catch (err) {
5014
+ notify(onError, err, "onError");
5015
+ continue;
5016
+ }
5017
+ if (!result.blocked) continue;
5018
+ const egressMode = ruleMode(rule);
5019
+ onDetect({
5020
+ phase: "egress",
5021
+ mode: egressMode,
5022
+ category: rule?.category,
5023
+ rule,
5024
+ message: result.message,
5025
+ method: typeof method === "string" ? method : null,
5026
+ path: egressPath,
5027
+ capture: evidenceFrom(result)
5028
+ });
5029
+ if (egressMode === "block") block = true;
5030
+ }
5031
+ return block;
4837
5032
  };
4838
5033
  const protection = {
4839
5034
  get mode() {