@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.cjs
CHANGED
|
@@ -3788,18 +3788,10 @@ function conditionsProblem(conditions, depth = 0) {
|
|
|
3788
3788
|
return null;
|
|
3789
3789
|
}
|
|
3790
3790
|
|
|
3791
|
-
// src/protect/
|
|
3792
|
-
|
|
3793
|
-
var CAPTURE_LIMITS = Object.freeze({
|
|
3794
|
-
/** Named and prefix values in one event, together. Raw has its own allowance. */
|
|
3795
|
-
capturedValues: 10,
|
|
3796
|
-
/** Characters of any single captured value. */
|
|
3797
|
-
valueChars: 512,
|
|
3798
|
-
/** Resolved values one prefix permission may contribute. */
|
|
3799
|
-
prefixValues: 5
|
|
3800
|
-
});
|
|
3801
|
-
function parametersOf(rule) {
|
|
3791
|
+
// src/protect/rule-parameters.js
|
|
3792
|
+
function readRuleParameters(rule) {
|
|
3802
3793
|
const out = /* @__PURE__ */ new Set();
|
|
3794
|
+
let complete = true;
|
|
3803
3795
|
const add = (parameter) => {
|
|
3804
3796
|
if (typeof parameter === "string" && parameter !== "rules") out.add(parameter);
|
|
3805
3797
|
};
|
|
@@ -3811,7 +3803,11 @@ function parametersOf(rule) {
|
|
|
3811
3803
|
add(parameter);
|
|
3812
3804
|
};
|
|
3813
3805
|
const walk2 = (conditions, depth) => {
|
|
3814
|
-
if (!Array.isArray(conditions)
|
|
3806
|
+
if (!Array.isArray(conditions)) return;
|
|
3807
|
+
if (depth > LIMITS.maxNestingDepth) {
|
|
3808
|
+
complete = false;
|
|
3809
|
+
return;
|
|
3810
|
+
}
|
|
3815
3811
|
for (const condition of conditions) {
|
|
3816
3812
|
if (!condition || typeof condition !== "object") continue;
|
|
3817
3813
|
collect(condition.parameter);
|
|
@@ -3819,8 +3815,22 @@ function parametersOf(rule) {
|
|
|
3819
3815
|
}
|
|
3820
3816
|
};
|
|
3821
3817
|
walk2(rule?.rule_v2, 0);
|
|
3822
|
-
return [...out];
|
|
3818
|
+
return { parameters: [...out], complete };
|
|
3819
|
+
}
|
|
3820
|
+
function ruleParameters(rule) {
|
|
3821
|
+
return readRuleParameters(rule).parameters;
|
|
3823
3822
|
}
|
|
3823
|
+
|
|
3824
|
+
// src/protect/capture-plan.js
|
|
3825
|
+
var NEVER_CAPTURABLE = /* @__PURE__ */ new Set(["response"]);
|
|
3826
|
+
var CAPTURE_LIMITS = Object.freeze({
|
|
3827
|
+
/** Named and prefix values in one event, together. Raw has its own allowance. */
|
|
3828
|
+
capturedValues: 10,
|
|
3829
|
+
/** Characters of any single captured value. */
|
|
3830
|
+
valueChars: 512,
|
|
3831
|
+
/** Resolved values one prefix permission may contribute. */
|
|
3832
|
+
prefixValues: 5
|
|
3833
|
+
});
|
|
3824
3834
|
function rawOptIn(rule) {
|
|
3825
3835
|
if (!rule || typeof rule !== "object" || !Object.hasOwn(rule, "capture")) return null;
|
|
3826
3836
|
const capture = rule.capture;
|
|
@@ -3838,7 +3848,7 @@ function derivePlan(rule) {
|
|
|
3838
3848
|
const named = /* @__PURE__ */ new Set();
|
|
3839
3849
|
const prefixes = /* @__PURE__ */ new Set();
|
|
3840
3850
|
if (enforceableRuleProblem(rule) !== null) return NOTHING;
|
|
3841
|
-
const parameters =
|
|
3851
|
+
const parameters = ruleParameters(rule).filter((parameter) => parameterProblem(parameter) === null);
|
|
3842
3852
|
for (const parameter of parameters) {
|
|
3843
3853
|
const dot = parameter.indexOf(".");
|
|
3844
3854
|
if (dot === -1) continue;
|
|
@@ -4550,9 +4560,23 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
4550
4560
|
title: "Private key in response body",
|
|
4551
4561
|
phase: "response",
|
|
4552
4562
|
category: "secret-exposure",
|
|
4553
|
-
|
|
4563
|
+
// Withheld: the key material sits after the marker and this pattern does not delimit it.
|
|
4564
|
+
action: "block",
|
|
4554
4565
|
prefilter: ["PRIVATE KEY"],
|
|
4555
|
-
|
|
4566
|
+
// Matches a PEM BEGIN line whose label contains `PRIVATE KEY`, with up to 32 further label
|
|
4567
|
+
// characters — uppercase letters, digits, spaces and hyphens — on either side of it. That covers
|
|
4568
|
+
// the enumerated types (`RSA`, `EC`, `OPENSSH`, `DSA`, `ENCRYPTED`), a label carrying words after
|
|
4569
|
+
// `PRIVATE KEY` (`PGP PRIVATE KEY BLOCK`), a hyphenated or otherwise unlisted type, and a bare
|
|
4570
|
+
// `-----BEGIN PRIVATE KEY-----`. `PUBLIC KEY` and `CERTIFICATE` do not match, and neither does
|
|
4571
|
+
// prose that mentions a private key without a BEGIN line.
|
|
4572
|
+
//
|
|
4573
|
+
// No footer required: a truncated response or an absent END marker does not make the material
|
|
4574
|
+
// above it less of a key. Two bounded character classes rather than a repeated group — a
|
|
4575
|
+
// quantifier inside a quantified group is the shape the engine refuses as a backtracking risk, and
|
|
4576
|
+
// a refused pattern is a rule that never fires.
|
|
4577
|
+
//
|
|
4578
|
+
// A lowercase label, or one longer than 32 characters on either side, is not matched.
|
|
4579
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/-----BEGIN [A-Z0-9 -]{0,32}PRIVATE KEY[A-Z0-9 -]{0,32}-----/" } }]
|
|
4556
4580
|
},
|
|
4557
4581
|
{
|
|
4558
4582
|
id: "resp-aws-access-key",
|
|
@@ -4629,26 +4653,65 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
4629
4653
|
title: "Database connection string with credentials in response body",
|
|
4630
4654
|
phase: "response",
|
|
4631
4655
|
category: "secret-exposure",
|
|
4632
|
-
|
|
4656
|
+
// Withheld. The credentials are only the first half of the disclosure — the host, port, database
|
|
4657
|
+
// name and query name the system they open — and a URI's own grammar admits commas, parentheses
|
|
4658
|
+
// and semicolons, so no end-of-URI character class delimits it in free text without either
|
|
4659
|
+
// stopping inside a real URI or consuming the punctuation around it. The pattern therefore
|
|
4660
|
+
// identifies the URI and the response is withheld rather than partly rewritten.
|
|
4661
|
+
action: "block",
|
|
4633
4662
|
prefilter: ["mongodb", "postgres", "mysql", "redis", "amqp"],
|
|
4634
|
-
|
|
4663
|
+
// A scheme this application connects with, a user, a password and an `@`. Credentials are
|
|
4664
|
+
// required, so a URL without them is not matched.
|
|
4665
|
+
//
|
|
4666
|
+
// The username is the userinfo grammar without `:`; the password is the same grammar with it. Every
|
|
4667
|
+
// other character either class admits may appear raw in a real credential, so a narrower one turns
|
|
4668
|
+
// a live credential into a rule that says nothing — `postgres://user:p;ss@host` is an ordinary DSN.
|
|
4669
|
+
//
|
|
4670
|
+
// The first raw colon separates username from password. Excluding it from the username makes the
|
|
4671
|
+
// separator unambiguous and keeps matching linear; the password continues to admit raw colons. A
|
|
4672
|
+
// username that contains a colon carries it as `%3A`.
|
|
4673
|
+
//
|
|
4674
|
+
// Both classes admitting `:` would leave every colon available as the separator, so a candidate
|
|
4675
|
+
// run with no `@` is re-split at every position. The screening cap does not bound that: a rule may
|
|
4676
|
+
// raise it with `max_bytes` or remove it with `bypass_limit`.
|
|
4677
|
+
//
|
|
4678
|
+
// What terminates a candidate is everything the grammar excludes: whitespace, `/`, `?`, `#`, `@`,
|
|
4679
|
+
// quotes, backslashes, angle and square brackets and braces. That is what keeps the run inside one
|
|
4680
|
+
// value. Expressed as "anything but `:`, `@`, `/` and whitespace" it crosses structure instead: in
|
|
4681
|
+
// `{"docs":"postgres://db.internal","contact":"user@example.com"}` it consumes the closing quote,
|
|
4682
|
+
// the comma and the next key, reaching the `:` and `@` of an unrelated property and withholding a
|
|
4683
|
+
// response that discloses nothing.
|
|
4684
|
+
//
|
|
4685
|
+
// The consequence is deliberate: a run of punctuation-joined text that parses as a credential URI
|
|
4686
|
+
// is treated as one. `postgres://db.internal;contact:admin@example.com` has username
|
|
4687
|
+
// `db.internal;contact`, password `admin` and host `example.com` — indistinguishable from a leak,
|
|
4688
|
+
// so it is withheld. Ordinary prose separates with whitespace, which terminates the candidate.
|
|
4689
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[A-Za-z0-9._~%!$&'()*+,;=-]+:[A-Za-z0-9._~%!$&'()*+,;=:-]+@/i" } }]
|
|
4635
4690
|
},
|
|
4636
4691
|
{
|
|
4637
4692
|
id: "resp-stack-trace",
|
|
4638
4693
|
title: "Node stack trace leaking in response body",
|
|
4639
4694
|
phase: "response",
|
|
4640
4695
|
category: "info-exposure",
|
|
4641
|
-
|
|
4696
|
+
// Withheld. A frame is recognised by its shape and the trace has no end the pattern can rely on,
|
|
4697
|
+
// so masking the frames it happens to match leaves the message, the remaining frames and every
|
|
4698
|
+
// path and line number in them.
|
|
4699
|
+
action: "block",
|
|
4642
4700
|
// No prefilter: a Node stack frame has no single distinctive literal (` at ` is too common to
|
|
4643
4701
|
// gate on). The pattern is linearly bounded per line, so it runs on every screened body.
|
|
4644
|
-
|
|
4702
|
+
//
|
|
4703
|
+
// Accepts a real newline and a JSON-escaped one. Most traces reach a client inside a JSON error
|
|
4704
|
+
// body, where the newline is the two characters `\` and `n`.
|
|
4705
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/(?:\\n|\\\\n)\\s*at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
|
|
4645
4706
|
},
|
|
4646
4707
|
{
|
|
4647
4708
|
id: "resp-sql-error",
|
|
4648
4709
|
title: "SQL / ORM error disclosure in response body",
|
|
4649
4710
|
phase: "response",
|
|
4650
4711
|
category: "info-exposure",
|
|
4651
|
-
|
|
4712
|
+
// Withheld. The signature is the start of the disclosure: masking `SQLSTATE[23000]` and serving
|
|
4713
|
+
// the constraint name, the column and the offending value discloses the schema anyway.
|
|
4714
|
+
action: "block",
|
|
4652
4715
|
prefilter: ["SQLSTATE", "Sequelize", "ER_", "ORA-", "PG::", "SQLITE_ERROR", "SQL syntax"],
|
|
4653
4716
|
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/(SQLSTATE\\[[0-9A-Z]+\\]|SequelizeDatabaseError|ER_[A-Z_]+|ORA-\\d{5}|PG::[A-Za-z]+Error|SQLITE_ERROR|You have an error in your SQL syntax)/i" } }]
|
|
4654
4717
|
},
|
|
@@ -4657,7 +4720,9 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
4657
4720
|
title: "Backend exception / stack trace disclosure in response body",
|
|
4658
4721
|
phase: "response",
|
|
4659
4722
|
category: "info-exposure",
|
|
4660
|
-
|
|
4723
|
+
// Withheld, for the same reason as the trace above: the marker opens the dump and the file names,
|
|
4724
|
+
// line numbers and frames after it are the disclosure.
|
|
4725
|
+
action: "block",
|
|
4661
4726
|
// Multi-language exception/traceback signatures a normal API response never carries:
|
|
4662
4727
|
// Python traceback, Java "Exception in thread", .NET System.*Exception, JVM stack frames,
|
|
4663
4728
|
// Go goroutine dumps. (Node `at fn (file:line:col)` frames are handled by resp-stack-trace.)
|
|
@@ -4671,6 +4736,11 @@ var DEFAULT_EGRESS_RULES = [
|
|
|
4671
4736
|
title: "Outbound request to an internal / metadata address (SSRF)",
|
|
4672
4737
|
phase: "egress",
|
|
4673
4738
|
category: "ssrf",
|
|
4739
|
+
// Declared, not implied. Nothing in the egress path reads it — a match refuses the call, and the
|
|
4740
|
+
// rule's mode decides whether it actually did — but the action is part of how a rule describes
|
|
4741
|
+
// itself to whatever reports on it, and a rule that declares nothing is reported as a rule nobody
|
|
4742
|
+
// can classify.
|
|
4743
|
+
action: "block",
|
|
4674
4744
|
rule_v2: [{ parameter: "egress.host", match: { type: "internal_host" } }]
|
|
4675
4745
|
}
|
|
4676
4746
|
];
|
|
@@ -5038,10 +5108,25 @@ function worthRetrying(status) {
|
|
|
5038
5108
|
}
|
|
5039
5109
|
var ATTEMPT_TIMEOUT_MS = 1e4;
|
|
5040
5110
|
var STOP_BUDGET_MS = 5e3;
|
|
5111
|
+
var CATEGORY_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
5112
|
+
var RESERVED_CATEGORY = "unknown";
|
|
5113
|
+
function declaredClass(declared, recognised) {
|
|
5114
|
+
if (typeof declared !== "string" || declared === "") return { value: null, dropped: false };
|
|
5115
|
+
if (!recognised(declared)) return { value: null, dropped: false };
|
|
5116
|
+
if (declared.length > MAX_CLASS_CHARS) return { value: null, dropped: true };
|
|
5117
|
+
return { value: declared, dropped: false };
|
|
5118
|
+
}
|
|
5119
|
+
var recognisedCategory = (value) => value !== RESERVED_CATEGORY && CATEGORY_PATTERN.test(value);
|
|
5120
|
+
var recognisedAction = (value) => ACTIONS.includes(value);
|
|
5041
5121
|
var MAX_ROUTE_CHARS = 256;
|
|
5042
5122
|
var MAX_PARAMETERS = 25;
|
|
5043
5123
|
var MAX_PARAMETER_CHARS = 64;
|
|
5044
5124
|
var MAX_IDENTIFIER_CHARS = 256;
|
|
5125
|
+
var MAX_CLASS_CHARS = 64;
|
|
5126
|
+
var EVENT_PATTERN = /^[0-9a-f]{32}$/;
|
|
5127
|
+
function eventIdentity(value) {
|
|
5128
|
+
return typeof value === "string" && EVENT_PATTERN.test(value) ? value : null;
|
|
5129
|
+
}
|
|
5045
5130
|
var MAX_CAPTURED_VALUES = 10;
|
|
5046
5131
|
var MAX_CAPTURED_VALUE_CHARS = 512;
|
|
5047
5132
|
var MAX_QUERY_KEYS = 10;
|
|
@@ -5186,21 +5271,6 @@ function unattended(timer) {
|
|
|
5186
5271
|
if (timer && typeof timer.unref === "function") timer.unref();
|
|
5187
5272
|
return timer;
|
|
5188
5273
|
}
|
|
5189
|
-
function ruleParameters(rule) {
|
|
5190
|
-
const out = /* @__PURE__ */ new Set();
|
|
5191
|
-
const walk2 = (conditions) => {
|
|
5192
|
-
if (!Array.isArray(conditions)) return;
|
|
5193
|
-
for (const condition of conditions) {
|
|
5194
|
-
if (!condition || typeof condition !== "object") continue;
|
|
5195
|
-
if (typeof condition.parameter === "string" && condition.parameter !== "rules") {
|
|
5196
|
-
out.add(condition.parameter);
|
|
5197
|
-
}
|
|
5198
|
-
if (Array.isArray(condition.rules)) walk2(condition.rules);
|
|
5199
|
-
}
|
|
5200
|
-
};
|
|
5201
|
-
walk2(rule?.rule_v2);
|
|
5202
|
-
return [...out];
|
|
5203
|
-
}
|
|
5204
5274
|
function revisionOf(rule) {
|
|
5205
5275
|
const revision = rule?.source_revision;
|
|
5206
5276
|
if (typeof revision === "string" && revision !== "") return revision;
|
|
@@ -5501,7 +5571,7 @@ function createDetectionReporter(opts) {
|
|
|
5501
5571
|
const capture = boundCapture(detection.capture);
|
|
5502
5572
|
const rawRoute = routeOf(detection.path);
|
|
5503
5573
|
const route = typeof rawRoute === "string" ? capText(rawRoute, MAX_ROUTE_CHARS) : { value: rawRoute, truncated: false };
|
|
5504
|
-
const allParameters =
|
|
5574
|
+
const { parameters: allParameters, complete: sawEveryParameter } = readRuleParameters(detection.rule);
|
|
5505
5575
|
const parameters = allParameters.slice(0, MAX_PARAMETERS).map((name) => capText(name, MAX_PARAMETER_CHARS));
|
|
5506
5576
|
const truncated = [];
|
|
5507
5577
|
if (route.truncated) truncated.push("route");
|
|
@@ -5510,12 +5580,14 @@ function createDetectionReporter(opts) {
|
|
|
5510
5580
|
if (query.total > queryKeys.length || queryKeys.some((entry) => entry.truncated)) {
|
|
5511
5581
|
truncated.push("query_keys");
|
|
5512
5582
|
}
|
|
5513
|
-
if (allParameters.length > MAX_PARAMETERS || parameters.some((entry) => entry.truncated)) {
|
|
5583
|
+
if (!sawEveryParameter || allParameters.length > MAX_PARAMETERS || parameters.some((entry) => entry.truncated)) {
|
|
5514
5584
|
truncated.push("parameters");
|
|
5515
5585
|
}
|
|
5516
5586
|
const id = capText(String(ruleId), MAX_IDENTIFIER_CHARS);
|
|
5517
5587
|
const revision = capText(revisionOf(detection.rule) ?? "", MAX_IDENTIFIER_CHARS);
|
|
5518
5588
|
const etag = capText(rulesEtag ?? "", MAX_IDENTIFIER_CHARS);
|
|
5589
|
+
const classCategory = declaredClass(detection.rule?.category, recognisedCategory);
|
|
5590
|
+
const classAction = declaredClass(detection.rule?.action, recognisedAction);
|
|
5519
5591
|
for (const [name, field] of [
|
|
5520
5592
|
["rule_id", id],
|
|
5521
5593
|
["rule_revision", revision],
|
|
@@ -5523,6 +5595,12 @@ function createDetectionReporter(opts) {
|
|
|
5523
5595
|
]) {
|
|
5524
5596
|
if (field.truncated) truncated.push(name);
|
|
5525
5597
|
}
|
|
5598
|
+
for (const [name, field] of [
|
|
5599
|
+
["category", classCategory],
|
|
5600
|
+
["action", classAction]
|
|
5601
|
+
]) {
|
|
5602
|
+
if (field.dropped) truncated.push(name);
|
|
5603
|
+
}
|
|
5526
5604
|
queue.push({
|
|
5527
5605
|
rule_id: id.value,
|
|
5528
5606
|
route: route.value,
|
|
@@ -5531,7 +5609,10 @@ function createDetectionReporter(opts) {
|
|
|
5531
5609
|
...truncated.length > 0 ? { truncated } : {},
|
|
5532
5610
|
// Only when parameters were actually left out. Reporting a total because some OTHER field was
|
|
5533
5611
|
// shortened states that parameters were omitted when none were.
|
|
5534
|
-
|
|
5612
|
+
// Only when the walk saw the whole rule. Shortened by the cap, this is the true total; cut short
|
|
5613
|
+
// by the nesting bound, it is the count of what was seen, and sending that as the total states a
|
|
5614
|
+
// size nobody established. The mark travels either way, so a short list is always marked as one.
|
|
5615
|
+
...truncated.includes("parameters") && sawEveryParameter ? { parameters_total: allParameters.length } : {},
|
|
5535
5616
|
method: method === null ? null : method.value,
|
|
5536
5617
|
// The rest of the URL, as names only. `route` is the path; together they say what was requested
|
|
5537
5618
|
// without saying what was in it.
|
|
@@ -5540,7 +5621,32 @@ function createDetectionReporter(opts) {
|
|
|
5540
5621
|
...query.total > queryKeys.length ? { query_keys_total: query.total } : {},
|
|
5541
5622
|
// Who asked. Capped, since it is client-supplied text and this is an event with a size bound.
|
|
5542
5623
|
user_agent: userAgent === null ? null : userAgent.value,
|
|
5624
|
+
// What KIND of match this was: which phase it happened in, what class of thing the rule is for,
|
|
5625
|
+
// and what the rule DECLARES it does about it.
|
|
5626
|
+
//
|
|
5627
|
+
// Three separate facts, and none of them is `enforced` below. A rule declaring `block` while
|
|
5628
|
+
// observing reports exactly that — `action: 'block'`, `enforced: false` — which is what a
|
|
5629
|
+
// dry-run window consists of. Reading either off the other would describe such a window as
|
|
5630
|
+
// protection that never happened, or as rules that do nothing.
|
|
5631
|
+
//
|
|
5632
|
+
// `null` where the rule says nothing, never a guess. A consumer can tell "this rule is for
|
|
5633
|
+
// secret exposure" from "we cannot say what this rule is for", and a filled-in value would take
|
|
5634
|
+
// that distinction away for the sake of a tidier field.
|
|
5543
5635
|
phase: detection.phase ?? null,
|
|
5636
|
+
// `null` where the rule declared nothing, and also where what it declared could not be carried:
|
|
5637
|
+
// both are "we cannot say what this rule is for", which is a different fact from a class we do
|
|
5638
|
+
// know, and `truncated` distinguishes the second from the first.
|
|
5639
|
+
category: classCategory.value,
|
|
5640
|
+
action: classAction.value,
|
|
5641
|
+
// Which call this detection belongs to, so a consumer can tell one call two rules saw from two
|
|
5642
|
+
// separate calls. Two rules matching one call is the ordinary case — a rule that enforces and a
|
|
5643
|
+
// rule that only observes are meant to match the same thing — so without this, adding these up
|
|
5644
|
+
// reports one call more than once.
|
|
5645
|
+
//
|
|
5646
|
+
// Nothing about the request goes into it: it is only ever compared with other identities, so
|
|
5647
|
+
// deriving it from the address or the path would carry something about whoever made the request
|
|
5648
|
+
// into a place nothing needs it. That is a property of how it is minted, not of its shape.
|
|
5649
|
+
event: eventIdentity(detection.event),
|
|
5544
5650
|
// The state this detection was handled under, which is the whole point: `false` is a rule that
|
|
5545
5651
|
// saw traffic it would have stopped.
|
|
5546
5652
|
enforced: detection.mode === "block",
|
|
@@ -5711,6 +5817,19 @@ function reportingState(input) {
|
|
|
5711
5817
|
return { state: "on", reports: true };
|
|
5712
5818
|
}
|
|
5713
5819
|
|
|
5820
|
+
// src/protect/response-hardening.js
|
|
5821
|
+
var BODY_INDEPENDENT = Object.freeze(["response.status", "response.headers"]);
|
|
5822
|
+
var BODY_INDEPENDENT_PREFIX = "response.header.";
|
|
5823
|
+
var HEADER_ACTIONS = Object.freeze(["set-header", "remove-header", "harden-cookie"]);
|
|
5824
|
+
function hardensWithoutBody(rule) {
|
|
5825
|
+
if (!rule || !HEADER_ACTIONS.includes(rule.action)) return false;
|
|
5826
|
+
const { parameters, complete } = readRuleParameters(rule);
|
|
5827
|
+
if (!complete) return false;
|
|
5828
|
+
return parameters.every(
|
|
5829
|
+
(parameter) => BODY_INDEPENDENT.includes(parameter) || parameter.startsWith(BODY_INDEPENDENT_PREFIX)
|
|
5830
|
+
);
|
|
5831
|
+
}
|
|
5832
|
+
|
|
5714
5833
|
// src/protect/firewall-log.js
|
|
5715
5834
|
var DEFAULT_API_BASE = "https://api.patchstack.com";
|
|
5716
5835
|
var STOP_BUDGET_MS2 = 5e3;
|
|
@@ -6094,7 +6213,7 @@ async function createProtection(options = {}) {
|
|
|
6094
6213
|
let screenCap;
|
|
6095
6214
|
let engine;
|
|
6096
6215
|
let responseRuleSet;
|
|
6097
|
-
let
|
|
6216
|
+
let egressRuleSet;
|
|
6098
6217
|
const applyBundle = (delivered) => {
|
|
6099
6218
|
const incoming = delivered.firewall ?? [];
|
|
6100
6219
|
requestRules = byPhase(incoming, "request");
|
|
@@ -6121,7 +6240,10 @@ async function createProtection(options = {}) {
|
|
|
6121
6240
|
// the common case — cutting CPU/latency and shrinking the regex/ReDoS surface. Case-insensitive.
|
|
6122
6241
|
prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
|
|
6123
6242
|
}));
|
|
6124
|
-
|
|
6243
|
+
egressRuleSet = egressRules.map((rule) => ({
|
|
6244
|
+
rule,
|
|
6245
|
+
engine: new RuleEngine({ firewall: [rule], onError })
|
|
6246
|
+
}));
|
|
6125
6247
|
};
|
|
6126
6248
|
applyBundle(bundle);
|
|
6127
6249
|
const skipCounts = /* @__PURE__ */ Object.create(null);
|
|
@@ -6146,8 +6268,9 @@ async function createProtection(options = {}) {
|
|
|
6146
6268
|
if (!permitsAnything(entry.plan)) return { plan: entry.reference };
|
|
6147
6269
|
return { plan: entry.reference, ...captureValues(entry.plan, result.resolver) };
|
|
6148
6270
|
};
|
|
6149
|
-
const decide = (phase, result, block, allow2,
|
|
6271
|
+
const decide = (phase, result, block, allow2, describe = () => ({})) => {
|
|
6150
6272
|
if (!result || !result.blocked) return allow2();
|
|
6273
|
+
const ctx = describe() ?? {};
|
|
6151
6274
|
const effectiveMode = ruleMode(result.rule);
|
|
6152
6275
|
onDetect({
|
|
6153
6276
|
phase,
|
|
@@ -6160,6 +6283,9 @@ async function createProtection(options = {}) {
|
|
|
6160
6283
|
method: ctx.method,
|
|
6161
6284
|
path: ctx.path,
|
|
6162
6285
|
ip: ctx.ip,
|
|
6286
|
+
// Which call this was. Named here rather than spread from `ctx` for the same reason as everything
|
|
6287
|
+
// else in this payload: a field reaches the wire because someone listed it.
|
|
6288
|
+
event: ctx.event ?? null,
|
|
6163
6289
|
// Provenance travels with the address. Without it a consumer cannot tell an observed peer from a
|
|
6164
6290
|
// value read out of a forwarded header, and `null` from "there was no address to establish".
|
|
6165
6291
|
clientIpSource: ctx.clientIpSource,
|
|
@@ -6169,12 +6295,13 @@ async function createProtection(options = {}) {
|
|
|
6169
6295
|
});
|
|
6170
6296
|
return effectiveMode === "block" ? block() : allow2();
|
|
6171
6297
|
};
|
|
6172
|
-
const screenText = (text, meta, reqCtx) => {
|
|
6298
|
+
const screenText = (text, meta, reqCtx, only) => {
|
|
6173
6299
|
let blockRule = null;
|
|
6174
6300
|
const redactions = [];
|
|
6175
6301
|
const headerMutations = [];
|
|
6176
6302
|
let lowerText = null;
|
|
6177
6303
|
for (const { rule, engine: re, redactors, prefilter, mutatedSpan } of responseRuleSet) {
|
|
6304
|
+
if (only && !only(rule)) continue;
|
|
6178
6305
|
if (prefilter) {
|
|
6179
6306
|
if (lowerText === null) lowerText = text.toLowerCase();
|
|
6180
6307
|
if (!prefilter.some((p) => lowerText.includes(p))) continue;
|
|
@@ -6290,7 +6417,7 @@ async function createProtection(options = {}) {
|
|
|
6290
6417
|
result,
|
|
6291
6418
|
() => blockResponse(result, request),
|
|
6292
6419
|
() => null,
|
|
6293
|
-
requestMeta(shaped, request)
|
|
6420
|
+
() => requestMeta(shaped, request)
|
|
6294
6421
|
);
|
|
6295
6422
|
return { blocked, client: shaped?._clientIp };
|
|
6296
6423
|
};
|
|
@@ -6305,7 +6432,12 @@ async function createProtection(options = {}) {
|
|
|
6305
6432
|
originalUrl: u.pathname + u.search,
|
|
6306
6433
|
headers,
|
|
6307
6434
|
ip: resolved.ip ?? "",
|
|
6308
|
-
_clientIp: resolved
|
|
6435
|
+
_clientIp: resolved,
|
|
6436
|
+
// The request itself, so the identity can be asked for LATER. This context is built on every
|
|
6437
|
+
// request whether or not anything matches, so asking here would mint one for every request —
|
|
6438
|
+
// and asking of the context rather than of the request would give the response a different
|
|
6439
|
+
// identity from the request that caused it, reporting one call as two.
|
|
6440
|
+
_eventOf: request
|
|
6309
6441
|
};
|
|
6310
6442
|
} catch {
|
|
6311
6443
|
return void 0;
|
|
@@ -6316,13 +6448,17 @@ async function createProtection(options = {}) {
|
|
|
6316
6448
|
originalUrl: req.url,
|
|
6317
6449
|
headers: req.headers || {},
|
|
6318
6450
|
ip: client?.ip ?? "",
|
|
6319
|
-
_clientIp: client ?? { ip: null, source: "unavailable" }
|
|
6451
|
+
_clientIp: client ?? { ip: null, source: "unavailable" },
|
|
6452
|
+
// The request itself, for the same reasons as on the fetch path above: asked for later, and
|
|
6453
|
+
// asked of the request rather than of this context.
|
|
6454
|
+
_eventOf: req
|
|
6320
6455
|
} : void 0;
|
|
6321
6456
|
const screenResp = async (response, reqCtx) => {
|
|
6322
6457
|
const read = await readTextResponse(response, screenCap);
|
|
6323
6458
|
if (read.skip) {
|
|
6324
6459
|
if (read.skip !== "not-a-response") recordSkip("response", read.skip, { status: response?.status });
|
|
6325
|
-
return response;
|
|
6460
|
+
if (read.skip === "not-a-response") return response;
|
|
6461
|
+
return hardenHeadersOnly(response, reqCtx);
|
|
6326
6462
|
}
|
|
6327
6463
|
const text = read.text;
|
|
6328
6464
|
const r = screenText(text, { status: response.status, headers: headerObject(response.headers) }, reqCtx);
|
|
@@ -6330,6 +6466,74 @@ async function createProtection(options = {}) {
|
|
|
6330
6466
|
if (r.verdict === "redact") return rebuildResponse(response, r.body, r.headers);
|
|
6331
6467
|
return response;
|
|
6332
6468
|
};
|
|
6469
|
+
const headerValueChanged = (was, value) => {
|
|
6470
|
+
const had = was !== void 0 && was !== null;
|
|
6471
|
+
if (value === null || value === void 0) return had;
|
|
6472
|
+
if (!had) return true;
|
|
6473
|
+
if (Array.isArray(value) || Array.isArray(was)) {
|
|
6474
|
+
const a = Array.isArray(was) ? was : [was];
|
|
6475
|
+
const b = Array.isArray(value) ? value : [value];
|
|
6476
|
+
return a.length !== b.length || b.some((item, i) => String(item) !== String(a[i]));
|
|
6477
|
+
}
|
|
6478
|
+
return String(value) !== String(was);
|
|
6479
|
+
};
|
|
6480
|
+
const headersChanged = (before, after) => Object.entries(after).some(([name, value]) => headerValueChanged(before[name], value));
|
|
6481
|
+
const writeHeadHeaderIndex = (args) => {
|
|
6482
|
+
const at = typeof args[1] === "string" ? 2 : 1;
|
|
6483
|
+
const value = args[at];
|
|
6484
|
+
return value !== null && typeof value === "object" ? at : -1;
|
|
6485
|
+
};
|
|
6486
|
+
const writeHeadEntries = (headers) => {
|
|
6487
|
+
if (!Array.isArray(headers)) return Object.entries(headers);
|
|
6488
|
+
if (headers.length && headers.every((entry) => Array.isArray(entry) && entry.length === 2)) {
|
|
6489
|
+
return headers.map(([name, value]) => [String(name), value]);
|
|
6490
|
+
}
|
|
6491
|
+
const entries = [];
|
|
6492
|
+
for (let i = 0; i + 1 < headers.length; i += 2) entries.push([String(headers[i]), headers[i + 1]]);
|
|
6493
|
+
return entries;
|
|
6494
|
+
};
|
|
6495
|
+
const writeHeadHeaderObject = (headers) => {
|
|
6496
|
+
const out = {};
|
|
6497
|
+
for (const [name, value] of writeHeadEntries(headers)) {
|
|
6498
|
+
const key = name.toLowerCase();
|
|
6499
|
+
out[key] = Object.hasOwn(out, key) ? [].concat(out[key], value) : value;
|
|
6500
|
+
}
|
|
6501
|
+
return out;
|
|
6502
|
+
};
|
|
6503
|
+
const rewriteWriteHeadHeaders = (headers, changed) => {
|
|
6504
|
+
const replaced = /* @__PURE__ */ new Set();
|
|
6505
|
+
const entries = [];
|
|
6506
|
+
for (const [name, value] of writeHeadEntries(headers)) {
|
|
6507
|
+
const key = name.toLowerCase();
|
|
6508
|
+
if (!changed.has(key)) {
|
|
6509
|
+
entries.push([name, value]);
|
|
6510
|
+
continue;
|
|
6511
|
+
}
|
|
6512
|
+
if (replaced.has(key)) continue;
|
|
6513
|
+
replaced.add(key);
|
|
6514
|
+
const replacement = changed.get(key);
|
|
6515
|
+
if (replacement === null || replacement === void 0) continue;
|
|
6516
|
+
entries.push([name, replacement]);
|
|
6517
|
+
}
|
|
6518
|
+
if (!Array.isArray(headers)) return Object.fromEntries(entries);
|
|
6519
|
+
const paired = headers.length > 0 && headers.every((entry) => Array.isArray(entry) && entry.length === 2);
|
|
6520
|
+
const expanded = entries.flatMap(
|
|
6521
|
+
([name, value]) => Array.isArray(value) ? value.map((item) => [name, item]) : [[name, value]]
|
|
6522
|
+
);
|
|
6523
|
+
return paired ? expanded : expanded.flat();
|
|
6524
|
+
};
|
|
6525
|
+
const hardenHeadersOnly = (response, reqCtx) => {
|
|
6526
|
+
try {
|
|
6527
|
+
const meta = { status: response.status, headers: headerObject(response.headers) };
|
|
6528
|
+
const r = screenText("", meta, reqCtx, hardensWithoutBody);
|
|
6529
|
+
if (r.verdict !== "redact" || !r.headers) return response;
|
|
6530
|
+
if (!headersChanged(meta.headers, r.headers)) return response;
|
|
6531
|
+
return rebuildResponse(response, response.body, r.headers);
|
|
6532
|
+
} catch (err) {
|
|
6533
|
+
notify(onError, err, "onError");
|
|
6534
|
+
return response;
|
|
6535
|
+
}
|
|
6536
|
+
};
|
|
6333
6537
|
const wrapNodeResponse = (res, reqCtx) => {
|
|
6334
6538
|
const origWrite = res.write.bind(res);
|
|
6335
6539
|
const origEnd = res.end.bind(res);
|
|
@@ -6337,6 +6541,51 @@ async function createProtection(options = {}) {
|
|
|
6337
6541
|
let size = 0;
|
|
6338
6542
|
let overflow = false;
|
|
6339
6543
|
const MAX = screenCap;
|
|
6544
|
+
let hardened = false;
|
|
6545
|
+
let answeredWithoutBody = false;
|
|
6546
|
+
const hardenBeforeFlush = (effective) => {
|
|
6547
|
+
if (hardened) return null;
|
|
6548
|
+
hardened = true;
|
|
6549
|
+
try {
|
|
6550
|
+
if (res.headersSent || typeof res.setHeader !== "function") return null;
|
|
6551
|
+
const set = typeof res.getHeaders === "function" ? res.getHeaders() : {};
|
|
6552
|
+
const headers = effective && effective.headers ? { ...set, ...effective.headers } : set;
|
|
6553
|
+
const status = effective && effective.status !== void 0 ? effective.status : res.statusCode;
|
|
6554
|
+
const r = screenText("", { status, headers }, reqCtx, hardensWithoutBody);
|
|
6555
|
+
answeredWithoutBody = true;
|
|
6556
|
+
if (r.verdict !== "redact" || !r.headers) return null;
|
|
6557
|
+
const changed = /* @__PURE__ */ new Map();
|
|
6558
|
+
for (const [name, value] of Object.entries(r.headers)) {
|
|
6559
|
+
if (!headerValueChanged(headers[name], value)) continue;
|
|
6560
|
+
changed.set(name.toLowerCase(), value);
|
|
6561
|
+
try {
|
|
6562
|
+
if (value === null || value === void 0) res.removeHeader?.(name);
|
|
6563
|
+
else res.setHeader(name, value);
|
|
6564
|
+
} catch {
|
|
6565
|
+
}
|
|
6566
|
+
}
|
|
6567
|
+
return changed.size ? changed : null;
|
|
6568
|
+
} catch (err) {
|
|
6569
|
+
notify(onError, err, "onError");
|
|
6570
|
+
return null;
|
|
6571
|
+
}
|
|
6572
|
+
};
|
|
6573
|
+
const stillToAnswer = (rule) => !hardensWithoutBody(rule);
|
|
6574
|
+
if (typeof res.writeHead === "function") {
|
|
6575
|
+
const origWriteHead = res.writeHead.bind(res);
|
|
6576
|
+
res.writeHead = function(...args) {
|
|
6577
|
+
const at = writeHeadHeaderIndex(args);
|
|
6578
|
+
const given = at === -1 ? null : args[at];
|
|
6579
|
+
const changed = hardenBeforeFlush({
|
|
6580
|
+
status: typeof args[0] === "number" ? args[0] : void 0,
|
|
6581
|
+
headers: given ? writeHeadHeaderObject(given) : null
|
|
6582
|
+
});
|
|
6583
|
+
if (!changed || !given) return origWriteHead(...args);
|
|
6584
|
+
const rewritten = [...args];
|
|
6585
|
+
rewritten[at] = rewriteWriteHeadHeaders(given, changed);
|
|
6586
|
+
return origWriteHead(...rewritten);
|
|
6587
|
+
};
|
|
6588
|
+
}
|
|
6340
6589
|
const collect = (chunk, enc) => {
|
|
6341
6590
|
if (chunk == null) return;
|
|
6342
6591
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof enc === "string" ? enc : "utf8");
|
|
@@ -6352,6 +6601,7 @@ async function createProtection(options = {}) {
|
|
|
6352
6601
|
chunks.push(buf);
|
|
6353
6602
|
};
|
|
6354
6603
|
res.write = function(chunk, enc, cb) {
|
|
6604
|
+
hardenBeforeFlush();
|
|
6355
6605
|
if (overflow) return origWrite(chunk, enc, cb);
|
|
6356
6606
|
collect(chunk, enc);
|
|
6357
6607
|
if (typeof enc === "function") enc();
|
|
@@ -6359,6 +6609,7 @@ async function createProtection(options = {}) {
|
|
|
6359
6609
|
return true;
|
|
6360
6610
|
};
|
|
6361
6611
|
res.end = function(chunk, enc, cb) {
|
|
6612
|
+
hardenBeforeFlush();
|
|
6362
6613
|
if (typeof chunk === "function") {
|
|
6363
6614
|
cb = chunk;
|
|
6364
6615
|
chunk = void 0;
|
|
@@ -6385,7 +6636,12 @@ async function createProtection(options = {}) {
|
|
|
6385
6636
|
const text = buffer.toString("utf8");
|
|
6386
6637
|
let r;
|
|
6387
6638
|
try {
|
|
6388
|
-
r = screenText(
|
|
6639
|
+
r = screenText(
|
|
6640
|
+
text,
|
|
6641
|
+
{ status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} },
|
|
6642
|
+
reqCtx,
|
|
6643
|
+
answeredWithoutBody ? stillToAnswer : void 0
|
|
6644
|
+
);
|
|
6389
6645
|
} catch (err) {
|
|
6390
6646
|
notify(onError, err, "onError");
|
|
6391
6647
|
for (const c of chunks) origWrite(c);
|
|
@@ -6435,15 +6691,6 @@ async function createProtection(options = {}) {
|
|
|
6435
6691
|
const allow = new Set((options.allowHosts ?? []).map((h) => String(h).toLowerCase()));
|
|
6436
6692
|
const egressShouldBlock = (url, host, method) => {
|
|
6437
6693
|
if (host && allow.has(host.toLowerCase())) return false;
|
|
6438
|
-
let result;
|
|
6439
|
-
try {
|
|
6440
|
-
result = egressEngine.evaluate({ _egress: { url, host, method } });
|
|
6441
|
-
} catch (err) {
|
|
6442
|
-
notify(onError, err, "onError");
|
|
6443
|
-
return false;
|
|
6444
|
-
}
|
|
6445
|
-
if (!result.blocked) return false;
|
|
6446
|
-
const egressMode = ruleMode(result.rule);
|
|
6447
6694
|
let egressPath = null;
|
|
6448
6695
|
try {
|
|
6449
6696
|
const u = new URL(url);
|
|
@@ -6451,17 +6698,39 @@ async function createProtection(options = {}) {
|
|
|
6451
6698
|
} catch {
|
|
6452
6699
|
egressPath = typeof url === "string" ? url : null;
|
|
6453
6700
|
}
|
|
6454
|
-
|
|
6455
|
-
|
|
6456
|
-
|
|
6457
|
-
|
|
6458
|
-
|
|
6459
|
-
|
|
6460
|
-
|
|
6461
|
-
|
|
6462
|
-
|
|
6463
|
-
|
|
6464
|
-
|
|
6701
|
+
const egressEvent = mintEvent();
|
|
6702
|
+
let block = false;
|
|
6703
|
+
for (const { rule, engine: re } of egressRuleSet) {
|
|
6704
|
+
let result;
|
|
6705
|
+
try {
|
|
6706
|
+
result = re.evaluate({ _egress: { url, host, method } });
|
|
6707
|
+
} catch (err) {
|
|
6708
|
+
notify(onError, err, "onError");
|
|
6709
|
+
continue;
|
|
6710
|
+
}
|
|
6711
|
+
if (!result.blocked) continue;
|
|
6712
|
+
const egressMode = ruleMode(rule);
|
|
6713
|
+
onDetect({
|
|
6714
|
+
phase: "egress",
|
|
6715
|
+
mode: egressMode,
|
|
6716
|
+
category: rule?.category,
|
|
6717
|
+
rule,
|
|
6718
|
+
message: result.message,
|
|
6719
|
+
// One identity for this outbound call, shared by every rule that matches it — the egress phase
|
|
6720
|
+
// evaluates all of them rather than stopping at the first, so without this two rules refusing one
|
|
6721
|
+
// call would count as two calls refused.
|
|
6722
|
+
//
|
|
6723
|
+
// Its own identity, not the identity of whatever request the application was serving when it made
|
|
6724
|
+
// the call. An outbound attempt is a thing that happened in its own right, and a call made outside
|
|
6725
|
+
// any request — a job, a timer — has no request to belong to.
|
|
6726
|
+
event: egressEvent,
|
|
6727
|
+
method: typeof method === "string" ? method : null,
|
|
6728
|
+
path: egressPath,
|
|
6729
|
+
capture: evidenceFrom(result)
|
|
6730
|
+
});
|
|
6731
|
+
if (egressMode === "block") block = true;
|
|
6732
|
+
}
|
|
6733
|
+
return block;
|
|
6465
6734
|
};
|
|
6466
6735
|
const protection = {
|
|
6467
6736
|
get mode() {
|
|
@@ -6530,7 +6799,7 @@ async function createProtection(options = {}) {
|
|
|
6530
6799
|
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
|
|
6531
6800
|
next();
|
|
6532
6801
|
},
|
|
6533
|
-
nodeRequestMeta(req, client)
|
|
6802
|
+
() => nodeRequestMeta(req, client)
|
|
6534
6803
|
);
|
|
6535
6804
|
};
|
|
6536
6805
|
},
|
|
@@ -6594,7 +6863,7 @@ async function createProtection(options = {}) {
|
|
|
6594
6863
|
if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, shaped?._clientIp));
|
|
6595
6864
|
next();
|
|
6596
6865
|
},
|
|
6597
|
-
nodeRequestMeta(req, shaped?._clientIp)
|
|
6866
|
+
() => nodeRequestMeta(req, shaped?._clientIp)
|
|
6598
6867
|
);
|
|
6599
6868
|
}
|
|
6600
6869
|
}
|
|
@@ -7068,11 +7337,37 @@ function defaultOnDetect({ phase, mode, category, rule, message }) {
|
|
|
7068
7337
|
const tag = mode === "block" ? "BLOCK" : "DETECT (dry-run)";
|
|
7069
7338
|
console.warn(`[patchstack] ${tag} phase=${phase ?? "request"} category=${category ?? "?"} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
|
|
7070
7339
|
}
|
|
7340
|
+
var eventIdentities = /* @__PURE__ */ new WeakMap();
|
|
7341
|
+
function mintEvent() {
|
|
7342
|
+
try {
|
|
7343
|
+
const bytes = globalThis.crypto?.getRandomValues?.(new Uint8Array(16));
|
|
7344
|
+
if (bytes) return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
7345
|
+
} catch {
|
|
7346
|
+
}
|
|
7347
|
+
try {
|
|
7348
|
+
let hex = Date.now().toString(16);
|
|
7349
|
+
while (hex.length < 32) hex += Math.floor(Math.random() * 4294967295).toString(16).padStart(8, "0");
|
|
7350
|
+
return hex.slice(0, 32);
|
|
7351
|
+
} catch {
|
|
7352
|
+
return null;
|
|
7353
|
+
}
|
|
7354
|
+
}
|
|
7355
|
+
function eventFor(request) {
|
|
7356
|
+
if (request === null || typeof request !== "object") return mintEvent();
|
|
7357
|
+
const existing = eventIdentities.get(request);
|
|
7358
|
+
if (existing !== void 0) return existing;
|
|
7359
|
+
const minted = mintEvent();
|
|
7360
|
+
if (minted !== null) eventIdentities.set(request, minted);
|
|
7361
|
+
return minted;
|
|
7362
|
+
}
|
|
7071
7363
|
function requestMetaFromContext(reqCtx) {
|
|
7072
7364
|
if (!reqCtx) return {};
|
|
7073
7365
|
const client = reqCtx._clientIp ?? { ip: null, source: "unavailable" };
|
|
7074
7366
|
return {
|
|
7075
7367
|
method: reqCtx.method ?? null,
|
|
7368
|
+
// The originating request's identity, so a response detection and the request detection for the same
|
|
7369
|
+
// request are one event rather than two. Asked for here, which is inside a detection being raised.
|
|
7370
|
+
event: eventFor(reqCtx._eventOf),
|
|
7076
7371
|
// Path AND query. The reporter is what drops the query's VALUES, keeping its parameter names, so
|
|
7077
7372
|
// trimming it here would leave a Fetch or response detection unable to say what was requested.
|
|
7078
7373
|
path: typeof reqCtx.originalUrl === "string" ? reqCtx.originalUrl : null,
|
|
@@ -7096,7 +7391,7 @@ function requestMeta(shaped, request) {
|
|
|
7096
7391
|
method = request.method ?? null;
|
|
7097
7392
|
userAgent = request.headers?.get?.("user-agent") ?? null;
|
|
7098
7393
|
}
|
|
7099
|
-
return { method, path: path7, ip: client.ip, clientIpSource: client.source, userAgent };
|
|
7394
|
+
return { method, path: path7, ip: client.ip, clientIpSource: client.source, userAgent, event: eventFor(request) };
|
|
7100
7395
|
}
|
|
7101
7396
|
function nodeRequestMeta(req, client) {
|
|
7102
7397
|
if (!req) return {};
|
|
@@ -7110,7 +7405,8 @@ function nodeRequestMeta(req, client) {
|
|
|
7110
7405
|
// verified, and a second derivation could disagree with the one the engine evaluated.
|
|
7111
7406
|
ip: resolved.ip,
|
|
7112
7407
|
clientIpSource: resolved.source,
|
|
7113
|
-
userAgent: typeof ua === "string" ? ua : Array.isArray(ua) ? ua[0] : null
|
|
7408
|
+
userAgent: typeof ua === "string" ? ua : Array.isArray(ua) ? ua[0] : null,
|
|
7409
|
+
event: eventFor(req)
|
|
7114
7410
|
};
|
|
7115
7411
|
}
|
|
7116
7412
|
// Annotate the CommonJS export names for ESM import in node:
|