@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.edge.js
CHANGED
|
@@ -2194,18 +2194,10 @@ function conditionsProblem(conditions, depth = 0) {
|
|
|
2194
2194
|
return null;
|
|
2195
2195
|
}
|
|
2196
2196
|
|
|
2197
|
-
// src/protect/
|
|
2198
|
-
|
|
2199
|
-
var CAPTURE_LIMITS = Object.freeze({
|
|
2200
|
-
/** Named and prefix values in one event, together. Raw has its own allowance. */
|
|
2201
|
-
capturedValues: 10,
|
|
2202
|
-
/** Characters of any single captured value. */
|
|
2203
|
-
valueChars: 512,
|
|
2204
|
-
/** Resolved values one prefix permission may contribute. */
|
|
2205
|
-
prefixValues: 5
|
|
2206
|
-
});
|
|
2207
|
-
function parametersOf(rule) {
|
|
2197
|
+
// src/protect/rule-parameters.js
|
|
2198
|
+
function readRuleParameters(rule) {
|
|
2208
2199
|
const out = /* @__PURE__ */ new Set();
|
|
2200
|
+
let complete = true;
|
|
2209
2201
|
const add = (parameter) => {
|
|
2210
2202
|
if (typeof parameter === "string" && parameter !== "rules") out.add(parameter);
|
|
2211
2203
|
};
|
|
@@ -2217,7 +2209,11 @@ function parametersOf(rule) {
|
|
|
2217
2209
|
add(parameter);
|
|
2218
2210
|
};
|
|
2219
2211
|
const walk = (conditions, depth) => {
|
|
2220
|
-
if (!Array.isArray(conditions)
|
|
2212
|
+
if (!Array.isArray(conditions)) return;
|
|
2213
|
+
if (depth > LIMITS.maxNestingDepth) {
|
|
2214
|
+
complete = false;
|
|
2215
|
+
return;
|
|
2216
|
+
}
|
|
2221
2217
|
for (const condition of conditions) {
|
|
2222
2218
|
if (!condition || typeof condition !== "object") continue;
|
|
2223
2219
|
collect(condition.parameter);
|
|
@@ -2225,8 +2221,22 @@ function parametersOf(rule) {
|
|
|
2225
2221
|
}
|
|
2226
2222
|
};
|
|
2227
2223
|
walk(rule?.rule_v2, 0);
|
|
2228
|
-
return [...out];
|
|
2224
|
+
return { parameters: [...out], complete };
|
|
2229
2225
|
}
|
|
2226
|
+
function ruleParameters(rule) {
|
|
2227
|
+
return readRuleParameters(rule).parameters;
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
// src/protect/capture-plan.js
|
|
2231
|
+
var NEVER_CAPTURABLE = /* @__PURE__ */ new Set(["response"]);
|
|
2232
|
+
var CAPTURE_LIMITS = Object.freeze({
|
|
2233
|
+
/** Named and prefix values in one event, together. Raw has its own allowance. */
|
|
2234
|
+
capturedValues: 10,
|
|
2235
|
+
/** Characters of any single captured value. */
|
|
2236
|
+
valueChars: 512,
|
|
2237
|
+
/** Resolved values one prefix permission may contribute. */
|
|
2238
|
+
prefixValues: 5
|
|
2239
|
+
});
|
|
2230
2240
|
function rawOptIn(rule) {
|
|
2231
2241
|
if (!rule || typeof rule !== "object" || !Object.hasOwn(rule, "capture")) return null;
|
|
2232
2242
|
const capture = rule.capture;
|
|
@@ -2244,7 +2254,7 @@ function derivePlan(rule) {
|
|
|
2244
2254
|
const named = /* @__PURE__ */ new Set();
|
|
2245
2255
|
const prefixes = /* @__PURE__ */ new Set();
|
|
2246
2256
|
if (enforceableRuleProblem(rule) !== null) return NOTHING;
|
|
2247
|
-
const parameters =
|
|
2257
|
+
const parameters = ruleParameters(rule).filter((parameter) => parameterProblem(parameter) === null);
|
|
2248
2258
|
for (const parameter of parameters) {
|
|
2249
2259
|
const dot = parameter.indexOf(".");
|
|
2250
2260
|
if (dot === -1) continue;
|
|
@@ -3037,9 +3047,23 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
3037
3047
|
title: "Private key in response body",
|
|
3038
3048
|
phase: "response",
|
|
3039
3049
|
category: "secret-exposure",
|
|
3040
|
-
|
|
3050
|
+
// Withheld: the key material sits after the marker and this pattern does not delimit it.
|
|
3051
|
+
action: "block",
|
|
3041
3052
|
prefilter: ["PRIVATE KEY"],
|
|
3042
|
-
|
|
3053
|
+
// Matches a PEM BEGIN line whose label contains `PRIVATE KEY`, with up to 32 further label
|
|
3054
|
+
// characters — uppercase letters, digits, spaces and hyphens — on either side of it. That covers
|
|
3055
|
+
// the enumerated types (`RSA`, `EC`, `OPENSSH`, `DSA`, `ENCRYPTED`), a label carrying words after
|
|
3056
|
+
// `PRIVATE KEY` (`PGP PRIVATE KEY BLOCK`), a hyphenated or otherwise unlisted type, and a bare
|
|
3057
|
+
// `-----BEGIN PRIVATE KEY-----`. `PUBLIC KEY` and `CERTIFICATE` do not match, and neither does
|
|
3058
|
+
// prose that mentions a private key without a BEGIN line.
|
|
3059
|
+
//
|
|
3060
|
+
// No footer required: a truncated response or an absent END marker does not make the material
|
|
3061
|
+
// above it less of a key. Two bounded character classes rather than a repeated group — a
|
|
3062
|
+
// quantifier inside a quantified group is the shape the engine refuses as a backtracking risk, and
|
|
3063
|
+
// a refused pattern is a rule that never fires.
|
|
3064
|
+
//
|
|
3065
|
+
// A lowercase label, or one longer than 32 characters on either side, is not matched.
|
|
3066
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/-----BEGIN [A-Z0-9 -]{0,32}PRIVATE KEY[A-Z0-9 -]{0,32}-----/" } }]
|
|
3043
3067
|
},
|
|
3044
3068
|
{
|
|
3045
3069
|
id: "resp-aws-access-key",
|
|
@@ -3116,26 +3140,65 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
3116
3140
|
title: "Database connection string with credentials in response body",
|
|
3117
3141
|
phase: "response",
|
|
3118
3142
|
category: "secret-exposure",
|
|
3119
|
-
|
|
3143
|
+
// Withheld. The credentials are only the first half of the disclosure — the host, port, database
|
|
3144
|
+
// name and query name the system they open — and a URI's own grammar admits commas, parentheses
|
|
3145
|
+
// and semicolons, so no end-of-URI character class delimits it in free text without either
|
|
3146
|
+
// stopping inside a real URI or consuming the punctuation around it. The pattern therefore
|
|
3147
|
+
// identifies the URI and the response is withheld rather than partly rewritten.
|
|
3148
|
+
action: "block",
|
|
3120
3149
|
prefilter: ["mongodb", "postgres", "mysql", "redis", "amqp"],
|
|
3121
|
-
|
|
3150
|
+
// A scheme this application connects with, a user, a password and an `@`. Credentials are
|
|
3151
|
+
// required, so a URL without them is not matched.
|
|
3152
|
+
//
|
|
3153
|
+
// The username is the userinfo grammar without `:`; the password is the same grammar with it. Every
|
|
3154
|
+
// other character either class admits may appear raw in a real credential, so a narrower one turns
|
|
3155
|
+
// a live credential into a rule that says nothing — `postgres://user:p;ss@host` is an ordinary DSN.
|
|
3156
|
+
//
|
|
3157
|
+
// The first raw colon separates username from password. Excluding it from the username makes the
|
|
3158
|
+
// separator unambiguous and keeps matching linear; the password continues to admit raw colons. A
|
|
3159
|
+
// username that contains a colon carries it as `%3A`.
|
|
3160
|
+
//
|
|
3161
|
+
// Both classes admitting `:` would leave every colon available as the separator, so a candidate
|
|
3162
|
+
// run with no `@` is re-split at every position. The screening cap does not bound that: a rule may
|
|
3163
|
+
// raise it with `max_bytes` or remove it with `bypass_limit`.
|
|
3164
|
+
//
|
|
3165
|
+
// What terminates a candidate is everything the grammar excludes: whitespace, `/`, `?`, `#`, `@`,
|
|
3166
|
+
// quotes, backslashes, angle and square brackets and braces. That is what keeps the run inside one
|
|
3167
|
+
// value. Expressed as "anything but `:`, `@`, `/` and whitespace" it crosses structure instead: in
|
|
3168
|
+
// `{"docs":"postgres://db.internal","contact":"user@example.com"}` it consumes the closing quote,
|
|
3169
|
+
// the comma and the next key, reaching the `:` and `@` of an unrelated property and withholding a
|
|
3170
|
+
// response that discloses nothing.
|
|
3171
|
+
//
|
|
3172
|
+
// The consequence is deliberate: a run of punctuation-joined text that parses as a credential URI
|
|
3173
|
+
// is treated as one. `postgres://db.internal;contact:admin@example.com` has username
|
|
3174
|
+
// `db.internal;contact`, password `admin` and host `example.com` — indistinguishable from a leak,
|
|
3175
|
+
// so it is withheld. Ordinary prose separates with whitespace, which terminates the candidate.
|
|
3176
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[A-Za-z0-9._~%!$&'()*+,;=-]+:[A-Za-z0-9._~%!$&'()*+,;=:-]+@/i" } }]
|
|
3122
3177
|
},
|
|
3123
3178
|
{
|
|
3124
3179
|
id: "resp-stack-trace",
|
|
3125
3180
|
title: "Node stack trace leaking in response body",
|
|
3126
3181
|
phase: "response",
|
|
3127
3182
|
category: "info-exposure",
|
|
3128
|
-
|
|
3183
|
+
// Withheld. A frame is recognised by its shape and the trace has no end the pattern can rely on,
|
|
3184
|
+
// so masking the frames it happens to match leaves the message, the remaining frames and every
|
|
3185
|
+
// path and line number in them.
|
|
3186
|
+
action: "block",
|
|
3129
3187
|
// No prefilter: a Node stack frame has no single distinctive literal (` at ` is too common to
|
|
3130
3188
|
// gate on). The pattern is linearly bounded per line, so it runs on every screened body.
|
|
3131
|
-
|
|
3189
|
+
//
|
|
3190
|
+
// Accepts a real newline and a JSON-escaped one. Most traces reach a client inside a JSON error
|
|
3191
|
+
// body, where the newline is the two characters `\` and `n`.
|
|
3192
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/(?:\\n|\\\\n)\\s*at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
|
|
3132
3193
|
},
|
|
3133
3194
|
{
|
|
3134
3195
|
id: "resp-sql-error",
|
|
3135
3196
|
title: "SQL / ORM error disclosure in response body",
|
|
3136
3197
|
phase: "response",
|
|
3137
3198
|
category: "info-exposure",
|
|
3138
|
-
|
|
3199
|
+
// Withheld. The signature is the start of the disclosure: masking `SQLSTATE[23000]` and serving
|
|
3200
|
+
// the constraint name, the column and the offending value discloses the schema anyway.
|
|
3201
|
+
action: "block",
|
|
3139
3202
|
prefilter: ["SQLSTATE", "Sequelize", "ER_", "ORA-", "PG::", "SQLITE_ERROR", "SQL syntax"],
|
|
3140
3203
|
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/(SQLSTATE\\[[0-9A-Z]+\\]|SequelizeDatabaseError|ER_[A-Z_]+|ORA-\\d{5}|PG::[A-Za-z]+Error|SQLITE_ERROR|You have an error in your SQL syntax)/i" } }]
|
|
3141
3204
|
},
|
|
@@ -3144,7 +3207,9 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
3144
3207
|
title: "Backend exception / stack trace disclosure in response body",
|
|
3145
3208
|
phase: "response",
|
|
3146
3209
|
category: "info-exposure",
|
|
3147
|
-
|
|
3210
|
+
// Withheld, for the same reason as the trace above: the marker opens the dump and the file names,
|
|
3211
|
+
// line numbers and frames after it are the disclosure.
|
|
3212
|
+
action: "block",
|
|
3148
3213
|
// Multi-language exception/traceback signatures a normal API response never carries:
|
|
3149
3214
|
// Python traceback, Java "Exception in thread", .NET System.*Exception, JVM stack frames,
|
|
3150
3215
|
// Go goroutine dumps. (Node `at fn (file:line:col)` frames are handled by resp-stack-trace.)
|
|
@@ -3672,21 +3737,6 @@ function unattended(timer) {
|
|
|
3672
3737
|
if (timer && typeof timer.unref === "function") timer.unref();
|
|
3673
3738
|
return timer;
|
|
3674
3739
|
}
|
|
3675
|
-
function ruleParameters(rule) {
|
|
3676
|
-
const out = /* @__PURE__ */ new Set();
|
|
3677
|
-
const walk = (conditions) => {
|
|
3678
|
-
if (!Array.isArray(conditions)) return;
|
|
3679
|
-
for (const condition of conditions) {
|
|
3680
|
-
if (!condition || typeof condition !== "object") continue;
|
|
3681
|
-
if (typeof condition.parameter === "string" && condition.parameter !== "rules") {
|
|
3682
|
-
out.add(condition.parameter);
|
|
3683
|
-
}
|
|
3684
|
-
if (Array.isArray(condition.rules)) walk(condition.rules);
|
|
3685
|
-
}
|
|
3686
|
-
};
|
|
3687
|
-
walk(rule?.rule_v2);
|
|
3688
|
-
return [...out];
|
|
3689
|
-
}
|
|
3690
3740
|
function revisionOf(rule) {
|
|
3691
3741
|
const revision = rule?.source_revision;
|
|
3692
3742
|
if (typeof revision === "string" && revision !== "") return revision;
|
|
@@ -3987,7 +4037,7 @@ function createDetectionReporter(opts) {
|
|
|
3987
4037
|
const capture = boundCapture(detection.capture);
|
|
3988
4038
|
const rawRoute = routeOf(detection.path);
|
|
3989
4039
|
const route = typeof rawRoute === "string" ? capText(rawRoute, MAX_ROUTE_CHARS) : { value: rawRoute, truncated: false };
|
|
3990
|
-
const allParameters =
|
|
4040
|
+
const { parameters: allParameters, complete: sawEveryParameter } = readRuleParameters(detection.rule);
|
|
3991
4041
|
const parameters = allParameters.slice(0, MAX_PARAMETERS).map((name) => capText(name, MAX_PARAMETER_CHARS));
|
|
3992
4042
|
const truncated = [];
|
|
3993
4043
|
if (route.truncated) truncated.push("route");
|
|
@@ -3996,7 +4046,7 @@ function createDetectionReporter(opts) {
|
|
|
3996
4046
|
if (query.total > queryKeys.length || queryKeys.some((entry) => entry.truncated)) {
|
|
3997
4047
|
truncated.push("query_keys");
|
|
3998
4048
|
}
|
|
3999
|
-
if (allParameters.length > MAX_PARAMETERS || parameters.some((entry) => entry.truncated)) {
|
|
4049
|
+
if (!sawEveryParameter || allParameters.length > MAX_PARAMETERS || parameters.some((entry) => entry.truncated)) {
|
|
4000
4050
|
truncated.push("parameters");
|
|
4001
4051
|
}
|
|
4002
4052
|
const id = capText(String(ruleId), MAX_IDENTIFIER_CHARS);
|
|
@@ -4017,7 +4067,10 @@ function createDetectionReporter(opts) {
|
|
|
4017
4067
|
...truncated.length > 0 ? { truncated } : {},
|
|
4018
4068
|
// Only when parameters were actually left out. Reporting a total because some OTHER field was
|
|
4019
4069
|
// shortened states that parameters were omitted when none were.
|
|
4020
|
-
|
|
4070
|
+
// Only when the walk saw the whole rule. Shortened by the cap, this is the true total; cut short
|
|
4071
|
+
// by the nesting bound, it is the count of what was seen, and sending that as the total states a
|
|
4072
|
+
// size nobody established. The mark travels either way, so a short list is always marked as one.
|
|
4073
|
+
...truncated.includes("parameters") && sawEveryParameter ? { parameters_total: allParameters.length } : {},
|
|
4021
4074
|
method: method === null ? null : method.value,
|
|
4022
4075
|
// The rest of the URL, as names only. `route` is the path; together they say what was requested
|
|
4023
4076
|
// without saying what was in it.
|
|
@@ -4197,6 +4250,19 @@ function reportingState(input) {
|
|
|
4197
4250
|
return { state: "on", reports: true };
|
|
4198
4251
|
}
|
|
4199
4252
|
|
|
4253
|
+
// src/protect/response-hardening.js
|
|
4254
|
+
var BODY_INDEPENDENT = Object.freeze(["response.status", "response.headers"]);
|
|
4255
|
+
var BODY_INDEPENDENT_PREFIX = "response.header.";
|
|
4256
|
+
var HEADER_ACTIONS = Object.freeze(["set-header", "remove-header", "harden-cookie"]);
|
|
4257
|
+
function hardensWithoutBody(rule) {
|
|
4258
|
+
if (!rule || !HEADER_ACTIONS.includes(rule.action)) return false;
|
|
4259
|
+
const { parameters, complete } = readRuleParameters(rule);
|
|
4260
|
+
if (!complete) return false;
|
|
4261
|
+
return parameters.every(
|
|
4262
|
+
(parameter) => BODY_INDEPENDENT.includes(parameter) || parameter.startsWith(BODY_INDEPENDENT_PREFIX)
|
|
4263
|
+
);
|
|
4264
|
+
}
|
|
4265
|
+
|
|
4200
4266
|
// src/protect/firewall-log.js
|
|
4201
4267
|
var DEFAULT_API_BASE = "https://api.patchstack.com";
|
|
4202
4268
|
var STOP_BUDGET_MS2 = 5e3;
|
|
@@ -4580,7 +4646,7 @@ async function createProtection(options = {}) {
|
|
|
4580
4646
|
let screenCap;
|
|
4581
4647
|
let engine;
|
|
4582
4648
|
let responseRuleSet;
|
|
4583
|
-
let
|
|
4649
|
+
let egressRuleSet;
|
|
4584
4650
|
const applyBundle = (delivered) => {
|
|
4585
4651
|
const incoming = delivered.firewall ?? [];
|
|
4586
4652
|
requestRules = byPhase(incoming, "request");
|
|
@@ -4607,7 +4673,10 @@ async function createProtection(options = {}) {
|
|
|
4607
4673
|
// the common case — cutting CPU/latency and shrinking the regex/ReDoS surface. Case-insensitive.
|
|
4608
4674
|
prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
|
|
4609
4675
|
}));
|
|
4610
|
-
|
|
4676
|
+
egressRuleSet = egressRules.map((rule) => ({
|
|
4677
|
+
rule,
|
|
4678
|
+
engine: new RuleEngine({ firewall: [rule], onError })
|
|
4679
|
+
}));
|
|
4611
4680
|
};
|
|
4612
4681
|
applyBundle(bundle);
|
|
4613
4682
|
const skipCounts = /* @__PURE__ */ Object.create(null);
|
|
@@ -4655,12 +4724,13 @@ async function createProtection(options = {}) {
|
|
|
4655
4724
|
});
|
|
4656
4725
|
return effectiveMode === "block" ? block() : allow2();
|
|
4657
4726
|
};
|
|
4658
|
-
const screenText = (text, meta, reqCtx) => {
|
|
4727
|
+
const screenText = (text, meta, reqCtx, only) => {
|
|
4659
4728
|
let blockRule = null;
|
|
4660
4729
|
const redactions = [];
|
|
4661
4730
|
const headerMutations = [];
|
|
4662
4731
|
let lowerText = null;
|
|
4663
4732
|
for (const { rule, engine: re, redactors, prefilter, mutatedSpan } of responseRuleSet) {
|
|
4733
|
+
if (only && !only(rule)) continue;
|
|
4664
4734
|
if (prefilter) {
|
|
4665
4735
|
if (lowerText === null) lowerText = text.toLowerCase();
|
|
4666
4736
|
if (!prefilter.some((p) => lowerText.includes(p))) continue;
|
|
@@ -4808,7 +4878,8 @@ async function createProtection(options = {}) {
|
|
|
4808
4878
|
const read = await readTextResponse(response, screenCap);
|
|
4809
4879
|
if (read.skip) {
|
|
4810
4880
|
if (read.skip !== "not-a-response") recordSkip("response", read.skip, { status: response?.status });
|
|
4811
|
-
return response;
|
|
4881
|
+
if (read.skip === "not-a-response") return response;
|
|
4882
|
+
return hardenHeadersOnly(response, reqCtx);
|
|
4812
4883
|
}
|
|
4813
4884
|
const text = read.text;
|
|
4814
4885
|
const r = screenText(text, { status: response.status, headers: headerObject(response.headers) }, reqCtx);
|
|
@@ -4816,6 +4887,74 @@ async function createProtection(options = {}) {
|
|
|
4816
4887
|
if (r.verdict === "redact") return rebuildResponse(response, r.body, r.headers);
|
|
4817
4888
|
return response;
|
|
4818
4889
|
};
|
|
4890
|
+
const headerValueChanged = (was, value) => {
|
|
4891
|
+
const had = was !== void 0 && was !== null;
|
|
4892
|
+
if (value === null || value === void 0) return had;
|
|
4893
|
+
if (!had) return true;
|
|
4894
|
+
if (Array.isArray(value) || Array.isArray(was)) {
|
|
4895
|
+
const a = Array.isArray(was) ? was : [was];
|
|
4896
|
+
const b = Array.isArray(value) ? value : [value];
|
|
4897
|
+
return a.length !== b.length || b.some((item, i) => String(item) !== String(a[i]));
|
|
4898
|
+
}
|
|
4899
|
+
return String(value) !== String(was);
|
|
4900
|
+
};
|
|
4901
|
+
const headersChanged = (before, after) => Object.entries(after).some(([name, value]) => headerValueChanged(before[name], value));
|
|
4902
|
+
const writeHeadHeaderIndex = (args) => {
|
|
4903
|
+
const at = typeof args[1] === "string" ? 2 : 1;
|
|
4904
|
+
const value = args[at];
|
|
4905
|
+
return value !== null && typeof value === "object" ? at : -1;
|
|
4906
|
+
};
|
|
4907
|
+
const writeHeadEntries = (headers) => {
|
|
4908
|
+
if (!Array.isArray(headers)) return Object.entries(headers);
|
|
4909
|
+
if (headers.length && headers.every((entry) => Array.isArray(entry) && entry.length === 2)) {
|
|
4910
|
+
return headers.map(([name, value]) => [String(name), value]);
|
|
4911
|
+
}
|
|
4912
|
+
const entries = [];
|
|
4913
|
+
for (let i = 0; i + 1 < headers.length; i += 2) entries.push([String(headers[i]), headers[i + 1]]);
|
|
4914
|
+
return entries;
|
|
4915
|
+
};
|
|
4916
|
+
const writeHeadHeaderObject = (headers) => {
|
|
4917
|
+
const out = {};
|
|
4918
|
+
for (const [name, value] of writeHeadEntries(headers)) {
|
|
4919
|
+
const key = name.toLowerCase();
|
|
4920
|
+
out[key] = Object.hasOwn(out, key) ? [].concat(out[key], value) : value;
|
|
4921
|
+
}
|
|
4922
|
+
return out;
|
|
4923
|
+
};
|
|
4924
|
+
const rewriteWriteHeadHeaders = (headers, changed) => {
|
|
4925
|
+
const replaced = /* @__PURE__ */ new Set();
|
|
4926
|
+
const entries = [];
|
|
4927
|
+
for (const [name, value] of writeHeadEntries(headers)) {
|
|
4928
|
+
const key = name.toLowerCase();
|
|
4929
|
+
if (!changed.has(key)) {
|
|
4930
|
+
entries.push([name, value]);
|
|
4931
|
+
continue;
|
|
4932
|
+
}
|
|
4933
|
+
if (replaced.has(key)) continue;
|
|
4934
|
+
replaced.add(key);
|
|
4935
|
+
const replacement = changed.get(key);
|
|
4936
|
+
if (replacement === null || replacement === void 0) continue;
|
|
4937
|
+
entries.push([name, replacement]);
|
|
4938
|
+
}
|
|
4939
|
+
if (!Array.isArray(headers)) return Object.fromEntries(entries);
|
|
4940
|
+
const paired = headers.length > 0 && headers.every((entry) => Array.isArray(entry) && entry.length === 2);
|
|
4941
|
+
const expanded = entries.flatMap(
|
|
4942
|
+
([name, value]) => Array.isArray(value) ? value.map((item) => [name, item]) : [[name, value]]
|
|
4943
|
+
);
|
|
4944
|
+
return paired ? expanded : expanded.flat();
|
|
4945
|
+
};
|
|
4946
|
+
const hardenHeadersOnly = (response, reqCtx) => {
|
|
4947
|
+
try {
|
|
4948
|
+
const meta = { status: response.status, headers: headerObject(response.headers) };
|
|
4949
|
+
const r = screenText("", meta, reqCtx, hardensWithoutBody);
|
|
4950
|
+
if (r.verdict !== "redact" || !r.headers) return response;
|
|
4951
|
+
if (!headersChanged(meta.headers, r.headers)) return response;
|
|
4952
|
+
return rebuildResponse(response, response.body, r.headers);
|
|
4953
|
+
} catch (err) {
|
|
4954
|
+
notify(onError, err, "onError");
|
|
4955
|
+
return response;
|
|
4956
|
+
}
|
|
4957
|
+
};
|
|
4819
4958
|
const wrapNodeResponse = (res, reqCtx) => {
|
|
4820
4959
|
const origWrite = res.write.bind(res);
|
|
4821
4960
|
const origEnd = res.end.bind(res);
|
|
@@ -4823,6 +4962,51 @@ async function createProtection(options = {}) {
|
|
|
4823
4962
|
let size = 0;
|
|
4824
4963
|
let overflow = false;
|
|
4825
4964
|
const MAX = screenCap;
|
|
4965
|
+
let hardened = false;
|
|
4966
|
+
let answeredWithoutBody = false;
|
|
4967
|
+
const hardenBeforeFlush = (effective) => {
|
|
4968
|
+
if (hardened) return null;
|
|
4969
|
+
hardened = true;
|
|
4970
|
+
try {
|
|
4971
|
+
if (res.headersSent || typeof res.setHeader !== "function") return null;
|
|
4972
|
+
const set = typeof res.getHeaders === "function" ? res.getHeaders() : {};
|
|
4973
|
+
const headers = effective && effective.headers ? { ...set, ...effective.headers } : set;
|
|
4974
|
+
const status = effective && effective.status !== void 0 ? effective.status : res.statusCode;
|
|
4975
|
+
const r = screenText("", { status, headers }, reqCtx, hardensWithoutBody);
|
|
4976
|
+
answeredWithoutBody = true;
|
|
4977
|
+
if (r.verdict !== "redact" || !r.headers) return null;
|
|
4978
|
+
const changed = /* @__PURE__ */ new Map();
|
|
4979
|
+
for (const [name, value] of Object.entries(r.headers)) {
|
|
4980
|
+
if (!headerValueChanged(headers[name], value)) continue;
|
|
4981
|
+
changed.set(name.toLowerCase(), value);
|
|
4982
|
+
try {
|
|
4983
|
+
if (value === null || value === void 0) res.removeHeader?.(name);
|
|
4984
|
+
else res.setHeader(name, value);
|
|
4985
|
+
} catch {
|
|
4986
|
+
}
|
|
4987
|
+
}
|
|
4988
|
+
return changed.size ? changed : null;
|
|
4989
|
+
} catch (err) {
|
|
4990
|
+
notify(onError, err, "onError");
|
|
4991
|
+
return null;
|
|
4992
|
+
}
|
|
4993
|
+
};
|
|
4994
|
+
const stillToAnswer = (rule) => !hardensWithoutBody(rule);
|
|
4995
|
+
if (typeof res.writeHead === "function") {
|
|
4996
|
+
const origWriteHead = res.writeHead.bind(res);
|
|
4997
|
+
res.writeHead = function(...args) {
|
|
4998
|
+
const at = writeHeadHeaderIndex(args);
|
|
4999
|
+
const given = at === -1 ? null : args[at];
|
|
5000
|
+
const changed = hardenBeforeFlush({
|
|
5001
|
+
status: typeof args[0] === "number" ? args[0] : void 0,
|
|
5002
|
+
headers: given ? writeHeadHeaderObject(given) : null
|
|
5003
|
+
});
|
|
5004
|
+
if (!changed || !given) return origWriteHead(...args);
|
|
5005
|
+
const rewritten = [...args];
|
|
5006
|
+
rewritten[at] = rewriteWriteHeadHeaders(given, changed);
|
|
5007
|
+
return origWriteHead(...rewritten);
|
|
5008
|
+
};
|
|
5009
|
+
}
|
|
4826
5010
|
const collect = (chunk, enc) => {
|
|
4827
5011
|
if (chunk == null) return;
|
|
4828
5012
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof enc === "string" ? enc : "utf8");
|
|
@@ -4838,6 +5022,7 @@ async function createProtection(options = {}) {
|
|
|
4838
5022
|
chunks.push(buf);
|
|
4839
5023
|
};
|
|
4840
5024
|
res.write = function(chunk, enc, cb) {
|
|
5025
|
+
hardenBeforeFlush();
|
|
4841
5026
|
if (overflow) return origWrite(chunk, enc, cb);
|
|
4842
5027
|
collect(chunk, enc);
|
|
4843
5028
|
if (typeof enc === "function") enc();
|
|
@@ -4845,6 +5030,7 @@ async function createProtection(options = {}) {
|
|
|
4845
5030
|
return true;
|
|
4846
5031
|
};
|
|
4847
5032
|
res.end = function(chunk, enc, cb) {
|
|
5033
|
+
hardenBeforeFlush();
|
|
4848
5034
|
if (typeof chunk === "function") {
|
|
4849
5035
|
cb = chunk;
|
|
4850
5036
|
chunk = void 0;
|
|
@@ -4871,7 +5057,12 @@ async function createProtection(options = {}) {
|
|
|
4871
5057
|
const text = buffer.toString("utf8");
|
|
4872
5058
|
let r;
|
|
4873
5059
|
try {
|
|
4874
|
-
r = screenText(
|
|
5060
|
+
r = screenText(
|
|
5061
|
+
text,
|
|
5062
|
+
{ status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} },
|
|
5063
|
+
reqCtx,
|
|
5064
|
+
answeredWithoutBody ? stillToAnswer : void 0
|
|
5065
|
+
);
|
|
4875
5066
|
} catch (err) {
|
|
4876
5067
|
notify(onError, err, "onError");
|
|
4877
5068
|
for (const c of chunks) origWrite(c);
|
|
@@ -4921,15 +5112,6 @@ async function createProtection(options = {}) {
|
|
|
4921
5112
|
const allow = new Set((options.allowHosts ?? []).map((h) => String(h).toLowerCase()));
|
|
4922
5113
|
const egressShouldBlock = (url, host, method) => {
|
|
4923
5114
|
if (host && allow.has(host.toLowerCase())) return false;
|
|
4924
|
-
let result;
|
|
4925
|
-
try {
|
|
4926
|
-
result = egressEngine.evaluate({ _egress: { url, host, method } });
|
|
4927
|
-
} catch (err) {
|
|
4928
|
-
notify(onError, err, "onError");
|
|
4929
|
-
return false;
|
|
4930
|
-
}
|
|
4931
|
-
if (!result.blocked) return false;
|
|
4932
|
-
const egressMode = ruleMode(result.rule);
|
|
4933
5115
|
let egressPath = null;
|
|
4934
5116
|
try {
|
|
4935
5117
|
const u = new URL(url);
|
|
@@ -4937,17 +5119,30 @@ async function createProtection(options = {}) {
|
|
|
4937
5119
|
} catch {
|
|
4938
5120
|
egressPath = typeof url === "string" ? url : null;
|
|
4939
5121
|
}
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
|
|
4944
|
-
|
|
4945
|
-
|
|
4946
|
-
|
|
4947
|
-
|
|
4948
|
-
|
|
4949
|
-
|
|
4950
|
-
|
|
5122
|
+
let block = false;
|
|
5123
|
+
for (const { rule, engine: re } of egressRuleSet) {
|
|
5124
|
+
let result;
|
|
5125
|
+
try {
|
|
5126
|
+
result = re.evaluate({ _egress: { url, host, method } });
|
|
5127
|
+
} catch (err) {
|
|
5128
|
+
notify(onError, err, "onError");
|
|
5129
|
+
continue;
|
|
5130
|
+
}
|
|
5131
|
+
if (!result.blocked) continue;
|
|
5132
|
+
const egressMode = ruleMode(rule);
|
|
5133
|
+
onDetect({
|
|
5134
|
+
phase: "egress",
|
|
5135
|
+
mode: egressMode,
|
|
5136
|
+
category: rule?.category,
|
|
5137
|
+
rule,
|
|
5138
|
+
message: result.message,
|
|
5139
|
+
method: typeof method === "string" ? method : null,
|
|
5140
|
+
path: egressPath,
|
|
5141
|
+
capture: evidenceFrom(result)
|
|
5142
|
+
});
|
|
5143
|
+
if (egressMode === "block") block = true;
|
|
5144
|
+
}
|
|
5145
|
+
return block;
|
|
4951
5146
|
};
|
|
4952
5147
|
const protection = {
|
|
4953
5148
|
get mode() {
|