@patchstack/connect 0.3.33 → 0.4.1
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/AGENT-INSTALL.md +28 -3
- package/README.md +5 -1
- package/dist/cli.js +186 -32
- package/dist/cli.js.map +1 -1
- package/dist/protect.cjs +369 -73
- package/dist/protect.cjs.map +1 -1
- package/dist/protect.d.cts +13 -2
- package/dist/protect.d.ts +13 -2
- package/dist/protect.edge.js +369 -73
- package/dist/protect.edge.js.map +3 -3
- package/dist/protect.js +369 -73
- 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 };
|
|
2193
|
+
}
|
|
2194
|
+
function ruleParameters(rule) {
|
|
2195
|
+
return readRuleParameters(rule).parameters;
|
|
2197
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.)
|
|
@@ -3044,6 +3109,11 @@ var DEFAULT_EGRESS_RULES = [
|
|
|
3044
3109
|
title: "Outbound request to an internal / metadata address (SSRF)",
|
|
3045
3110
|
phase: "egress",
|
|
3046
3111
|
category: "ssrf",
|
|
3112
|
+
// Declared, not implied. Nothing in the egress path reads it — a match refuses the call, and the
|
|
3113
|
+
// rule's mode decides whether it actually did — but the action is part of how a rule describes
|
|
3114
|
+
// itself to whatever reports on it, and a rule that declares nothing is reported as a rule nobody
|
|
3115
|
+
// can classify.
|
|
3116
|
+
action: "block",
|
|
3047
3117
|
rule_v2: [{ parameter: "egress.host", match: { type: "internal_host" } }]
|
|
3048
3118
|
}
|
|
3049
3119
|
];
|
|
@@ -3410,10 +3480,25 @@ function worthRetrying(status) {
|
|
|
3410
3480
|
}
|
|
3411
3481
|
var ATTEMPT_TIMEOUT_MS = 1e4;
|
|
3412
3482
|
var STOP_BUDGET_MS = 5e3;
|
|
3483
|
+
var CATEGORY_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
3484
|
+
var RESERVED_CATEGORY = "unknown";
|
|
3485
|
+
function declaredClass(declared, recognised) {
|
|
3486
|
+
if (typeof declared !== "string" || declared === "") return { value: null, dropped: false };
|
|
3487
|
+
if (!recognised(declared)) return { value: null, dropped: false };
|
|
3488
|
+
if (declared.length > MAX_CLASS_CHARS) return { value: null, dropped: true };
|
|
3489
|
+
return { value: declared, dropped: false };
|
|
3490
|
+
}
|
|
3491
|
+
var recognisedCategory = (value) => value !== RESERVED_CATEGORY && CATEGORY_PATTERN.test(value);
|
|
3492
|
+
var recognisedAction = (value) => ACTIONS.includes(value);
|
|
3413
3493
|
var MAX_ROUTE_CHARS = 256;
|
|
3414
3494
|
var MAX_PARAMETERS = 25;
|
|
3415
3495
|
var MAX_PARAMETER_CHARS = 64;
|
|
3416
3496
|
var MAX_IDENTIFIER_CHARS = 256;
|
|
3497
|
+
var MAX_CLASS_CHARS = 64;
|
|
3498
|
+
var EVENT_PATTERN = /^[0-9a-f]{32}$/;
|
|
3499
|
+
function eventIdentity(value) {
|
|
3500
|
+
return typeof value === "string" && EVENT_PATTERN.test(value) ? value : null;
|
|
3501
|
+
}
|
|
3417
3502
|
var MAX_CAPTURED_VALUES = 10;
|
|
3418
3503
|
var MAX_CAPTURED_VALUE_CHARS = 512;
|
|
3419
3504
|
var MAX_QUERY_KEYS = 10;
|
|
@@ -3558,21 +3643,6 @@ function unattended(timer) {
|
|
|
3558
3643
|
if (timer && typeof timer.unref === "function") timer.unref();
|
|
3559
3644
|
return timer;
|
|
3560
3645
|
}
|
|
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
3646
|
function revisionOf(rule) {
|
|
3577
3647
|
const revision = rule?.source_revision;
|
|
3578
3648
|
if (typeof revision === "string" && revision !== "") return revision;
|
|
@@ -3873,7 +3943,7 @@ function createDetectionReporter(opts) {
|
|
|
3873
3943
|
const capture = boundCapture(detection.capture);
|
|
3874
3944
|
const rawRoute = routeOf(detection.path);
|
|
3875
3945
|
const route = typeof rawRoute === "string" ? capText(rawRoute, MAX_ROUTE_CHARS) : { value: rawRoute, truncated: false };
|
|
3876
|
-
const allParameters =
|
|
3946
|
+
const { parameters: allParameters, complete: sawEveryParameter } = readRuleParameters(detection.rule);
|
|
3877
3947
|
const parameters = allParameters.slice(0, MAX_PARAMETERS).map((name) => capText(name, MAX_PARAMETER_CHARS));
|
|
3878
3948
|
const truncated = [];
|
|
3879
3949
|
if (route.truncated) truncated.push("route");
|
|
@@ -3882,12 +3952,14 @@ function createDetectionReporter(opts) {
|
|
|
3882
3952
|
if (query.total > queryKeys.length || queryKeys.some((entry) => entry.truncated)) {
|
|
3883
3953
|
truncated.push("query_keys");
|
|
3884
3954
|
}
|
|
3885
|
-
if (allParameters.length > MAX_PARAMETERS || parameters.some((entry) => entry.truncated)) {
|
|
3955
|
+
if (!sawEveryParameter || allParameters.length > MAX_PARAMETERS || parameters.some((entry) => entry.truncated)) {
|
|
3886
3956
|
truncated.push("parameters");
|
|
3887
3957
|
}
|
|
3888
3958
|
const id = capText(String(ruleId), MAX_IDENTIFIER_CHARS);
|
|
3889
3959
|
const revision = capText(revisionOf(detection.rule) ?? "", MAX_IDENTIFIER_CHARS);
|
|
3890
3960
|
const etag = capText(rulesEtag ?? "", MAX_IDENTIFIER_CHARS);
|
|
3961
|
+
const classCategory = declaredClass(detection.rule?.category, recognisedCategory);
|
|
3962
|
+
const classAction = declaredClass(detection.rule?.action, recognisedAction);
|
|
3891
3963
|
for (const [name, field] of [
|
|
3892
3964
|
["rule_id", id],
|
|
3893
3965
|
["rule_revision", revision],
|
|
@@ -3895,6 +3967,12 @@ function createDetectionReporter(opts) {
|
|
|
3895
3967
|
]) {
|
|
3896
3968
|
if (field.truncated) truncated.push(name);
|
|
3897
3969
|
}
|
|
3970
|
+
for (const [name, field] of [
|
|
3971
|
+
["category", classCategory],
|
|
3972
|
+
["action", classAction]
|
|
3973
|
+
]) {
|
|
3974
|
+
if (field.dropped) truncated.push(name);
|
|
3975
|
+
}
|
|
3898
3976
|
queue.push({
|
|
3899
3977
|
rule_id: id.value,
|
|
3900
3978
|
route: route.value,
|
|
@@ -3903,7 +3981,10 @@ function createDetectionReporter(opts) {
|
|
|
3903
3981
|
...truncated.length > 0 ? { truncated } : {},
|
|
3904
3982
|
// Only when parameters were actually left out. Reporting a total because some OTHER field was
|
|
3905
3983
|
// shortened states that parameters were omitted when none were.
|
|
3906
|
-
|
|
3984
|
+
// Only when the walk saw the whole rule. Shortened by the cap, this is the true total; cut short
|
|
3985
|
+
// by the nesting bound, it is the count of what was seen, and sending that as the total states a
|
|
3986
|
+
// size nobody established. The mark travels either way, so a short list is always marked as one.
|
|
3987
|
+
...truncated.includes("parameters") && sawEveryParameter ? { parameters_total: allParameters.length } : {},
|
|
3907
3988
|
method: method === null ? null : method.value,
|
|
3908
3989
|
// The rest of the URL, as names only. `route` is the path; together they say what was requested
|
|
3909
3990
|
// without saying what was in it.
|
|
@@ -3912,7 +3993,32 @@ function createDetectionReporter(opts) {
|
|
|
3912
3993
|
...query.total > queryKeys.length ? { query_keys_total: query.total } : {},
|
|
3913
3994
|
// Who asked. Capped, since it is client-supplied text and this is an event with a size bound.
|
|
3914
3995
|
user_agent: userAgent === null ? null : userAgent.value,
|
|
3996
|
+
// What KIND of match this was: which phase it happened in, what class of thing the rule is for,
|
|
3997
|
+
// and what the rule DECLARES it does about it.
|
|
3998
|
+
//
|
|
3999
|
+
// Three separate facts, and none of them is `enforced` below. A rule declaring `block` while
|
|
4000
|
+
// observing reports exactly that — `action: 'block'`, `enforced: false` — which is what a
|
|
4001
|
+
// dry-run window consists of. Reading either off the other would describe such a window as
|
|
4002
|
+
// protection that never happened, or as rules that do nothing.
|
|
4003
|
+
//
|
|
4004
|
+
// `null` where the rule says nothing, never a guess. A consumer can tell "this rule is for
|
|
4005
|
+
// secret exposure" from "we cannot say what this rule is for", and a filled-in value would take
|
|
4006
|
+
// that distinction away for the sake of a tidier field.
|
|
3915
4007
|
phase: detection.phase ?? null,
|
|
4008
|
+
// `null` where the rule declared nothing, and also where what it declared could not be carried:
|
|
4009
|
+
// both are "we cannot say what this rule is for", which is a different fact from a class we do
|
|
4010
|
+
// know, and `truncated` distinguishes the second from the first.
|
|
4011
|
+
category: classCategory.value,
|
|
4012
|
+
action: classAction.value,
|
|
4013
|
+
// Which call this detection belongs to, so a consumer can tell one call two rules saw from two
|
|
4014
|
+
// separate calls. Two rules matching one call is the ordinary case — a rule that enforces and a
|
|
4015
|
+
// rule that only observes are meant to match the same thing — so without this, adding these up
|
|
4016
|
+
// reports one call more than once.
|
|
4017
|
+
//
|
|
4018
|
+
// Nothing about the request goes into it: it is only ever compared with other identities, so
|
|
4019
|
+
// deriving it from the address or the path would carry something about whoever made the request
|
|
4020
|
+
// into a place nothing needs it. That is a property of how it is minted, not of its shape.
|
|
4021
|
+
event: eventIdentity(detection.event),
|
|
3916
4022
|
// The state this detection was handled under, which is the whole point: `false` is a rule that
|
|
3917
4023
|
// saw traffic it would have stopped.
|
|
3918
4024
|
enforced: detection.mode === "block",
|
|
@@ -4083,6 +4189,19 @@ function reportingState(input) {
|
|
|
4083
4189
|
return { state: "on", reports: true };
|
|
4084
4190
|
}
|
|
4085
4191
|
|
|
4192
|
+
// src/protect/response-hardening.js
|
|
4193
|
+
var BODY_INDEPENDENT = Object.freeze(["response.status", "response.headers"]);
|
|
4194
|
+
var BODY_INDEPENDENT_PREFIX = "response.header.";
|
|
4195
|
+
var HEADER_ACTIONS = Object.freeze(["set-header", "remove-header", "harden-cookie"]);
|
|
4196
|
+
function hardensWithoutBody(rule) {
|
|
4197
|
+
if (!rule || !HEADER_ACTIONS.includes(rule.action)) return false;
|
|
4198
|
+
const { parameters, complete } = readRuleParameters(rule);
|
|
4199
|
+
if (!complete) return false;
|
|
4200
|
+
return parameters.every(
|
|
4201
|
+
(parameter) => BODY_INDEPENDENT.includes(parameter) || parameter.startsWith(BODY_INDEPENDENT_PREFIX)
|
|
4202
|
+
);
|
|
4203
|
+
}
|
|
4204
|
+
|
|
4086
4205
|
// src/protect/firewall-log.js
|
|
4087
4206
|
var DEFAULT_API_BASE = "https://api.patchstack.com";
|
|
4088
4207
|
var STOP_BUDGET_MS2 = 5e3;
|
|
@@ -4466,7 +4585,7 @@ async function createProtection(options = {}) {
|
|
|
4466
4585
|
let screenCap;
|
|
4467
4586
|
let engine;
|
|
4468
4587
|
let responseRuleSet;
|
|
4469
|
-
let
|
|
4588
|
+
let egressRuleSet;
|
|
4470
4589
|
const applyBundle = (delivered) => {
|
|
4471
4590
|
const incoming = delivered.firewall ?? [];
|
|
4472
4591
|
requestRules = byPhase(incoming, "request");
|
|
@@ -4493,7 +4612,10 @@ async function createProtection(options = {}) {
|
|
|
4493
4612
|
// the common case — cutting CPU/latency and shrinking the regex/ReDoS surface. Case-insensitive.
|
|
4494
4613
|
prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
|
|
4495
4614
|
}));
|
|
4496
|
-
|
|
4615
|
+
egressRuleSet = egressRules.map((rule) => ({
|
|
4616
|
+
rule,
|
|
4617
|
+
engine: new RuleEngine({ firewall: [rule], onError })
|
|
4618
|
+
}));
|
|
4497
4619
|
};
|
|
4498
4620
|
applyBundle(bundle);
|
|
4499
4621
|
const skipCounts = /* @__PURE__ */ Object.create(null);
|
|
@@ -4518,8 +4640,9 @@ async function createProtection(options = {}) {
|
|
|
4518
4640
|
if (!permitsAnything(entry.plan)) return { plan: entry.reference };
|
|
4519
4641
|
return { plan: entry.reference, ...captureValues(entry.plan, result.resolver) };
|
|
4520
4642
|
};
|
|
4521
|
-
const decide = (phase, result, block, allow2,
|
|
4643
|
+
const decide = (phase, result, block, allow2, describe = () => ({})) => {
|
|
4522
4644
|
if (!result || !result.blocked) return allow2();
|
|
4645
|
+
const ctx = describe() ?? {};
|
|
4523
4646
|
const effectiveMode = ruleMode(result.rule);
|
|
4524
4647
|
onDetect({
|
|
4525
4648
|
phase,
|
|
@@ -4532,6 +4655,9 @@ async function createProtection(options = {}) {
|
|
|
4532
4655
|
method: ctx.method,
|
|
4533
4656
|
path: ctx.path,
|
|
4534
4657
|
ip: ctx.ip,
|
|
4658
|
+
// Which call this was. Named here rather than spread from `ctx` for the same reason as everything
|
|
4659
|
+
// else in this payload: a field reaches the wire because someone listed it.
|
|
4660
|
+
event: ctx.event ?? null,
|
|
4535
4661
|
// Provenance travels with the address. Without it a consumer cannot tell an observed peer from a
|
|
4536
4662
|
// value read out of a forwarded header, and `null` from "there was no address to establish".
|
|
4537
4663
|
clientIpSource: ctx.clientIpSource,
|
|
@@ -4541,12 +4667,13 @@ async function createProtection(options = {}) {
|
|
|
4541
4667
|
});
|
|
4542
4668
|
return effectiveMode === "block" ? block() : allow2();
|
|
4543
4669
|
};
|
|
4544
|
-
const screenText = (text, meta, reqCtx) => {
|
|
4670
|
+
const screenText = (text, meta, reqCtx, only) => {
|
|
4545
4671
|
let blockRule = null;
|
|
4546
4672
|
const redactions = [];
|
|
4547
4673
|
const headerMutations = [];
|
|
4548
4674
|
let lowerText = null;
|
|
4549
4675
|
for (const { rule, engine: re, redactors, prefilter, mutatedSpan } of responseRuleSet) {
|
|
4676
|
+
if (only && !only(rule)) continue;
|
|
4550
4677
|
if (prefilter) {
|
|
4551
4678
|
if (lowerText === null) lowerText = text.toLowerCase();
|
|
4552
4679
|
if (!prefilter.some((p) => lowerText.includes(p))) continue;
|
|
@@ -4662,7 +4789,7 @@ async function createProtection(options = {}) {
|
|
|
4662
4789
|
result,
|
|
4663
4790
|
() => blockResponse(result, request),
|
|
4664
4791
|
() => null,
|
|
4665
|
-
requestMeta(shaped, request)
|
|
4792
|
+
() => requestMeta(shaped, request)
|
|
4666
4793
|
);
|
|
4667
4794
|
return { blocked, client: shaped?._clientIp };
|
|
4668
4795
|
};
|
|
@@ -4677,7 +4804,12 @@ async function createProtection(options = {}) {
|
|
|
4677
4804
|
originalUrl: u.pathname + u.search,
|
|
4678
4805
|
headers,
|
|
4679
4806
|
ip: resolved.ip ?? "",
|
|
4680
|
-
_clientIp: resolved
|
|
4807
|
+
_clientIp: resolved,
|
|
4808
|
+
// The request itself, so the identity can be asked for LATER. This context is built on every
|
|
4809
|
+
// request whether or not anything matches, so asking here would mint one for every request —
|
|
4810
|
+
// and asking of the context rather than of the request would give the response a different
|
|
4811
|
+
// identity from the request that caused it, reporting one call as two.
|
|
4812
|
+
_eventOf: request
|
|
4681
4813
|
};
|
|
4682
4814
|
} catch {
|
|
4683
4815
|
return void 0;
|
|
@@ -4688,13 +4820,17 @@ async function createProtection(options = {}) {
|
|
|
4688
4820
|
originalUrl: req.url,
|
|
4689
4821
|
headers: req.headers || {},
|
|
4690
4822
|
ip: client?.ip ?? "",
|
|
4691
|
-
_clientIp: client ?? { ip: null, source: "unavailable" }
|
|
4823
|
+
_clientIp: client ?? { ip: null, source: "unavailable" },
|
|
4824
|
+
// The request itself, for the same reasons as on the fetch path above: asked for later, and
|
|
4825
|
+
// asked of the request rather than of this context.
|
|
4826
|
+
_eventOf: req
|
|
4692
4827
|
} : void 0;
|
|
4693
4828
|
const screenResp = async (response, reqCtx) => {
|
|
4694
4829
|
const read = await readTextResponse(response, screenCap);
|
|
4695
4830
|
if (read.skip) {
|
|
4696
4831
|
if (read.skip !== "not-a-response") recordSkip("response", read.skip, { status: response?.status });
|
|
4697
|
-
return response;
|
|
4832
|
+
if (read.skip === "not-a-response") return response;
|
|
4833
|
+
return hardenHeadersOnly(response, reqCtx);
|
|
4698
4834
|
}
|
|
4699
4835
|
const text = read.text;
|
|
4700
4836
|
const r = screenText(text, { status: response.status, headers: headerObject(response.headers) }, reqCtx);
|
|
@@ -4702,6 +4838,74 @@ async function createProtection(options = {}) {
|
|
|
4702
4838
|
if (r.verdict === "redact") return rebuildResponse(response, r.body, r.headers);
|
|
4703
4839
|
return response;
|
|
4704
4840
|
};
|
|
4841
|
+
const headerValueChanged = (was, value) => {
|
|
4842
|
+
const had = was !== void 0 && was !== null;
|
|
4843
|
+
if (value === null || value === void 0) return had;
|
|
4844
|
+
if (!had) return true;
|
|
4845
|
+
if (Array.isArray(value) || Array.isArray(was)) {
|
|
4846
|
+
const a = Array.isArray(was) ? was : [was];
|
|
4847
|
+
const b = Array.isArray(value) ? value : [value];
|
|
4848
|
+
return a.length !== b.length || b.some((item, i) => String(item) !== String(a[i]));
|
|
4849
|
+
}
|
|
4850
|
+
return String(value) !== String(was);
|
|
4851
|
+
};
|
|
4852
|
+
const headersChanged = (before, after) => Object.entries(after).some(([name, value]) => headerValueChanged(before[name], value));
|
|
4853
|
+
const writeHeadHeaderIndex = (args) => {
|
|
4854
|
+
const at = typeof args[1] === "string" ? 2 : 1;
|
|
4855
|
+
const value = args[at];
|
|
4856
|
+
return value !== null && typeof value === "object" ? at : -1;
|
|
4857
|
+
};
|
|
4858
|
+
const writeHeadEntries = (headers) => {
|
|
4859
|
+
if (!Array.isArray(headers)) return Object.entries(headers);
|
|
4860
|
+
if (headers.length && headers.every((entry) => Array.isArray(entry) && entry.length === 2)) {
|
|
4861
|
+
return headers.map(([name, value]) => [String(name), value]);
|
|
4862
|
+
}
|
|
4863
|
+
const entries = [];
|
|
4864
|
+
for (let i = 0; i + 1 < headers.length; i += 2) entries.push([String(headers[i]), headers[i + 1]]);
|
|
4865
|
+
return entries;
|
|
4866
|
+
};
|
|
4867
|
+
const writeHeadHeaderObject = (headers) => {
|
|
4868
|
+
const out = {};
|
|
4869
|
+
for (const [name, value] of writeHeadEntries(headers)) {
|
|
4870
|
+
const key = name.toLowerCase();
|
|
4871
|
+
out[key] = Object.hasOwn(out, key) ? [].concat(out[key], value) : value;
|
|
4872
|
+
}
|
|
4873
|
+
return out;
|
|
4874
|
+
};
|
|
4875
|
+
const rewriteWriteHeadHeaders = (headers, changed) => {
|
|
4876
|
+
const replaced = /* @__PURE__ */ new Set();
|
|
4877
|
+
const entries = [];
|
|
4878
|
+
for (const [name, value] of writeHeadEntries(headers)) {
|
|
4879
|
+
const key = name.toLowerCase();
|
|
4880
|
+
if (!changed.has(key)) {
|
|
4881
|
+
entries.push([name, value]);
|
|
4882
|
+
continue;
|
|
4883
|
+
}
|
|
4884
|
+
if (replaced.has(key)) continue;
|
|
4885
|
+
replaced.add(key);
|
|
4886
|
+
const replacement = changed.get(key);
|
|
4887
|
+
if (replacement === null || replacement === void 0) continue;
|
|
4888
|
+
entries.push([name, replacement]);
|
|
4889
|
+
}
|
|
4890
|
+
if (!Array.isArray(headers)) return Object.fromEntries(entries);
|
|
4891
|
+
const paired = headers.length > 0 && headers.every((entry) => Array.isArray(entry) && entry.length === 2);
|
|
4892
|
+
const expanded = entries.flatMap(
|
|
4893
|
+
([name, value]) => Array.isArray(value) ? value.map((item) => [name, item]) : [[name, value]]
|
|
4894
|
+
);
|
|
4895
|
+
return paired ? expanded : expanded.flat();
|
|
4896
|
+
};
|
|
4897
|
+
const hardenHeadersOnly = (response, reqCtx) => {
|
|
4898
|
+
try {
|
|
4899
|
+
const meta = { status: response.status, headers: headerObject(response.headers) };
|
|
4900
|
+
const r = screenText("", meta, reqCtx, hardensWithoutBody);
|
|
4901
|
+
if (r.verdict !== "redact" || !r.headers) return response;
|
|
4902
|
+
if (!headersChanged(meta.headers, r.headers)) return response;
|
|
4903
|
+
return rebuildResponse(response, response.body, r.headers);
|
|
4904
|
+
} catch (err) {
|
|
4905
|
+
notify(onError, err, "onError");
|
|
4906
|
+
return response;
|
|
4907
|
+
}
|
|
4908
|
+
};
|
|
4705
4909
|
const wrapNodeResponse = (res, reqCtx) => {
|
|
4706
4910
|
const origWrite = res.write.bind(res);
|
|
4707
4911
|
const origEnd = res.end.bind(res);
|
|
@@ -4709,6 +4913,51 @@ async function createProtection(options = {}) {
|
|
|
4709
4913
|
let size = 0;
|
|
4710
4914
|
let overflow = false;
|
|
4711
4915
|
const MAX = screenCap;
|
|
4916
|
+
let hardened = false;
|
|
4917
|
+
let answeredWithoutBody = false;
|
|
4918
|
+
const hardenBeforeFlush = (effective) => {
|
|
4919
|
+
if (hardened) return null;
|
|
4920
|
+
hardened = true;
|
|
4921
|
+
try {
|
|
4922
|
+
if (res.headersSent || typeof res.setHeader !== "function") return null;
|
|
4923
|
+
const set = typeof res.getHeaders === "function" ? res.getHeaders() : {};
|
|
4924
|
+
const headers = effective && effective.headers ? { ...set, ...effective.headers } : set;
|
|
4925
|
+
const status = effective && effective.status !== void 0 ? effective.status : res.statusCode;
|
|
4926
|
+
const r = screenText("", { status, headers }, reqCtx, hardensWithoutBody);
|
|
4927
|
+
answeredWithoutBody = true;
|
|
4928
|
+
if (r.verdict !== "redact" || !r.headers) return null;
|
|
4929
|
+
const changed = /* @__PURE__ */ new Map();
|
|
4930
|
+
for (const [name, value] of Object.entries(r.headers)) {
|
|
4931
|
+
if (!headerValueChanged(headers[name], value)) continue;
|
|
4932
|
+
changed.set(name.toLowerCase(), value);
|
|
4933
|
+
try {
|
|
4934
|
+
if (value === null || value === void 0) res.removeHeader?.(name);
|
|
4935
|
+
else res.setHeader(name, value);
|
|
4936
|
+
} catch {
|
|
4937
|
+
}
|
|
4938
|
+
}
|
|
4939
|
+
return changed.size ? changed : null;
|
|
4940
|
+
} catch (err) {
|
|
4941
|
+
notify(onError, err, "onError");
|
|
4942
|
+
return null;
|
|
4943
|
+
}
|
|
4944
|
+
};
|
|
4945
|
+
const stillToAnswer = (rule) => !hardensWithoutBody(rule);
|
|
4946
|
+
if (typeof res.writeHead === "function") {
|
|
4947
|
+
const origWriteHead = res.writeHead.bind(res);
|
|
4948
|
+
res.writeHead = function(...args) {
|
|
4949
|
+
const at = writeHeadHeaderIndex(args);
|
|
4950
|
+
const given = at === -1 ? null : args[at];
|
|
4951
|
+
const changed = hardenBeforeFlush({
|
|
4952
|
+
status: typeof args[0] === "number" ? args[0] : void 0,
|
|
4953
|
+
headers: given ? writeHeadHeaderObject(given) : null
|
|
4954
|
+
});
|
|
4955
|
+
if (!changed || !given) return origWriteHead(...args);
|
|
4956
|
+
const rewritten = [...args];
|
|
4957
|
+
rewritten[at] = rewriteWriteHeadHeaders(given, changed);
|
|
4958
|
+
return origWriteHead(...rewritten);
|
|
4959
|
+
};
|
|
4960
|
+
}
|
|
4712
4961
|
const collect = (chunk, enc) => {
|
|
4713
4962
|
if (chunk == null) return;
|
|
4714
4963
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof enc === "string" ? enc : "utf8");
|
|
@@ -4724,6 +4973,7 @@ async function createProtection(options = {}) {
|
|
|
4724
4973
|
chunks.push(buf);
|
|
4725
4974
|
};
|
|
4726
4975
|
res.write = function(chunk, enc, cb) {
|
|
4976
|
+
hardenBeforeFlush();
|
|
4727
4977
|
if (overflow) return origWrite(chunk, enc, cb);
|
|
4728
4978
|
collect(chunk, enc);
|
|
4729
4979
|
if (typeof enc === "function") enc();
|
|
@@ -4731,6 +4981,7 @@ async function createProtection(options = {}) {
|
|
|
4731
4981
|
return true;
|
|
4732
4982
|
};
|
|
4733
4983
|
res.end = function(chunk, enc, cb) {
|
|
4984
|
+
hardenBeforeFlush();
|
|
4734
4985
|
if (typeof chunk === "function") {
|
|
4735
4986
|
cb = chunk;
|
|
4736
4987
|
chunk = void 0;
|
|
@@ -4757,7 +5008,12 @@ async function createProtection(options = {}) {
|
|
|
4757
5008
|
const text = buffer.toString("utf8");
|
|
4758
5009
|
let r;
|
|
4759
5010
|
try {
|
|
4760
|
-
r = screenText(
|
|
5011
|
+
r = screenText(
|
|
5012
|
+
text,
|
|
5013
|
+
{ status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} },
|
|
5014
|
+
reqCtx,
|
|
5015
|
+
answeredWithoutBody ? stillToAnswer : void 0
|
|
5016
|
+
);
|
|
4761
5017
|
} catch (err) {
|
|
4762
5018
|
notify(onError, err, "onError");
|
|
4763
5019
|
for (const c of chunks) origWrite(c);
|
|
@@ -4807,15 +5063,6 @@ async function createProtection(options = {}) {
|
|
|
4807
5063
|
const allow = new Set((options.allowHosts ?? []).map((h) => String(h).toLowerCase()));
|
|
4808
5064
|
const egressShouldBlock = (url, host, method) => {
|
|
4809
5065
|
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
5066
|
let egressPath = null;
|
|
4820
5067
|
try {
|
|
4821
5068
|
const u = new URL(url);
|
|
@@ -4823,17 +5070,39 @@ async function createProtection(options = {}) {
|
|
|
4823
5070
|
} catch {
|
|
4824
5071
|
egressPath = typeof url === "string" ? url : null;
|
|
4825
5072
|
}
|
|
4826
|
-
|
|
4827
|
-
|
|
4828
|
-
|
|
4829
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
4832
|
-
|
|
4833
|
-
|
|
4834
|
-
|
|
4835
|
-
|
|
4836
|
-
|
|
5073
|
+
const egressEvent = mintEvent();
|
|
5074
|
+
let block = false;
|
|
5075
|
+
for (const { rule, engine: re } of egressRuleSet) {
|
|
5076
|
+
let result;
|
|
5077
|
+
try {
|
|
5078
|
+
result = re.evaluate({ _egress: { url, host, method } });
|
|
5079
|
+
} catch (err) {
|
|
5080
|
+
notify(onError, err, "onError");
|
|
5081
|
+
continue;
|
|
5082
|
+
}
|
|
5083
|
+
if (!result.blocked) continue;
|
|
5084
|
+
const egressMode = ruleMode(rule);
|
|
5085
|
+
onDetect({
|
|
5086
|
+
phase: "egress",
|
|
5087
|
+
mode: egressMode,
|
|
5088
|
+
category: rule?.category,
|
|
5089
|
+
rule,
|
|
5090
|
+
message: result.message,
|
|
5091
|
+
// One identity for this outbound call, shared by every rule that matches it — the egress phase
|
|
5092
|
+
// evaluates all of them rather than stopping at the first, so without this two rules refusing one
|
|
5093
|
+
// call would count as two calls refused.
|
|
5094
|
+
//
|
|
5095
|
+
// Its own identity, not the identity of whatever request the application was serving when it made
|
|
5096
|
+
// the call. An outbound attempt is a thing that happened in its own right, and a call made outside
|
|
5097
|
+
// any request — a job, a timer — has no request to belong to.
|
|
5098
|
+
event: egressEvent,
|
|
5099
|
+
method: typeof method === "string" ? method : null,
|
|
5100
|
+
path: egressPath,
|
|
5101
|
+
capture: evidenceFrom(result)
|
|
5102
|
+
});
|
|
5103
|
+
if (egressMode === "block") block = true;
|
|
5104
|
+
}
|
|
5105
|
+
return block;
|
|
4837
5106
|
};
|
|
4838
5107
|
const protection = {
|
|
4839
5108
|
get mode() {
|
|
@@ -4902,7 +5171,7 @@ async function createProtection(options = {}) {
|
|
|
4902
5171
|
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
|
|
4903
5172
|
next();
|
|
4904
5173
|
},
|
|
4905
|
-
nodeRequestMeta(req, client)
|
|
5174
|
+
() => nodeRequestMeta(req, client)
|
|
4906
5175
|
);
|
|
4907
5176
|
};
|
|
4908
5177
|
},
|
|
@@ -4966,7 +5235,7 @@ async function createProtection(options = {}) {
|
|
|
4966
5235
|
if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, shaped?._clientIp));
|
|
4967
5236
|
next();
|
|
4968
5237
|
},
|
|
4969
|
-
nodeRequestMeta(req, shaped?._clientIp)
|
|
5238
|
+
() => nodeRequestMeta(req, shaped?._clientIp)
|
|
4970
5239
|
);
|
|
4971
5240
|
}
|
|
4972
5241
|
}
|
|
@@ -5440,11 +5709,37 @@ function defaultOnDetect({ phase, mode, category, rule, message }) {
|
|
|
5440
5709
|
const tag = mode === "block" ? "BLOCK" : "DETECT (dry-run)";
|
|
5441
5710
|
console.warn(`[patchstack] ${tag} phase=${phase ?? "request"} category=${category ?? "?"} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
|
|
5442
5711
|
}
|
|
5712
|
+
var eventIdentities = /* @__PURE__ */ new WeakMap();
|
|
5713
|
+
function mintEvent() {
|
|
5714
|
+
try {
|
|
5715
|
+
const bytes = globalThis.crypto?.getRandomValues?.(new Uint8Array(16));
|
|
5716
|
+
if (bytes) return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
5717
|
+
} catch {
|
|
5718
|
+
}
|
|
5719
|
+
try {
|
|
5720
|
+
let hex = Date.now().toString(16);
|
|
5721
|
+
while (hex.length < 32) hex += Math.floor(Math.random() * 4294967295).toString(16).padStart(8, "0");
|
|
5722
|
+
return hex.slice(0, 32);
|
|
5723
|
+
} catch {
|
|
5724
|
+
return null;
|
|
5725
|
+
}
|
|
5726
|
+
}
|
|
5727
|
+
function eventFor(request) {
|
|
5728
|
+
if (request === null || typeof request !== "object") return mintEvent();
|
|
5729
|
+
const existing = eventIdentities.get(request);
|
|
5730
|
+
if (existing !== void 0) return existing;
|
|
5731
|
+
const minted = mintEvent();
|
|
5732
|
+
if (minted !== null) eventIdentities.set(request, minted);
|
|
5733
|
+
return minted;
|
|
5734
|
+
}
|
|
5443
5735
|
function requestMetaFromContext(reqCtx) {
|
|
5444
5736
|
if (!reqCtx) return {};
|
|
5445
5737
|
const client = reqCtx._clientIp ?? { ip: null, source: "unavailable" };
|
|
5446
5738
|
return {
|
|
5447
5739
|
method: reqCtx.method ?? null,
|
|
5740
|
+
// The originating request's identity, so a response detection and the request detection for the same
|
|
5741
|
+
// request are one event rather than two. Asked for here, which is inside a detection being raised.
|
|
5742
|
+
event: eventFor(reqCtx._eventOf),
|
|
5448
5743
|
// Path AND query. The reporter is what drops the query's VALUES, keeping its parameter names, so
|
|
5449
5744
|
// trimming it here would leave a Fetch or response detection unable to say what was requested.
|
|
5450
5745
|
path: typeof reqCtx.originalUrl === "string" ? reqCtx.originalUrl : null,
|
|
@@ -5468,7 +5763,7 @@ function requestMeta(shaped, request) {
|
|
|
5468
5763
|
method = request.method ?? null;
|
|
5469
5764
|
userAgent = request.headers?.get?.("user-agent") ?? null;
|
|
5470
5765
|
}
|
|
5471
|
-
return { method, path, ip: client.ip, clientIpSource: client.source, userAgent };
|
|
5766
|
+
return { method, path, ip: client.ip, clientIpSource: client.source, userAgent, event: eventFor(request) };
|
|
5472
5767
|
}
|
|
5473
5768
|
function nodeRequestMeta(req, client) {
|
|
5474
5769
|
if (!req) return {};
|
|
@@ -5482,7 +5777,8 @@ function nodeRequestMeta(req, client) {
|
|
|
5482
5777
|
// verified, and a second derivation could disagree with the one the engine evaluated.
|
|
5483
5778
|
ip: resolved.ip,
|
|
5484
5779
|
clientIpSource: resolved.source,
|
|
5485
|
-
userAgent: typeof ua === "string" ? ua : Array.isArray(ua) ? ua[0] : null
|
|
5780
|
+
userAgent: typeof ua === "string" ? ua : Array.isArray(ua) ? ua[0] : null,
|
|
5781
|
+
event: eventFor(req)
|
|
5486
5782
|
};
|
|
5487
5783
|
}
|
|
5488
5784
|
export {
|