@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.cjs CHANGED
@@ -3788,18 +3788,10 @@ function conditionsProblem(conditions, depth = 0) {
3788
3788
  return null;
3789
3789
  }
3790
3790
 
3791
- // src/protect/capture-plan.js
3792
- var NEVER_CAPTURABLE = /* @__PURE__ */ new Set(["response"]);
3793
- var CAPTURE_LIMITS = Object.freeze({
3794
- /** Named and prefix values in one event, together. Raw has its own allowance. */
3795
- capturedValues: 10,
3796
- /** Characters of any single captured value. */
3797
- valueChars: 512,
3798
- /** Resolved values one prefix permission may contribute. */
3799
- prefixValues: 5
3800
- });
3801
- function parametersOf(rule) {
3791
+ // src/protect/rule-parameters.js
3792
+ function readRuleParameters(rule) {
3802
3793
  const out = /* @__PURE__ */ new Set();
3794
+ let complete = true;
3803
3795
  const add = (parameter) => {
3804
3796
  if (typeof parameter === "string" && parameter !== "rules") out.add(parameter);
3805
3797
  };
@@ -3811,7 +3803,11 @@ function parametersOf(rule) {
3811
3803
  add(parameter);
3812
3804
  };
3813
3805
  const walk2 = (conditions, depth) => {
3814
- if (!Array.isArray(conditions) || depth > 20) return;
3806
+ if (!Array.isArray(conditions)) return;
3807
+ if (depth > LIMITS.maxNestingDepth) {
3808
+ complete = false;
3809
+ return;
3810
+ }
3815
3811
  for (const condition of conditions) {
3816
3812
  if (!condition || typeof condition !== "object") continue;
3817
3813
  collect(condition.parameter);
@@ -3819,8 +3815,22 @@ function parametersOf(rule) {
3819
3815
  }
3820
3816
  };
3821
3817
  walk2(rule?.rule_v2, 0);
3822
- return [...out];
3818
+ return { parameters: [...out], complete };
3823
3819
  }
3820
+ function ruleParameters(rule) {
3821
+ return readRuleParameters(rule).parameters;
3822
+ }
3823
+
3824
+ // src/protect/capture-plan.js
3825
+ var NEVER_CAPTURABLE = /* @__PURE__ */ new Set(["response"]);
3826
+ var CAPTURE_LIMITS = Object.freeze({
3827
+ /** Named and prefix values in one event, together. Raw has its own allowance. */
3828
+ capturedValues: 10,
3829
+ /** Characters of any single captured value. */
3830
+ valueChars: 512,
3831
+ /** Resolved values one prefix permission may contribute. */
3832
+ prefixValues: 5
3833
+ });
3824
3834
  function rawOptIn(rule) {
3825
3835
  if (!rule || typeof rule !== "object" || !Object.hasOwn(rule, "capture")) return null;
3826
3836
  const capture = rule.capture;
@@ -3838,7 +3848,7 @@ function derivePlan(rule) {
3838
3848
  const named = /* @__PURE__ */ new Set();
3839
3849
  const prefixes = /* @__PURE__ */ new Set();
3840
3850
  if (enforceableRuleProblem(rule) !== null) return NOTHING;
3841
- const parameters = parametersOf(rule).filter((parameter) => parameterProblem(parameter) === null);
3851
+ const parameters = ruleParameters(rule).filter((parameter) => parameterProblem(parameter) === null);
3842
3852
  for (const parameter of parameters) {
3843
3853
  const dot = parameter.indexOf(".");
3844
3854
  if (dot === -1) continue;
@@ -4550,9 +4560,23 @@ var DEFAULT_RESPONSE_RULES = [
4550
4560
  title: "Private key in response body",
4551
4561
  phase: "response",
4552
4562
  category: "secret-exposure",
4553
- action: "redact",
4563
+ // Withheld: the key material sits after the marker and this pattern does not delimit it.
4564
+ action: "block",
4554
4565
  prefilter: ["PRIVATE KEY"],
4555
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/" } }]
4566
+ // Matches a PEM BEGIN line whose label contains `PRIVATE KEY`, with up to 32 further label
4567
+ // characters — uppercase letters, digits, spaces and hyphens — on either side of it. That covers
4568
+ // the enumerated types (`RSA`, `EC`, `OPENSSH`, `DSA`, `ENCRYPTED`), a label carrying words after
4569
+ // `PRIVATE KEY` (`PGP PRIVATE KEY BLOCK`), a hyphenated or otherwise unlisted type, and a bare
4570
+ // `-----BEGIN PRIVATE KEY-----`. `PUBLIC KEY` and `CERTIFICATE` do not match, and neither does
4571
+ // prose that mentions a private key without a BEGIN line.
4572
+ //
4573
+ // No footer required: a truncated response or an absent END marker does not make the material
4574
+ // above it less of a key. Two bounded character classes rather than a repeated group — a
4575
+ // quantifier inside a quantified group is the shape the engine refuses as a backtracking risk, and
4576
+ // a refused pattern is a rule that never fires.
4577
+ //
4578
+ // A lowercase label, or one longer than 32 characters on either side, is not matched.
4579
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/-----BEGIN [A-Z0-9 -]{0,32}PRIVATE KEY[A-Z0-9 -]{0,32}-----/" } }]
4556
4580
  },
4557
4581
  {
4558
4582
  id: "resp-aws-access-key",
@@ -4629,26 +4653,65 @@ var DEFAULT_RESPONSE_RULES = [
4629
4653
  title: "Database connection string with credentials in response body",
4630
4654
  phase: "response",
4631
4655
  category: "secret-exposure",
4632
- action: "redact",
4656
+ // Withheld. The credentials are only the first half of the disclosure — the host, port, database
4657
+ // name and query name the system they open — and a URI's own grammar admits commas, parentheses
4658
+ // and semicolons, so no end-of-URI character class delimits it in free text without either
4659
+ // stopping inside a real URI or consuming the punctuation around it. The pattern therefore
4660
+ // identifies the URI and the response is withheld rather than partly rewritten.
4661
+ action: "block",
4633
4662
  prefilter: ["mongodb", "postgres", "mysql", "redis", "amqp"],
4634
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[^\\s:@\\/]+:[^\\s:@\\/]+@/i" } }]
4663
+ // A scheme this application connects with, a user, a password and an `@`. Credentials are
4664
+ // required, so a URL without them is not matched.
4665
+ //
4666
+ // The username is the userinfo grammar without `:`; the password is the same grammar with it. Every
4667
+ // other character either class admits may appear raw in a real credential, so a narrower one turns
4668
+ // a live credential into a rule that says nothing — `postgres://user:p;ss@host` is an ordinary DSN.
4669
+ //
4670
+ // The first raw colon separates username from password. Excluding it from the username makes the
4671
+ // separator unambiguous and keeps matching linear; the password continues to admit raw colons. A
4672
+ // username that contains a colon carries it as `%3A`.
4673
+ //
4674
+ // Both classes admitting `:` would leave every colon available as the separator, so a candidate
4675
+ // run with no `@` is re-split at every position. The screening cap does not bound that: a rule may
4676
+ // raise it with `max_bytes` or remove it with `bypass_limit`.
4677
+ //
4678
+ // What terminates a candidate is everything the grammar excludes: whitespace, `/`, `?`, `#`, `@`,
4679
+ // quotes, backslashes, angle and square brackets and braces. That is what keeps the run inside one
4680
+ // value. Expressed as "anything but `:`, `@`, `/` and whitespace" it crosses structure instead: in
4681
+ // `{"docs":"postgres://db.internal","contact":"user@example.com"}` it consumes the closing quote,
4682
+ // the comma and the next key, reaching the `:` and `@` of an unrelated property and withholding a
4683
+ // response that discloses nothing.
4684
+ //
4685
+ // The consequence is deliberate: a run of punctuation-joined text that parses as a credential URI
4686
+ // is treated as one. `postgres://db.internal;contact:admin@example.com` has username
4687
+ // `db.internal;contact`, password `admin` and host `example.com` — indistinguishable from a leak,
4688
+ // so it is withheld. Ordinary prose separates with whitespace, which terminates the candidate.
4689
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[A-Za-z0-9._~%!$&'()*+,;=-]+:[A-Za-z0-9._~%!$&'()*+,;=:-]+@/i" } }]
4635
4690
  },
4636
4691
  {
4637
4692
  id: "resp-stack-trace",
4638
4693
  title: "Node stack trace leaking in response body",
4639
4694
  phase: "response",
4640
4695
  category: "info-exposure",
4641
- action: "redact",
4696
+ // Withheld. A frame is recognised by its shape and the trace has no end the pattern can rely on,
4697
+ // so masking the frames it happens to match leaves the message, the remaining frames and every
4698
+ // path and line number in them.
4699
+ action: "block",
4642
4700
  // No prefilter: a Node stack frame has no single distinctive literal (` at ` is too common to
4643
4701
  // gate on). The pattern is linearly bounded per line, so it runs on every screened body.
4644
- rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\n\\s+at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
4702
+ //
4703
+ // Accepts a real newline and a JSON-escaped one. Most traces reach a client inside a JSON error
4704
+ // body, where the newline is the two characters `\` and `n`.
4705
+ rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/(?:\\n|\\\\n)\\s*at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
4645
4706
  },
4646
4707
  {
4647
4708
  id: "resp-sql-error",
4648
4709
  title: "SQL / ORM error disclosure in response body",
4649
4710
  phase: "response",
4650
4711
  category: "info-exposure",
4651
- action: "redact",
4712
+ // Withheld. The signature is the start of the disclosure: masking `SQLSTATE[23000]` and serving
4713
+ // the constraint name, the column and the offending value discloses the schema anyway.
4714
+ action: "block",
4652
4715
  prefilter: ["SQLSTATE", "Sequelize", "ER_", "ORA-", "PG::", "SQLITE_ERROR", "SQL syntax"],
4653
4716
  rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/(SQLSTATE\\[[0-9A-Z]+\\]|SequelizeDatabaseError|ER_[A-Z_]+|ORA-\\d{5}|PG::[A-Za-z]+Error|SQLITE_ERROR|You have an error in your SQL syntax)/i" } }]
4654
4717
  },
@@ -4657,7 +4720,9 @@ var DEFAULT_RESPONSE_RULES = [
4657
4720
  title: "Backend exception / stack trace disclosure in response body",
4658
4721
  phase: "response",
4659
4722
  category: "info-exposure",
4660
- action: "redact",
4723
+ // Withheld, for the same reason as the trace above: the marker opens the dump and the file names,
4724
+ // line numbers and frames after it are the disclosure.
4725
+ action: "block",
4661
4726
  // Multi-language exception/traceback signatures a normal API response never carries:
4662
4727
  // Python traceback, Java "Exception in thread", .NET System.*Exception, JVM stack frames,
4663
4728
  // Go goroutine dumps. (Node `at fn (file:line:col)` frames are handled by resp-stack-trace.)
@@ -5186,21 +5251,6 @@ function unattended(timer) {
5186
5251
  if (timer && typeof timer.unref === "function") timer.unref();
5187
5252
  return timer;
5188
5253
  }
5189
- function ruleParameters(rule) {
5190
- const out = /* @__PURE__ */ new Set();
5191
- const walk2 = (conditions) => {
5192
- if (!Array.isArray(conditions)) return;
5193
- for (const condition of conditions) {
5194
- if (!condition || typeof condition !== "object") continue;
5195
- if (typeof condition.parameter === "string" && condition.parameter !== "rules") {
5196
- out.add(condition.parameter);
5197
- }
5198
- if (Array.isArray(condition.rules)) walk2(condition.rules);
5199
- }
5200
- };
5201
- walk2(rule?.rule_v2);
5202
- return [...out];
5203
- }
5204
5254
  function revisionOf(rule) {
5205
5255
  const revision = rule?.source_revision;
5206
5256
  if (typeof revision === "string" && revision !== "") return revision;
@@ -5501,7 +5551,7 @@ function createDetectionReporter(opts) {
5501
5551
  const capture = boundCapture(detection.capture);
5502
5552
  const rawRoute = routeOf(detection.path);
5503
5553
  const route = typeof rawRoute === "string" ? capText(rawRoute, MAX_ROUTE_CHARS) : { value: rawRoute, truncated: false };
5504
- const allParameters = ruleParameters(detection.rule);
5554
+ const { parameters: allParameters, complete: sawEveryParameter } = readRuleParameters(detection.rule);
5505
5555
  const parameters = allParameters.slice(0, MAX_PARAMETERS).map((name) => capText(name, MAX_PARAMETER_CHARS));
5506
5556
  const truncated = [];
5507
5557
  if (route.truncated) truncated.push("route");
@@ -5510,7 +5560,7 @@ function createDetectionReporter(opts) {
5510
5560
  if (query.total > queryKeys.length || queryKeys.some((entry) => entry.truncated)) {
5511
5561
  truncated.push("query_keys");
5512
5562
  }
5513
- if (allParameters.length > MAX_PARAMETERS || parameters.some((entry) => entry.truncated)) {
5563
+ if (!sawEveryParameter || allParameters.length > MAX_PARAMETERS || parameters.some((entry) => entry.truncated)) {
5514
5564
  truncated.push("parameters");
5515
5565
  }
5516
5566
  const id = capText(String(ruleId), MAX_IDENTIFIER_CHARS);
@@ -5531,7 +5581,10 @@ function createDetectionReporter(opts) {
5531
5581
  ...truncated.length > 0 ? { truncated } : {},
5532
5582
  // Only when parameters were actually left out. Reporting a total because some OTHER field was
5533
5583
  // shortened states that parameters were omitted when none were.
5534
- ...truncated.includes("parameters") ? { parameters_total: allParameters.length } : {},
5584
+ // Only when the walk saw the whole rule. Shortened by the cap, this is the true total; cut short
5585
+ // by the nesting bound, it is the count of what was seen, and sending that as the total states a
5586
+ // size nobody established. The mark travels either way, so a short list is always marked as one.
5587
+ ...truncated.includes("parameters") && sawEveryParameter ? { parameters_total: allParameters.length } : {},
5535
5588
  method: method === null ? null : method.value,
5536
5589
  // The rest of the URL, as names only. `route` is the path; together they say what was requested
5537
5590
  // without saying what was in it.
@@ -5711,6 +5764,19 @@ function reportingState(input) {
5711
5764
  return { state: "on", reports: true };
5712
5765
  }
5713
5766
 
5767
+ // src/protect/response-hardening.js
5768
+ var BODY_INDEPENDENT = Object.freeze(["response.status", "response.headers"]);
5769
+ var BODY_INDEPENDENT_PREFIX = "response.header.";
5770
+ var HEADER_ACTIONS = Object.freeze(["set-header", "remove-header", "harden-cookie"]);
5771
+ function hardensWithoutBody(rule) {
5772
+ if (!rule || !HEADER_ACTIONS.includes(rule.action)) return false;
5773
+ const { parameters, complete } = readRuleParameters(rule);
5774
+ if (!complete) return false;
5775
+ return parameters.every(
5776
+ (parameter) => BODY_INDEPENDENT.includes(parameter) || parameter.startsWith(BODY_INDEPENDENT_PREFIX)
5777
+ );
5778
+ }
5779
+
5714
5780
  // src/protect/firewall-log.js
5715
5781
  var DEFAULT_API_BASE = "https://api.patchstack.com";
5716
5782
  var STOP_BUDGET_MS2 = 5e3;
@@ -6094,7 +6160,7 @@ async function createProtection(options = {}) {
6094
6160
  let screenCap;
6095
6161
  let engine;
6096
6162
  let responseRuleSet;
6097
- let egressEngine;
6163
+ let egressRuleSet;
6098
6164
  const applyBundle = (delivered) => {
6099
6165
  const incoming = delivered.firewall ?? [];
6100
6166
  requestRules = byPhase(incoming, "request");
@@ -6121,7 +6187,10 @@ async function createProtection(options = {}) {
6121
6187
  // the common case — cutting CPU/latency and shrinking the regex/ReDoS surface. Case-insensitive.
6122
6188
  prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
6123
6189
  }));
6124
- egressEngine = new RuleEngine({ firewall: egressRules, onError });
6190
+ egressRuleSet = egressRules.map((rule) => ({
6191
+ rule,
6192
+ engine: new RuleEngine({ firewall: [rule], onError })
6193
+ }));
6125
6194
  };
6126
6195
  applyBundle(bundle);
6127
6196
  const skipCounts = /* @__PURE__ */ Object.create(null);
@@ -6169,12 +6238,13 @@ async function createProtection(options = {}) {
6169
6238
  });
6170
6239
  return effectiveMode === "block" ? block() : allow2();
6171
6240
  };
6172
- const screenText = (text, meta, reqCtx) => {
6241
+ const screenText = (text, meta, reqCtx, only) => {
6173
6242
  let blockRule = null;
6174
6243
  const redactions = [];
6175
6244
  const headerMutations = [];
6176
6245
  let lowerText = null;
6177
6246
  for (const { rule, engine: re, redactors, prefilter, mutatedSpan } of responseRuleSet) {
6247
+ if (only && !only(rule)) continue;
6178
6248
  if (prefilter) {
6179
6249
  if (lowerText === null) lowerText = text.toLowerCase();
6180
6250
  if (!prefilter.some((p) => lowerText.includes(p))) continue;
@@ -6322,7 +6392,8 @@ async function createProtection(options = {}) {
6322
6392
  const read = await readTextResponse(response, screenCap);
6323
6393
  if (read.skip) {
6324
6394
  if (read.skip !== "not-a-response") recordSkip("response", read.skip, { status: response?.status });
6325
- return response;
6395
+ if (read.skip === "not-a-response") return response;
6396
+ return hardenHeadersOnly(response, reqCtx);
6326
6397
  }
6327
6398
  const text = read.text;
6328
6399
  const r = screenText(text, { status: response.status, headers: headerObject(response.headers) }, reqCtx);
@@ -6330,6 +6401,74 @@ async function createProtection(options = {}) {
6330
6401
  if (r.verdict === "redact") return rebuildResponse(response, r.body, r.headers);
6331
6402
  return response;
6332
6403
  };
6404
+ const headerValueChanged = (was, value) => {
6405
+ const had = was !== void 0 && was !== null;
6406
+ if (value === null || value === void 0) return had;
6407
+ if (!had) return true;
6408
+ if (Array.isArray(value) || Array.isArray(was)) {
6409
+ const a = Array.isArray(was) ? was : [was];
6410
+ const b = Array.isArray(value) ? value : [value];
6411
+ return a.length !== b.length || b.some((item, i) => String(item) !== String(a[i]));
6412
+ }
6413
+ return String(value) !== String(was);
6414
+ };
6415
+ const headersChanged = (before, after) => Object.entries(after).some(([name, value]) => headerValueChanged(before[name], value));
6416
+ const writeHeadHeaderIndex = (args) => {
6417
+ const at = typeof args[1] === "string" ? 2 : 1;
6418
+ const value = args[at];
6419
+ return value !== null && typeof value === "object" ? at : -1;
6420
+ };
6421
+ const writeHeadEntries = (headers) => {
6422
+ if (!Array.isArray(headers)) return Object.entries(headers);
6423
+ if (headers.length && headers.every((entry) => Array.isArray(entry) && entry.length === 2)) {
6424
+ return headers.map(([name, value]) => [String(name), value]);
6425
+ }
6426
+ const entries = [];
6427
+ for (let i = 0; i + 1 < headers.length; i += 2) entries.push([String(headers[i]), headers[i + 1]]);
6428
+ return entries;
6429
+ };
6430
+ const writeHeadHeaderObject = (headers) => {
6431
+ const out = {};
6432
+ for (const [name, value] of writeHeadEntries(headers)) {
6433
+ const key = name.toLowerCase();
6434
+ out[key] = Object.hasOwn(out, key) ? [].concat(out[key], value) : value;
6435
+ }
6436
+ return out;
6437
+ };
6438
+ const rewriteWriteHeadHeaders = (headers, changed) => {
6439
+ const replaced = /* @__PURE__ */ new Set();
6440
+ const entries = [];
6441
+ for (const [name, value] of writeHeadEntries(headers)) {
6442
+ const key = name.toLowerCase();
6443
+ if (!changed.has(key)) {
6444
+ entries.push([name, value]);
6445
+ continue;
6446
+ }
6447
+ if (replaced.has(key)) continue;
6448
+ replaced.add(key);
6449
+ const replacement = changed.get(key);
6450
+ if (replacement === null || replacement === void 0) continue;
6451
+ entries.push([name, replacement]);
6452
+ }
6453
+ if (!Array.isArray(headers)) return Object.fromEntries(entries);
6454
+ const paired = headers.length > 0 && headers.every((entry) => Array.isArray(entry) && entry.length === 2);
6455
+ const expanded = entries.flatMap(
6456
+ ([name, value]) => Array.isArray(value) ? value.map((item) => [name, item]) : [[name, value]]
6457
+ );
6458
+ return paired ? expanded : expanded.flat();
6459
+ };
6460
+ const hardenHeadersOnly = (response, reqCtx) => {
6461
+ try {
6462
+ const meta = { status: response.status, headers: headerObject(response.headers) };
6463
+ const r = screenText("", meta, reqCtx, hardensWithoutBody);
6464
+ if (r.verdict !== "redact" || !r.headers) return response;
6465
+ if (!headersChanged(meta.headers, r.headers)) return response;
6466
+ return rebuildResponse(response, response.body, r.headers);
6467
+ } catch (err) {
6468
+ notify(onError, err, "onError");
6469
+ return response;
6470
+ }
6471
+ };
6333
6472
  const wrapNodeResponse = (res, reqCtx) => {
6334
6473
  const origWrite = res.write.bind(res);
6335
6474
  const origEnd = res.end.bind(res);
@@ -6337,6 +6476,51 @@ async function createProtection(options = {}) {
6337
6476
  let size = 0;
6338
6477
  let overflow = false;
6339
6478
  const MAX = screenCap;
6479
+ let hardened = false;
6480
+ let answeredWithoutBody = false;
6481
+ const hardenBeforeFlush = (effective) => {
6482
+ if (hardened) return null;
6483
+ hardened = true;
6484
+ try {
6485
+ if (res.headersSent || typeof res.setHeader !== "function") return null;
6486
+ const set = typeof res.getHeaders === "function" ? res.getHeaders() : {};
6487
+ const headers = effective && effective.headers ? { ...set, ...effective.headers } : set;
6488
+ const status = effective && effective.status !== void 0 ? effective.status : res.statusCode;
6489
+ const r = screenText("", { status, headers }, reqCtx, hardensWithoutBody);
6490
+ answeredWithoutBody = true;
6491
+ if (r.verdict !== "redact" || !r.headers) return null;
6492
+ const changed = /* @__PURE__ */ new Map();
6493
+ for (const [name, value] of Object.entries(r.headers)) {
6494
+ if (!headerValueChanged(headers[name], value)) continue;
6495
+ changed.set(name.toLowerCase(), value);
6496
+ try {
6497
+ if (value === null || value === void 0) res.removeHeader?.(name);
6498
+ else res.setHeader(name, value);
6499
+ } catch {
6500
+ }
6501
+ }
6502
+ return changed.size ? changed : null;
6503
+ } catch (err) {
6504
+ notify(onError, err, "onError");
6505
+ return null;
6506
+ }
6507
+ };
6508
+ const stillToAnswer = (rule) => !hardensWithoutBody(rule);
6509
+ if (typeof res.writeHead === "function") {
6510
+ const origWriteHead = res.writeHead.bind(res);
6511
+ res.writeHead = function(...args) {
6512
+ const at = writeHeadHeaderIndex(args);
6513
+ const given = at === -1 ? null : args[at];
6514
+ const changed = hardenBeforeFlush({
6515
+ status: typeof args[0] === "number" ? args[0] : void 0,
6516
+ headers: given ? writeHeadHeaderObject(given) : null
6517
+ });
6518
+ if (!changed || !given) return origWriteHead(...args);
6519
+ const rewritten = [...args];
6520
+ rewritten[at] = rewriteWriteHeadHeaders(given, changed);
6521
+ return origWriteHead(...rewritten);
6522
+ };
6523
+ }
6340
6524
  const collect = (chunk, enc) => {
6341
6525
  if (chunk == null) return;
6342
6526
  const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof enc === "string" ? enc : "utf8");
@@ -6352,6 +6536,7 @@ async function createProtection(options = {}) {
6352
6536
  chunks.push(buf);
6353
6537
  };
6354
6538
  res.write = function(chunk, enc, cb) {
6539
+ hardenBeforeFlush();
6355
6540
  if (overflow) return origWrite(chunk, enc, cb);
6356
6541
  collect(chunk, enc);
6357
6542
  if (typeof enc === "function") enc();
@@ -6359,6 +6544,7 @@ async function createProtection(options = {}) {
6359
6544
  return true;
6360
6545
  };
6361
6546
  res.end = function(chunk, enc, cb) {
6547
+ hardenBeforeFlush();
6362
6548
  if (typeof chunk === "function") {
6363
6549
  cb = chunk;
6364
6550
  chunk = void 0;
@@ -6385,7 +6571,12 @@ async function createProtection(options = {}) {
6385
6571
  const text = buffer.toString("utf8");
6386
6572
  let r;
6387
6573
  try {
6388
- r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} }, reqCtx);
6574
+ r = screenText(
6575
+ text,
6576
+ { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} },
6577
+ reqCtx,
6578
+ answeredWithoutBody ? stillToAnswer : void 0
6579
+ );
6389
6580
  } catch (err) {
6390
6581
  notify(onError, err, "onError");
6391
6582
  for (const c of chunks) origWrite(c);
@@ -6435,15 +6626,6 @@ async function createProtection(options = {}) {
6435
6626
  const allow = new Set((options.allowHosts ?? []).map((h) => String(h).toLowerCase()));
6436
6627
  const egressShouldBlock = (url, host, method) => {
6437
6628
  if (host && allow.has(host.toLowerCase())) return false;
6438
- let result;
6439
- try {
6440
- result = egressEngine.evaluate({ _egress: { url, host, method } });
6441
- } catch (err) {
6442
- notify(onError, err, "onError");
6443
- return false;
6444
- }
6445
- if (!result.blocked) return false;
6446
- const egressMode = ruleMode(result.rule);
6447
6629
  let egressPath = null;
6448
6630
  try {
6449
6631
  const u = new URL(url);
@@ -6451,17 +6633,30 @@ async function createProtection(options = {}) {
6451
6633
  } catch {
6452
6634
  egressPath = typeof url === "string" ? url : null;
6453
6635
  }
6454
- onDetect({
6455
- phase: "egress",
6456
- mode: egressMode,
6457
- category: result.rule?.category,
6458
- rule: result.rule,
6459
- message: result.message,
6460
- method: typeof method === "string" ? method : null,
6461
- path: egressPath,
6462
- capture: evidenceFrom(result)
6463
- });
6464
- return egressMode === "block";
6636
+ let block = false;
6637
+ for (const { rule, engine: re } of egressRuleSet) {
6638
+ let result;
6639
+ try {
6640
+ result = re.evaluate({ _egress: { url, host, method } });
6641
+ } catch (err) {
6642
+ notify(onError, err, "onError");
6643
+ continue;
6644
+ }
6645
+ if (!result.blocked) continue;
6646
+ const egressMode = ruleMode(rule);
6647
+ onDetect({
6648
+ phase: "egress",
6649
+ mode: egressMode,
6650
+ category: rule?.category,
6651
+ rule,
6652
+ message: result.message,
6653
+ method: typeof method === "string" ? method : null,
6654
+ path: egressPath,
6655
+ capture: evidenceFrom(result)
6656
+ });
6657
+ if (egressMode === "block") block = true;
6658
+ }
6659
+ return block;
6465
6660
  };
6466
6661
  const protection = {
6467
6662
  get mode() {