@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/cli.js +164 -32
- package/dist/cli.js.map +1 -1
- package/dist/protect.cjs +260 -65
- package/dist/protect.cjs.map +1 -1
- package/dist/protect.edge.js +260 -65
- package/dist/protect.edge.js.map +3 -3
- package/dist/protect.js +260 -65
- package/dist/protect.js.map +1 -1
- package/package.json +1 -1
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/
|
|
3792
|
-
|
|
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)
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
6455
|
-
|
|
6456
|
-
|
|
6457
|
-
|
|
6458
|
-
|
|
6459
|
-
|
|
6460
|
-
|
|
6461
|
-
|
|
6462
|
-
|
|
6463
|
-
|
|
6464
|
-
|
|
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() {
|