@patchstack/connect 0.4.0 → 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 +22 -0
- package/dist/cli.js.map +1 -1
- package/dist/protect.cjs +109 -8
- 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 +109 -8
- package/dist/protect.edge.js.map +1 -1
- package/dist/protect.js +109 -8
- package/dist/protect.js.map +1 -1
- package/package.json +1 -1
package/dist/protect.cjs
CHANGED
|
@@ -4736,6 +4736,11 @@ var DEFAULT_EGRESS_RULES = [
|
|
|
4736
4736
|
title: "Outbound request to an internal / metadata address (SSRF)",
|
|
4737
4737
|
phase: "egress",
|
|
4738
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",
|
|
4739
4744
|
rule_v2: [{ parameter: "egress.host", match: { type: "internal_host" } }]
|
|
4740
4745
|
}
|
|
4741
4746
|
];
|
|
@@ -5103,10 +5108,25 @@ function worthRetrying(status) {
|
|
|
5103
5108
|
}
|
|
5104
5109
|
var ATTEMPT_TIMEOUT_MS = 1e4;
|
|
5105
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);
|
|
5106
5121
|
var MAX_ROUTE_CHARS = 256;
|
|
5107
5122
|
var MAX_PARAMETERS = 25;
|
|
5108
5123
|
var MAX_PARAMETER_CHARS = 64;
|
|
5109
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
|
+
}
|
|
5110
5130
|
var MAX_CAPTURED_VALUES = 10;
|
|
5111
5131
|
var MAX_CAPTURED_VALUE_CHARS = 512;
|
|
5112
5132
|
var MAX_QUERY_KEYS = 10;
|
|
@@ -5566,6 +5586,8 @@ function createDetectionReporter(opts) {
|
|
|
5566
5586
|
const id = capText(String(ruleId), MAX_IDENTIFIER_CHARS);
|
|
5567
5587
|
const revision = capText(revisionOf(detection.rule) ?? "", MAX_IDENTIFIER_CHARS);
|
|
5568
5588
|
const etag = capText(rulesEtag ?? "", MAX_IDENTIFIER_CHARS);
|
|
5589
|
+
const classCategory = declaredClass(detection.rule?.category, recognisedCategory);
|
|
5590
|
+
const classAction = declaredClass(detection.rule?.action, recognisedAction);
|
|
5569
5591
|
for (const [name, field] of [
|
|
5570
5592
|
["rule_id", id],
|
|
5571
5593
|
["rule_revision", revision],
|
|
@@ -5573,6 +5595,12 @@ function createDetectionReporter(opts) {
|
|
|
5573
5595
|
]) {
|
|
5574
5596
|
if (field.truncated) truncated.push(name);
|
|
5575
5597
|
}
|
|
5598
|
+
for (const [name, field] of [
|
|
5599
|
+
["category", classCategory],
|
|
5600
|
+
["action", classAction]
|
|
5601
|
+
]) {
|
|
5602
|
+
if (field.dropped) truncated.push(name);
|
|
5603
|
+
}
|
|
5576
5604
|
queue.push({
|
|
5577
5605
|
rule_id: id.value,
|
|
5578
5606
|
route: route.value,
|
|
@@ -5593,7 +5621,32 @@ function createDetectionReporter(opts) {
|
|
|
5593
5621
|
...query.total > queryKeys.length ? { query_keys_total: query.total } : {},
|
|
5594
5622
|
// Who asked. Capped, since it is client-supplied text and this is an event with a size bound.
|
|
5595
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.
|
|
5596
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),
|
|
5597
5650
|
// The state this detection was handled under, which is the whole point: `false` is a rule that
|
|
5598
5651
|
// saw traffic it would have stopped.
|
|
5599
5652
|
enforced: detection.mode === "block",
|
|
@@ -6215,8 +6268,9 @@ async function createProtection(options = {}) {
|
|
|
6215
6268
|
if (!permitsAnything(entry.plan)) return { plan: entry.reference };
|
|
6216
6269
|
return { plan: entry.reference, ...captureValues(entry.plan, result.resolver) };
|
|
6217
6270
|
};
|
|
6218
|
-
const decide = (phase, result, block, allow2,
|
|
6271
|
+
const decide = (phase, result, block, allow2, describe = () => ({})) => {
|
|
6219
6272
|
if (!result || !result.blocked) return allow2();
|
|
6273
|
+
const ctx = describe() ?? {};
|
|
6220
6274
|
const effectiveMode = ruleMode(result.rule);
|
|
6221
6275
|
onDetect({
|
|
6222
6276
|
phase,
|
|
@@ -6229,6 +6283,9 @@ async function createProtection(options = {}) {
|
|
|
6229
6283
|
method: ctx.method,
|
|
6230
6284
|
path: ctx.path,
|
|
6231
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,
|
|
6232
6289
|
// Provenance travels with the address. Without it a consumer cannot tell an observed peer from a
|
|
6233
6290
|
// value read out of a forwarded header, and `null` from "there was no address to establish".
|
|
6234
6291
|
clientIpSource: ctx.clientIpSource,
|
|
@@ -6360,7 +6417,7 @@ async function createProtection(options = {}) {
|
|
|
6360
6417
|
result,
|
|
6361
6418
|
() => blockResponse(result, request),
|
|
6362
6419
|
() => null,
|
|
6363
|
-
requestMeta(shaped, request)
|
|
6420
|
+
() => requestMeta(shaped, request)
|
|
6364
6421
|
);
|
|
6365
6422
|
return { blocked, client: shaped?._clientIp };
|
|
6366
6423
|
};
|
|
@@ -6375,7 +6432,12 @@ async function createProtection(options = {}) {
|
|
|
6375
6432
|
originalUrl: u.pathname + u.search,
|
|
6376
6433
|
headers,
|
|
6377
6434
|
ip: resolved.ip ?? "",
|
|
6378
|
-
_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
|
|
6379
6441
|
};
|
|
6380
6442
|
} catch {
|
|
6381
6443
|
return void 0;
|
|
@@ -6386,7 +6448,10 @@ async function createProtection(options = {}) {
|
|
|
6386
6448
|
originalUrl: req.url,
|
|
6387
6449
|
headers: req.headers || {},
|
|
6388
6450
|
ip: client?.ip ?? "",
|
|
6389
|
-
_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
|
|
6390
6455
|
} : void 0;
|
|
6391
6456
|
const screenResp = async (response, reqCtx) => {
|
|
6392
6457
|
const read = await readTextResponse(response, screenCap);
|
|
@@ -6633,6 +6698,7 @@ async function createProtection(options = {}) {
|
|
|
6633
6698
|
} catch {
|
|
6634
6699
|
egressPath = typeof url === "string" ? url : null;
|
|
6635
6700
|
}
|
|
6701
|
+
const egressEvent = mintEvent();
|
|
6636
6702
|
let block = false;
|
|
6637
6703
|
for (const { rule, engine: re } of egressRuleSet) {
|
|
6638
6704
|
let result;
|
|
@@ -6650,6 +6716,14 @@ async function createProtection(options = {}) {
|
|
|
6650
6716
|
category: rule?.category,
|
|
6651
6717
|
rule,
|
|
6652
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,
|
|
6653
6727
|
method: typeof method === "string" ? method : null,
|
|
6654
6728
|
path: egressPath,
|
|
6655
6729
|
capture: evidenceFrom(result)
|
|
@@ -6725,7 +6799,7 @@ async function createProtection(options = {}) {
|
|
|
6725
6799
|
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
|
|
6726
6800
|
next();
|
|
6727
6801
|
},
|
|
6728
|
-
nodeRequestMeta(req, client)
|
|
6802
|
+
() => nodeRequestMeta(req, client)
|
|
6729
6803
|
);
|
|
6730
6804
|
};
|
|
6731
6805
|
},
|
|
@@ -6789,7 +6863,7 @@ async function createProtection(options = {}) {
|
|
|
6789
6863
|
if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, shaped?._clientIp));
|
|
6790
6864
|
next();
|
|
6791
6865
|
},
|
|
6792
|
-
nodeRequestMeta(req, shaped?._clientIp)
|
|
6866
|
+
() => nodeRequestMeta(req, shaped?._clientIp)
|
|
6793
6867
|
);
|
|
6794
6868
|
}
|
|
6795
6869
|
}
|
|
@@ -7263,11 +7337,37 @@ function defaultOnDetect({ phase, mode, category, rule, message }) {
|
|
|
7263
7337
|
const tag = mode === "block" ? "BLOCK" : "DETECT (dry-run)";
|
|
7264
7338
|
console.warn(`[patchstack] ${tag} phase=${phase ?? "request"} category=${category ?? "?"} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
|
|
7265
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
|
+
}
|
|
7266
7363
|
function requestMetaFromContext(reqCtx) {
|
|
7267
7364
|
if (!reqCtx) return {};
|
|
7268
7365
|
const client = reqCtx._clientIp ?? { ip: null, source: "unavailable" };
|
|
7269
7366
|
return {
|
|
7270
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),
|
|
7271
7371
|
// Path AND query. The reporter is what drops the query's VALUES, keeping its parameter names, so
|
|
7272
7372
|
// trimming it here would leave a Fetch or response detection unable to say what was requested.
|
|
7273
7373
|
path: typeof reqCtx.originalUrl === "string" ? reqCtx.originalUrl : null,
|
|
@@ -7291,7 +7391,7 @@ function requestMeta(shaped, request) {
|
|
|
7291
7391
|
method = request.method ?? null;
|
|
7292
7392
|
userAgent = request.headers?.get?.("user-agent") ?? null;
|
|
7293
7393
|
}
|
|
7294
|
-
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) };
|
|
7295
7395
|
}
|
|
7296
7396
|
function nodeRequestMeta(req, client) {
|
|
7297
7397
|
if (!req) return {};
|
|
@@ -7305,7 +7405,8 @@ function nodeRequestMeta(req, client) {
|
|
|
7305
7405
|
// verified, and a second derivation could disagree with the one the engine evaluated.
|
|
7306
7406
|
ip: resolved.ip,
|
|
7307
7407
|
clientIpSource: resolved.source,
|
|
7308
|
-
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)
|
|
7309
7410
|
};
|
|
7310
7411
|
}
|
|
7311
7412
|
// Annotate the CommonJS export names for ESM import in node:
|