@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.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 };
|
|
2225
|
+
}
|
|
2226
|
+
function ruleParameters(rule) {
|
|
2227
|
+
return readRuleParameters(rule).parameters;
|
|
2229
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.)
|
|
@@ -3158,6 +3223,11 @@ var DEFAULT_EGRESS_RULES = [
|
|
|
3158
3223
|
title: "Outbound request to an internal / metadata address (SSRF)",
|
|
3159
3224
|
phase: "egress",
|
|
3160
3225
|
category: "ssrf",
|
|
3226
|
+
// Declared, not implied. Nothing in the egress path reads it — a match refuses the call, and the
|
|
3227
|
+
// rule's mode decides whether it actually did — but the action is part of how a rule describes
|
|
3228
|
+
// itself to whatever reports on it, and a rule that declares nothing is reported as a rule nobody
|
|
3229
|
+
// can classify.
|
|
3230
|
+
action: "block",
|
|
3161
3231
|
rule_v2: [{ parameter: "egress.host", match: { type: "internal_host" } }]
|
|
3162
3232
|
}
|
|
3163
3233
|
];
|
|
@@ -3524,10 +3594,25 @@ function worthRetrying(status) {
|
|
|
3524
3594
|
}
|
|
3525
3595
|
var ATTEMPT_TIMEOUT_MS = 1e4;
|
|
3526
3596
|
var STOP_BUDGET_MS = 5e3;
|
|
3597
|
+
var CATEGORY_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
3598
|
+
var RESERVED_CATEGORY = "unknown";
|
|
3599
|
+
function declaredClass(declared, recognised) {
|
|
3600
|
+
if (typeof declared !== "string" || declared === "") return { value: null, dropped: false };
|
|
3601
|
+
if (!recognised(declared)) return { value: null, dropped: false };
|
|
3602
|
+
if (declared.length > MAX_CLASS_CHARS) return { value: null, dropped: true };
|
|
3603
|
+
return { value: declared, dropped: false };
|
|
3604
|
+
}
|
|
3605
|
+
var recognisedCategory = (value) => value !== RESERVED_CATEGORY && CATEGORY_PATTERN.test(value);
|
|
3606
|
+
var recognisedAction = (value) => ACTIONS.includes(value);
|
|
3527
3607
|
var MAX_ROUTE_CHARS = 256;
|
|
3528
3608
|
var MAX_PARAMETERS = 25;
|
|
3529
3609
|
var MAX_PARAMETER_CHARS = 64;
|
|
3530
3610
|
var MAX_IDENTIFIER_CHARS = 256;
|
|
3611
|
+
var MAX_CLASS_CHARS = 64;
|
|
3612
|
+
var EVENT_PATTERN = /^[0-9a-f]{32}$/;
|
|
3613
|
+
function eventIdentity(value) {
|
|
3614
|
+
return typeof value === "string" && EVENT_PATTERN.test(value) ? value : null;
|
|
3615
|
+
}
|
|
3531
3616
|
var MAX_CAPTURED_VALUES = 10;
|
|
3532
3617
|
var MAX_CAPTURED_VALUE_CHARS = 512;
|
|
3533
3618
|
var MAX_QUERY_KEYS = 10;
|
|
@@ -3672,21 +3757,6 @@ function unattended(timer) {
|
|
|
3672
3757
|
if (timer && typeof timer.unref === "function") timer.unref();
|
|
3673
3758
|
return timer;
|
|
3674
3759
|
}
|
|
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
3760
|
function revisionOf(rule) {
|
|
3691
3761
|
const revision = rule?.source_revision;
|
|
3692
3762
|
if (typeof revision === "string" && revision !== "") return revision;
|
|
@@ -3987,7 +4057,7 @@ function createDetectionReporter(opts) {
|
|
|
3987
4057
|
const capture = boundCapture(detection.capture);
|
|
3988
4058
|
const rawRoute = routeOf(detection.path);
|
|
3989
4059
|
const route = typeof rawRoute === "string" ? capText(rawRoute, MAX_ROUTE_CHARS) : { value: rawRoute, truncated: false };
|
|
3990
|
-
const allParameters =
|
|
4060
|
+
const { parameters: allParameters, complete: sawEveryParameter } = readRuleParameters(detection.rule);
|
|
3991
4061
|
const parameters = allParameters.slice(0, MAX_PARAMETERS).map((name) => capText(name, MAX_PARAMETER_CHARS));
|
|
3992
4062
|
const truncated = [];
|
|
3993
4063
|
if (route.truncated) truncated.push("route");
|
|
@@ -3996,12 +4066,14 @@ function createDetectionReporter(opts) {
|
|
|
3996
4066
|
if (query.total > queryKeys.length || queryKeys.some((entry) => entry.truncated)) {
|
|
3997
4067
|
truncated.push("query_keys");
|
|
3998
4068
|
}
|
|
3999
|
-
if (allParameters.length > MAX_PARAMETERS || parameters.some((entry) => entry.truncated)) {
|
|
4069
|
+
if (!sawEveryParameter || allParameters.length > MAX_PARAMETERS || parameters.some((entry) => entry.truncated)) {
|
|
4000
4070
|
truncated.push("parameters");
|
|
4001
4071
|
}
|
|
4002
4072
|
const id = capText(String(ruleId), MAX_IDENTIFIER_CHARS);
|
|
4003
4073
|
const revision = capText(revisionOf(detection.rule) ?? "", MAX_IDENTIFIER_CHARS);
|
|
4004
4074
|
const etag = capText(rulesEtag ?? "", MAX_IDENTIFIER_CHARS);
|
|
4075
|
+
const classCategory = declaredClass(detection.rule?.category, recognisedCategory);
|
|
4076
|
+
const classAction = declaredClass(detection.rule?.action, recognisedAction);
|
|
4005
4077
|
for (const [name, field] of [
|
|
4006
4078
|
["rule_id", id],
|
|
4007
4079
|
["rule_revision", revision],
|
|
@@ -4009,6 +4081,12 @@ function createDetectionReporter(opts) {
|
|
|
4009
4081
|
]) {
|
|
4010
4082
|
if (field.truncated) truncated.push(name);
|
|
4011
4083
|
}
|
|
4084
|
+
for (const [name, field] of [
|
|
4085
|
+
["category", classCategory],
|
|
4086
|
+
["action", classAction]
|
|
4087
|
+
]) {
|
|
4088
|
+
if (field.dropped) truncated.push(name);
|
|
4089
|
+
}
|
|
4012
4090
|
queue.push({
|
|
4013
4091
|
rule_id: id.value,
|
|
4014
4092
|
route: route.value,
|
|
@@ -4017,7 +4095,10 @@ function createDetectionReporter(opts) {
|
|
|
4017
4095
|
...truncated.length > 0 ? { truncated } : {},
|
|
4018
4096
|
// Only when parameters were actually left out. Reporting a total because some OTHER field was
|
|
4019
4097
|
// shortened states that parameters were omitted when none were.
|
|
4020
|
-
|
|
4098
|
+
// Only when the walk saw the whole rule. Shortened by the cap, this is the true total; cut short
|
|
4099
|
+
// by the nesting bound, it is the count of what was seen, and sending that as the total states a
|
|
4100
|
+
// size nobody established. The mark travels either way, so a short list is always marked as one.
|
|
4101
|
+
...truncated.includes("parameters") && sawEveryParameter ? { parameters_total: allParameters.length } : {},
|
|
4021
4102
|
method: method === null ? null : method.value,
|
|
4022
4103
|
// The rest of the URL, as names only. `route` is the path; together they say what was requested
|
|
4023
4104
|
// without saying what was in it.
|
|
@@ -4026,7 +4107,32 @@ function createDetectionReporter(opts) {
|
|
|
4026
4107
|
...query.total > queryKeys.length ? { query_keys_total: query.total } : {},
|
|
4027
4108
|
// Who asked. Capped, since it is client-supplied text and this is an event with a size bound.
|
|
4028
4109
|
user_agent: userAgent === null ? null : userAgent.value,
|
|
4110
|
+
// What KIND of match this was: which phase it happened in, what class of thing the rule is for,
|
|
4111
|
+
// and what the rule DECLARES it does about it.
|
|
4112
|
+
//
|
|
4113
|
+
// Three separate facts, and none of them is `enforced` below. A rule declaring `block` while
|
|
4114
|
+
// observing reports exactly that — `action: 'block'`, `enforced: false` — which is what a
|
|
4115
|
+
// dry-run window consists of. Reading either off the other would describe such a window as
|
|
4116
|
+
// protection that never happened, or as rules that do nothing.
|
|
4117
|
+
//
|
|
4118
|
+
// `null` where the rule says nothing, never a guess. A consumer can tell "this rule is for
|
|
4119
|
+
// secret exposure" from "we cannot say what this rule is for", and a filled-in value would take
|
|
4120
|
+
// that distinction away for the sake of a tidier field.
|
|
4029
4121
|
phase: detection.phase ?? null,
|
|
4122
|
+
// `null` where the rule declared nothing, and also where what it declared could not be carried:
|
|
4123
|
+
// both are "we cannot say what this rule is for", which is a different fact from a class we do
|
|
4124
|
+
// know, and `truncated` distinguishes the second from the first.
|
|
4125
|
+
category: classCategory.value,
|
|
4126
|
+
action: classAction.value,
|
|
4127
|
+
// Which call this detection belongs to, so a consumer can tell one call two rules saw from two
|
|
4128
|
+
// separate calls. Two rules matching one call is the ordinary case — a rule that enforces and a
|
|
4129
|
+
// rule that only observes are meant to match the same thing — so without this, adding these up
|
|
4130
|
+
// reports one call more than once.
|
|
4131
|
+
//
|
|
4132
|
+
// Nothing about the request goes into it: it is only ever compared with other identities, so
|
|
4133
|
+
// deriving it from the address or the path would carry something about whoever made the request
|
|
4134
|
+
// into a place nothing needs it. That is a property of how it is minted, not of its shape.
|
|
4135
|
+
event: eventIdentity(detection.event),
|
|
4030
4136
|
// The state this detection was handled under, which is the whole point: `false` is a rule that
|
|
4031
4137
|
// saw traffic it would have stopped.
|
|
4032
4138
|
enforced: detection.mode === "block",
|
|
@@ -4197,6 +4303,19 @@ function reportingState(input) {
|
|
|
4197
4303
|
return { state: "on", reports: true };
|
|
4198
4304
|
}
|
|
4199
4305
|
|
|
4306
|
+
// src/protect/response-hardening.js
|
|
4307
|
+
var BODY_INDEPENDENT = Object.freeze(["response.status", "response.headers"]);
|
|
4308
|
+
var BODY_INDEPENDENT_PREFIX = "response.header.";
|
|
4309
|
+
var HEADER_ACTIONS = Object.freeze(["set-header", "remove-header", "harden-cookie"]);
|
|
4310
|
+
function hardensWithoutBody(rule) {
|
|
4311
|
+
if (!rule || !HEADER_ACTIONS.includes(rule.action)) return false;
|
|
4312
|
+
const { parameters, complete } = readRuleParameters(rule);
|
|
4313
|
+
if (!complete) return false;
|
|
4314
|
+
return parameters.every(
|
|
4315
|
+
(parameter) => BODY_INDEPENDENT.includes(parameter) || parameter.startsWith(BODY_INDEPENDENT_PREFIX)
|
|
4316
|
+
);
|
|
4317
|
+
}
|
|
4318
|
+
|
|
4200
4319
|
// src/protect/firewall-log.js
|
|
4201
4320
|
var DEFAULT_API_BASE = "https://api.patchstack.com";
|
|
4202
4321
|
var STOP_BUDGET_MS2 = 5e3;
|
|
@@ -4580,7 +4699,7 @@ async function createProtection(options = {}) {
|
|
|
4580
4699
|
let screenCap;
|
|
4581
4700
|
let engine;
|
|
4582
4701
|
let responseRuleSet;
|
|
4583
|
-
let
|
|
4702
|
+
let egressRuleSet;
|
|
4584
4703
|
const applyBundle = (delivered) => {
|
|
4585
4704
|
const incoming = delivered.firewall ?? [];
|
|
4586
4705
|
requestRules = byPhase(incoming, "request");
|
|
@@ -4607,7 +4726,10 @@ async function createProtection(options = {}) {
|
|
|
4607
4726
|
// the common case — cutting CPU/latency and shrinking the regex/ReDoS surface. Case-insensitive.
|
|
4608
4727
|
prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
|
|
4609
4728
|
}));
|
|
4610
|
-
|
|
4729
|
+
egressRuleSet = egressRules.map((rule) => ({
|
|
4730
|
+
rule,
|
|
4731
|
+
engine: new RuleEngine({ firewall: [rule], onError })
|
|
4732
|
+
}));
|
|
4611
4733
|
};
|
|
4612
4734
|
applyBundle(bundle);
|
|
4613
4735
|
const skipCounts = /* @__PURE__ */ Object.create(null);
|
|
@@ -4632,8 +4754,9 @@ async function createProtection(options = {}) {
|
|
|
4632
4754
|
if (!permitsAnything(entry.plan)) return { plan: entry.reference };
|
|
4633
4755
|
return { plan: entry.reference, ...captureValues(entry.plan, result.resolver) };
|
|
4634
4756
|
};
|
|
4635
|
-
const decide = (phase, result, block, allow2,
|
|
4757
|
+
const decide = (phase, result, block, allow2, describe = () => ({})) => {
|
|
4636
4758
|
if (!result || !result.blocked) return allow2();
|
|
4759
|
+
const ctx = describe() ?? {};
|
|
4637
4760
|
const effectiveMode = ruleMode(result.rule);
|
|
4638
4761
|
onDetect({
|
|
4639
4762
|
phase,
|
|
@@ -4646,6 +4769,9 @@ async function createProtection(options = {}) {
|
|
|
4646
4769
|
method: ctx.method,
|
|
4647
4770
|
path: ctx.path,
|
|
4648
4771
|
ip: ctx.ip,
|
|
4772
|
+
// Which call this was. Named here rather than spread from `ctx` for the same reason as everything
|
|
4773
|
+
// else in this payload: a field reaches the wire because someone listed it.
|
|
4774
|
+
event: ctx.event ?? null,
|
|
4649
4775
|
// Provenance travels with the address. Without it a consumer cannot tell an observed peer from a
|
|
4650
4776
|
// value read out of a forwarded header, and `null` from "there was no address to establish".
|
|
4651
4777
|
clientIpSource: ctx.clientIpSource,
|
|
@@ -4655,12 +4781,13 @@ async function createProtection(options = {}) {
|
|
|
4655
4781
|
});
|
|
4656
4782
|
return effectiveMode === "block" ? block() : allow2();
|
|
4657
4783
|
};
|
|
4658
|
-
const screenText = (text, meta, reqCtx) => {
|
|
4784
|
+
const screenText = (text, meta, reqCtx, only) => {
|
|
4659
4785
|
let blockRule = null;
|
|
4660
4786
|
const redactions = [];
|
|
4661
4787
|
const headerMutations = [];
|
|
4662
4788
|
let lowerText = null;
|
|
4663
4789
|
for (const { rule, engine: re, redactors, prefilter, mutatedSpan } of responseRuleSet) {
|
|
4790
|
+
if (only && !only(rule)) continue;
|
|
4664
4791
|
if (prefilter) {
|
|
4665
4792
|
if (lowerText === null) lowerText = text.toLowerCase();
|
|
4666
4793
|
if (!prefilter.some((p) => lowerText.includes(p))) continue;
|
|
@@ -4776,7 +4903,7 @@ async function createProtection(options = {}) {
|
|
|
4776
4903
|
result,
|
|
4777
4904
|
() => blockResponse(result, request),
|
|
4778
4905
|
() => null,
|
|
4779
|
-
requestMeta(shaped, request)
|
|
4906
|
+
() => requestMeta(shaped, request)
|
|
4780
4907
|
);
|
|
4781
4908
|
return { blocked, client: shaped?._clientIp };
|
|
4782
4909
|
};
|
|
@@ -4791,7 +4918,12 @@ async function createProtection(options = {}) {
|
|
|
4791
4918
|
originalUrl: u.pathname + u.search,
|
|
4792
4919
|
headers,
|
|
4793
4920
|
ip: resolved.ip ?? "",
|
|
4794
|
-
_clientIp: resolved
|
|
4921
|
+
_clientIp: resolved,
|
|
4922
|
+
// The request itself, so the identity can be asked for LATER. This context is built on every
|
|
4923
|
+
// request whether or not anything matches, so asking here would mint one for every request —
|
|
4924
|
+
// and asking of the context rather than of the request would give the response a different
|
|
4925
|
+
// identity from the request that caused it, reporting one call as two.
|
|
4926
|
+
_eventOf: request
|
|
4795
4927
|
};
|
|
4796
4928
|
} catch {
|
|
4797
4929
|
return void 0;
|
|
@@ -4802,13 +4934,17 @@ async function createProtection(options = {}) {
|
|
|
4802
4934
|
originalUrl: req.url,
|
|
4803
4935
|
headers: req.headers || {},
|
|
4804
4936
|
ip: client?.ip ?? "",
|
|
4805
|
-
_clientIp: client ?? { ip: null, source: "unavailable" }
|
|
4937
|
+
_clientIp: client ?? { ip: null, source: "unavailable" },
|
|
4938
|
+
// The request itself, for the same reasons as on the fetch path above: asked for later, and
|
|
4939
|
+
// asked of the request rather than of this context.
|
|
4940
|
+
_eventOf: req
|
|
4806
4941
|
} : void 0;
|
|
4807
4942
|
const screenResp = async (response, reqCtx) => {
|
|
4808
4943
|
const read = await readTextResponse(response, screenCap);
|
|
4809
4944
|
if (read.skip) {
|
|
4810
4945
|
if (read.skip !== "not-a-response") recordSkip("response", read.skip, { status: response?.status });
|
|
4811
|
-
return response;
|
|
4946
|
+
if (read.skip === "not-a-response") return response;
|
|
4947
|
+
return hardenHeadersOnly(response, reqCtx);
|
|
4812
4948
|
}
|
|
4813
4949
|
const text = read.text;
|
|
4814
4950
|
const r = screenText(text, { status: response.status, headers: headerObject(response.headers) }, reqCtx);
|
|
@@ -4816,6 +4952,74 @@ async function createProtection(options = {}) {
|
|
|
4816
4952
|
if (r.verdict === "redact") return rebuildResponse(response, r.body, r.headers);
|
|
4817
4953
|
return response;
|
|
4818
4954
|
};
|
|
4955
|
+
const headerValueChanged = (was, value) => {
|
|
4956
|
+
const had = was !== void 0 && was !== null;
|
|
4957
|
+
if (value === null || value === void 0) return had;
|
|
4958
|
+
if (!had) return true;
|
|
4959
|
+
if (Array.isArray(value) || Array.isArray(was)) {
|
|
4960
|
+
const a = Array.isArray(was) ? was : [was];
|
|
4961
|
+
const b = Array.isArray(value) ? value : [value];
|
|
4962
|
+
return a.length !== b.length || b.some((item, i) => String(item) !== String(a[i]));
|
|
4963
|
+
}
|
|
4964
|
+
return String(value) !== String(was);
|
|
4965
|
+
};
|
|
4966
|
+
const headersChanged = (before, after) => Object.entries(after).some(([name, value]) => headerValueChanged(before[name], value));
|
|
4967
|
+
const writeHeadHeaderIndex = (args) => {
|
|
4968
|
+
const at = typeof args[1] === "string" ? 2 : 1;
|
|
4969
|
+
const value = args[at];
|
|
4970
|
+
return value !== null && typeof value === "object" ? at : -1;
|
|
4971
|
+
};
|
|
4972
|
+
const writeHeadEntries = (headers) => {
|
|
4973
|
+
if (!Array.isArray(headers)) return Object.entries(headers);
|
|
4974
|
+
if (headers.length && headers.every((entry) => Array.isArray(entry) && entry.length === 2)) {
|
|
4975
|
+
return headers.map(([name, value]) => [String(name), value]);
|
|
4976
|
+
}
|
|
4977
|
+
const entries = [];
|
|
4978
|
+
for (let i = 0; i + 1 < headers.length; i += 2) entries.push([String(headers[i]), headers[i + 1]]);
|
|
4979
|
+
return entries;
|
|
4980
|
+
};
|
|
4981
|
+
const writeHeadHeaderObject = (headers) => {
|
|
4982
|
+
const out = {};
|
|
4983
|
+
for (const [name, value] of writeHeadEntries(headers)) {
|
|
4984
|
+
const key = name.toLowerCase();
|
|
4985
|
+
out[key] = Object.hasOwn(out, key) ? [].concat(out[key], value) : value;
|
|
4986
|
+
}
|
|
4987
|
+
return out;
|
|
4988
|
+
};
|
|
4989
|
+
const rewriteWriteHeadHeaders = (headers, changed) => {
|
|
4990
|
+
const replaced = /* @__PURE__ */ new Set();
|
|
4991
|
+
const entries = [];
|
|
4992
|
+
for (const [name, value] of writeHeadEntries(headers)) {
|
|
4993
|
+
const key = name.toLowerCase();
|
|
4994
|
+
if (!changed.has(key)) {
|
|
4995
|
+
entries.push([name, value]);
|
|
4996
|
+
continue;
|
|
4997
|
+
}
|
|
4998
|
+
if (replaced.has(key)) continue;
|
|
4999
|
+
replaced.add(key);
|
|
5000
|
+
const replacement = changed.get(key);
|
|
5001
|
+
if (replacement === null || replacement === void 0) continue;
|
|
5002
|
+
entries.push([name, replacement]);
|
|
5003
|
+
}
|
|
5004
|
+
if (!Array.isArray(headers)) return Object.fromEntries(entries);
|
|
5005
|
+
const paired = headers.length > 0 && headers.every((entry) => Array.isArray(entry) && entry.length === 2);
|
|
5006
|
+
const expanded = entries.flatMap(
|
|
5007
|
+
([name, value]) => Array.isArray(value) ? value.map((item) => [name, item]) : [[name, value]]
|
|
5008
|
+
);
|
|
5009
|
+
return paired ? expanded : expanded.flat();
|
|
5010
|
+
};
|
|
5011
|
+
const hardenHeadersOnly = (response, reqCtx) => {
|
|
5012
|
+
try {
|
|
5013
|
+
const meta = { status: response.status, headers: headerObject(response.headers) };
|
|
5014
|
+
const r = screenText("", meta, reqCtx, hardensWithoutBody);
|
|
5015
|
+
if (r.verdict !== "redact" || !r.headers) return response;
|
|
5016
|
+
if (!headersChanged(meta.headers, r.headers)) return response;
|
|
5017
|
+
return rebuildResponse(response, response.body, r.headers);
|
|
5018
|
+
} catch (err) {
|
|
5019
|
+
notify(onError, err, "onError");
|
|
5020
|
+
return response;
|
|
5021
|
+
}
|
|
5022
|
+
};
|
|
4819
5023
|
const wrapNodeResponse = (res, reqCtx) => {
|
|
4820
5024
|
const origWrite = res.write.bind(res);
|
|
4821
5025
|
const origEnd = res.end.bind(res);
|
|
@@ -4823,6 +5027,51 @@ async function createProtection(options = {}) {
|
|
|
4823
5027
|
let size = 0;
|
|
4824
5028
|
let overflow = false;
|
|
4825
5029
|
const MAX = screenCap;
|
|
5030
|
+
let hardened = false;
|
|
5031
|
+
let answeredWithoutBody = false;
|
|
5032
|
+
const hardenBeforeFlush = (effective) => {
|
|
5033
|
+
if (hardened) return null;
|
|
5034
|
+
hardened = true;
|
|
5035
|
+
try {
|
|
5036
|
+
if (res.headersSent || typeof res.setHeader !== "function") return null;
|
|
5037
|
+
const set = typeof res.getHeaders === "function" ? res.getHeaders() : {};
|
|
5038
|
+
const headers = effective && effective.headers ? { ...set, ...effective.headers } : set;
|
|
5039
|
+
const status = effective && effective.status !== void 0 ? effective.status : res.statusCode;
|
|
5040
|
+
const r = screenText("", { status, headers }, reqCtx, hardensWithoutBody);
|
|
5041
|
+
answeredWithoutBody = true;
|
|
5042
|
+
if (r.verdict !== "redact" || !r.headers) return null;
|
|
5043
|
+
const changed = /* @__PURE__ */ new Map();
|
|
5044
|
+
for (const [name, value] of Object.entries(r.headers)) {
|
|
5045
|
+
if (!headerValueChanged(headers[name], value)) continue;
|
|
5046
|
+
changed.set(name.toLowerCase(), value);
|
|
5047
|
+
try {
|
|
5048
|
+
if (value === null || value === void 0) res.removeHeader?.(name);
|
|
5049
|
+
else res.setHeader(name, value);
|
|
5050
|
+
} catch {
|
|
5051
|
+
}
|
|
5052
|
+
}
|
|
5053
|
+
return changed.size ? changed : null;
|
|
5054
|
+
} catch (err) {
|
|
5055
|
+
notify(onError, err, "onError");
|
|
5056
|
+
return null;
|
|
5057
|
+
}
|
|
5058
|
+
};
|
|
5059
|
+
const stillToAnswer = (rule) => !hardensWithoutBody(rule);
|
|
5060
|
+
if (typeof res.writeHead === "function") {
|
|
5061
|
+
const origWriteHead = res.writeHead.bind(res);
|
|
5062
|
+
res.writeHead = function(...args) {
|
|
5063
|
+
const at = writeHeadHeaderIndex(args);
|
|
5064
|
+
const given = at === -1 ? null : args[at];
|
|
5065
|
+
const changed = hardenBeforeFlush({
|
|
5066
|
+
status: typeof args[0] === "number" ? args[0] : void 0,
|
|
5067
|
+
headers: given ? writeHeadHeaderObject(given) : null
|
|
5068
|
+
});
|
|
5069
|
+
if (!changed || !given) return origWriteHead(...args);
|
|
5070
|
+
const rewritten = [...args];
|
|
5071
|
+
rewritten[at] = rewriteWriteHeadHeaders(given, changed);
|
|
5072
|
+
return origWriteHead(...rewritten);
|
|
5073
|
+
};
|
|
5074
|
+
}
|
|
4826
5075
|
const collect = (chunk, enc) => {
|
|
4827
5076
|
if (chunk == null) return;
|
|
4828
5077
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof enc === "string" ? enc : "utf8");
|
|
@@ -4838,6 +5087,7 @@ async function createProtection(options = {}) {
|
|
|
4838
5087
|
chunks.push(buf);
|
|
4839
5088
|
};
|
|
4840
5089
|
res.write = function(chunk, enc, cb) {
|
|
5090
|
+
hardenBeforeFlush();
|
|
4841
5091
|
if (overflow) return origWrite(chunk, enc, cb);
|
|
4842
5092
|
collect(chunk, enc);
|
|
4843
5093
|
if (typeof enc === "function") enc();
|
|
@@ -4845,6 +5095,7 @@ async function createProtection(options = {}) {
|
|
|
4845
5095
|
return true;
|
|
4846
5096
|
};
|
|
4847
5097
|
res.end = function(chunk, enc, cb) {
|
|
5098
|
+
hardenBeforeFlush();
|
|
4848
5099
|
if (typeof chunk === "function") {
|
|
4849
5100
|
cb = chunk;
|
|
4850
5101
|
chunk = void 0;
|
|
@@ -4871,7 +5122,12 @@ async function createProtection(options = {}) {
|
|
|
4871
5122
|
const text = buffer.toString("utf8");
|
|
4872
5123
|
let r;
|
|
4873
5124
|
try {
|
|
4874
|
-
r = screenText(
|
|
5125
|
+
r = screenText(
|
|
5126
|
+
text,
|
|
5127
|
+
{ status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} },
|
|
5128
|
+
reqCtx,
|
|
5129
|
+
answeredWithoutBody ? stillToAnswer : void 0
|
|
5130
|
+
);
|
|
4875
5131
|
} catch (err) {
|
|
4876
5132
|
notify(onError, err, "onError");
|
|
4877
5133
|
for (const c of chunks) origWrite(c);
|
|
@@ -4921,15 +5177,6 @@ async function createProtection(options = {}) {
|
|
|
4921
5177
|
const allow = new Set((options.allowHosts ?? []).map((h) => String(h).toLowerCase()));
|
|
4922
5178
|
const egressShouldBlock = (url, host, method) => {
|
|
4923
5179
|
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
5180
|
let egressPath = null;
|
|
4934
5181
|
try {
|
|
4935
5182
|
const u = new URL(url);
|
|
@@ -4937,17 +5184,39 @@ async function createProtection(options = {}) {
|
|
|
4937
5184
|
} catch {
|
|
4938
5185
|
egressPath = typeof url === "string" ? url : null;
|
|
4939
5186
|
}
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
|
|
4944
|
-
|
|
4945
|
-
|
|
4946
|
-
|
|
4947
|
-
|
|
4948
|
-
|
|
4949
|
-
|
|
4950
|
-
|
|
5187
|
+
const egressEvent = mintEvent();
|
|
5188
|
+
let block = false;
|
|
5189
|
+
for (const { rule, engine: re } of egressRuleSet) {
|
|
5190
|
+
let result;
|
|
5191
|
+
try {
|
|
5192
|
+
result = re.evaluate({ _egress: { url, host, method } });
|
|
5193
|
+
} catch (err) {
|
|
5194
|
+
notify(onError, err, "onError");
|
|
5195
|
+
continue;
|
|
5196
|
+
}
|
|
5197
|
+
if (!result.blocked) continue;
|
|
5198
|
+
const egressMode = ruleMode(rule);
|
|
5199
|
+
onDetect({
|
|
5200
|
+
phase: "egress",
|
|
5201
|
+
mode: egressMode,
|
|
5202
|
+
category: rule?.category,
|
|
5203
|
+
rule,
|
|
5204
|
+
message: result.message,
|
|
5205
|
+
// One identity for this outbound call, shared by every rule that matches it — the egress phase
|
|
5206
|
+
// evaluates all of them rather than stopping at the first, so without this two rules refusing one
|
|
5207
|
+
// call would count as two calls refused.
|
|
5208
|
+
//
|
|
5209
|
+
// Its own identity, not the identity of whatever request the application was serving when it made
|
|
5210
|
+
// the call. An outbound attempt is a thing that happened in its own right, and a call made outside
|
|
5211
|
+
// any request — a job, a timer — has no request to belong to.
|
|
5212
|
+
event: egressEvent,
|
|
5213
|
+
method: typeof method === "string" ? method : null,
|
|
5214
|
+
path: egressPath,
|
|
5215
|
+
capture: evidenceFrom(result)
|
|
5216
|
+
});
|
|
5217
|
+
if (egressMode === "block") block = true;
|
|
5218
|
+
}
|
|
5219
|
+
return block;
|
|
4951
5220
|
};
|
|
4952
5221
|
const protection = {
|
|
4953
5222
|
get mode() {
|
|
@@ -5016,7 +5285,7 @@ async function createProtection(options = {}) {
|
|
|
5016
5285
|
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
|
|
5017
5286
|
next();
|
|
5018
5287
|
},
|
|
5019
|
-
nodeRequestMeta(req, client)
|
|
5288
|
+
() => nodeRequestMeta(req, client)
|
|
5020
5289
|
);
|
|
5021
5290
|
};
|
|
5022
5291
|
},
|
|
@@ -5080,7 +5349,7 @@ async function createProtection(options = {}) {
|
|
|
5080
5349
|
if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, shaped?._clientIp));
|
|
5081
5350
|
next();
|
|
5082
5351
|
},
|
|
5083
|
-
nodeRequestMeta(req, shaped?._clientIp)
|
|
5352
|
+
() => nodeRequestMeta(req, shaped?._clientIp)
|
|
5084
5353
|
);
|
|
5085
5354
|
}
|
|
5086
5355
|
}
|
|
@@ -5554,11 +5823,37 @@ function defaultOnDetect({ phase, mode, category, rule, message }) {
|
|
|
5554
5823
|
const tag = mode === "block" ? "BLOCK" : "DETECT (dry-run)";
|
|
5555
5824
|
console.warn(`[patchstack] ${tag} phase=${phase ?? "request"} category=${category ?? "?"} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
|
|
5556
5825
|
}
|
|
5826
|
+
var eventIdentities = /* @__PURE__ */ new WeakMap();
|
|
5827
|
+
function mintEvent() {
|
|
5828
|
+
try {
|
|
5829
|
+
const bytes = globalThis.crypto?.getRandomValues?.(new Uint8Array(16));
|
|
5830
|
+
if (bytes) return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
5831
|
+
} catch {
|
|
5832
|
+
}
|
|
5833
|
+
try {
|
|
5834
|
+
let hex = Date.now().toString(16);
|
|
5835
|
+
while (hex.length < 32) hex += Math.floor(Math.random() * 4294967295).toString(16).padStart(8, "0");
|
|
5836
|
+
return hex.slice(0, 32);
|
|
5837
|
+
} catch {
|
|
5838
|
+
return null;
|
|
5839
|
+
}
|
|
5840
|
+
}
|
|
5841
|
+
function eventFor(request) {
|
|
5842
|
+
if (request === null || typeof request !== "object") return mintEvent();
|
|
5843
|
+
const existing = eventIdentities.get(request);
|
|
5844
|
+
if (existing !== void 0) return existing;
|
|
5845
|
+
const minted = mintEvent();
|
|
5846
|
+
if (minted !== null) eventIdentities.set(request, minted);
|
|
5847
|
+
return minted;
|
|
5848
|
+
}
|
|
5557
5849
|
function requestMetaFromContext(reqCtx) {
|
|
5558
5850
|
if (!reqCtx) return {};
|
|
5559
5851
|
const client = reqCtx._clientIp ?? { ip: null, source: "unavailable" };
|
|
5560
5852
|
return {
|
|
5561
5853
|
method: reqCtx.method ?? null,
|
|
5854
|
+
// The originating request's identity, so a response detection and the request detection for the same
|
|
5855
|
+
// request are one event rather than two. Asked for here, which is inside a detection being raised.
|
|
5856
|
+
event: eventFor(reqCtx._eventOf),
|
|
5562
5857
|
// Path AND query. The reporter is what drops the query's VALUES, keeping its parameter names, so
|
|
5563
5858
|
// trimming it here would leave a Fetch or response detection unable to say what was requested.
|
|
5564
5859
|
path: typeof reqCtx.originalUrl === "string" ? reqCtx.originalUrl : null,
|
|
@@ -5582,7 +5877,7 @@ function requestMeta(shaped, request) {
|
|
|
5582
5877
|
method = request.method ?? null;
|
|
5583
5878
|
userAgent = request.headers?.get?.("user-agent") ?? null;
|
|
5584
5879
|
}
|
|
5585
|
-
return { method, path, ip: client.ip, clientIpSource: client.source, userAgent };
|
|
5880
|
+
return { method, path, ip: client.ip, clientIpSource: client.source, userAgent, event: eventFor(request) };
|
|
5586
5881
|
}
|
|
5587
5882
|
function nodeRequestMeta(req, client) {
|
|
5588
5883
|
if (!req) return {};
|
|
@@ -5596,7 +5891,8 @@ function nodeRequestMeta(req, client) {
|
|
|
5596
5891
|
// verified, and a second derivation could disagree with the one the engine evaluated.
|
|
5597
5892
|
ip: resolved.ip,
|
|
5598
5893
|
clientIpSource: resolved.source,
|
|
5599
|
-
userAgent: typeof ua === "string" ? ua : Array.isArray(ua) ? ua[0] : null
|
|
5894
|
+
userAgent: typeof ua === "string" ? ua : Array.isArray(ua) ? ua[0] : null,
|
|
5895
|
+
event: eventFor(req)
|
|
5600
5896
|
};
|
|
5601
5897
|
}
|
|
5602
5898
|
export {
|