@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.js
CHANGED
|
@@ -2162,18 +2162,10 @@ function conditionsProblem(conditions, depth = 0) {
|
|
|
2162
2162
|
return null;
|
|
2163
2163
|
}
|
|
2164
2164
|
|
|
2165
|
-
// src/protect/
|
|
2166
|
-
|
|
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)
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
4827
|
-
|
|
4828
|
-
|
|
4829
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
4832
|
-
|
|
4833
|
-
|
|
4834
|
-
|
|
4835
|
-
|
|
4836
|
-
|
|
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() {
|