@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.d.cts
CHANGED
|
@@ -168,8 +168,19 @@ export interface CreateProtectionOptions {
|
|
|
168
168
|
*
|
|
169
169
|
* What it sends on EVERY detection: the rule id and its revision, the request path, the query string's
|
|
170
170
|
* parameter NAMES, the method, the parameters the rule reads, the phase, whether it was enforced, the
|
|
171
|
-
* rule-bundle ETag,
|
|
172
|
-
*
|
|
171
|
+
* rule-bundle ETag, a timestamp, the rule's category and the action it declares, and which call it
|
|
172
|
+
* belongs to — plus the values of the parameters the matched rule names, under a plan derived from
|
|
173
|
+
* that rule.
|
|
174
|
+
*
|
|
175
|
+
* The category and the declared action say what KIND of rule matched. Both are read from the rule
|
|
176
|
+
* itself and are `null` when it declares neither. Neither is the same as `enforced`:
|
|
177
|
+
* a rule declaring `block` while only observing reports that action with `enforced` false.
|
|
178
|
+
*
|
|
179
|
+
* Which call it belongs to is a token this guard mints and repeats on every rule that matched the same
|
|
180
|
+
* request or outbound call, so one call seen by two rules can be told from two calls. Nothing about
|
|
181
|
+
* the request goes into it — it is drawn from the runtime's randomness, or from the clock and
|
|
182
|
+
* `Math.random` where there is none. A request and its response share one; an outbound call gets its
|
|
183
|
+
* own. It is never a secret and never a boundary: what it has to do is not collide between two calls.
|
|
173
184
|
*
|
|
174
185
|
* Two fields depend on the phase. A request or response detection also carries the user agent and the
|
|
175
186
|
* client address with its provenance. An egress detection carries neither: the call was the
|
package/dist/protect.d.ts
CHANGED
|
@@ -168,8 +168,19 @@ export interface CreateProtectionOptions {
|
|
|
168
168
|
*
|
|
169
169
|
* What it sends on EVERY detection: the rule id and its revision, the request path, the query string's
|
|
170
170
|
* parameter NAMES, the method, the parameters the rule reads, the phase, whether it was enforced, the
|
|
171
|
-
* rule-bundle ETag,
|
|
172
|
-
*
|
|
171
|
+
* rule-bundle ETag, a timestamp, the rule's category and the action it declares, and which call it
|
|
172
|
+
* belongs to — plus the values of the parameters the matched rule names, under a plan derived from
|
|
173
|
+
* that rule.
|
|
174
|
+
*
|
|
175
|
+
* The category and the declared action say what KIND of rule matched. Both are read from the rule
|
|
176
|
+
* itself and are `null` when it declares neither. Neither is the same as `enforced`:
|
|
177
|
+
* a rule declaring `block` while only observing reports that action with `enforced` false.
|
|
178
|
+
*
|
|
179
|
+
* Which call it belongs to is a token this guard mints and repeats on every rule that matched the same
|
|
180
|
+
* request or outbound call, so one call seen by two rules can be told from two calls. Nothing about
|
|
181
|
+
* the request goes into it — it is drawn from the runtime's randomness, or from the clock and
|
|
182
|
+
* `Math.random` where there is none. A request and its response share one; an outbound call gets its
|
|
183
|
+
* own. It is never a secret and never a boundary: what it has to do is not collide between two calls.
|
|
173
184
|
*
|
|
174
185
|
* Two fields depend on the phase. A request or response detection also carries the user agent and the
|
|
175
186
|
* client address with its provenance. An egress detection carries neither: the call was the
|
package/dist/protect.edge.js
CHANGED
|
@@ -3223,6 +3223,11 @@ var DEFAULT_EGRESS_RULES = [
|
|
|
3223
3223
|
title: "Outbound request to an internal / metadata address (SSRF)",
|
|
3224
3224
|
phase: "egress",
|
|
3225
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",
|
|
3226
3231
|
rule_v2: [{ parameter: "egress.host", match: { type: "internal_host" } }]
|
|
3227
3232
|
}
|
|
3228
3233
|
];
|
|
@@ -3589,10 +3594,25 @@ function worthRetrying(status) {
|
|
|
3589
3594
|
}
|
|
3590
3595
|
var ATTEMPT_TIMEOUT_MS = 1e4;
|
|
3591
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);
|
|
3592
3607
|
var MAX_ROUTE_CHARS = 256;
|
|
3593
3608
|
var MAX_PARAMETERS = 25;
|
|
3594
3609
|
var MAX_PARAMETER_CHARS = 64;
|
|
3595
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
|
+
}
|
|
3596
3616
|
var MAX_CAPTURED_VALUES = 10;
|
|
3597
3617
|
var MAX_CAPTURED_VALUE_CHARS = 512;
|
|
3598
3618
|
var MAX_QUERY_KEYS = 10;
|
|
@@ -4052,6 +4072,8 @@ function createDetectionReporter(opts) {
|
|
|
4052
4072
|
const id = capText(String(ruleId), MAX_IDENTIFIER_CHARS);
|
|
4053
4073
|
const revision = capText(revisionOf(detection.rule) ?? "", MAX_IDENTIFIER_CHARS);
|
|
4054
4074
|
const etag = capText(rulesEtag ?? "", MAX_IDENTIFIER_CHARS);
|
|
4075
|
+
const classCategory = declaredClass(detection.rule?.category, recognisedCategory);
|
|
4076
|
+
const classAction = declaredClass(detection.rule?.action, recognisedAction);
|
|
4055
4077
|
for (const [name, field] of [
|
|
4056
4078
|
["rule_id", id],
|
|
4057
4079
|
["rule_revision", revision],
|
|
@@ -4059,6 +4081,12 @@ function createDetectionReporter(opts) {
|
|
|
4059
4081
|
]) {
|
|
4060
4082
|
if (field.truncated) truncated.push(name);
|
|
4061
4083
|
}
|
|
4084
|
+
for (const [name, field] of [
|
|
4085
|
+
["category", classCategory],
|
|
4086
|
+
["action", classAction]
|
|
4087
|
+
]) {
|
|
4088
|
+
if (field.dropped) truncated.push(name);
|
|
4089
|
+
}
|
|
4062
4090
|
queue.push({
|
|
4063
4091
|
rule_id: id.value,
|
|
4064
4092
|
route: route.value,
|
|
@@ -4079,7 +4107,32 @@ function createDetectionReporter(opts) {
|
|
|
4079
4107
|
...query.total > queryKeys.length ? { query_keys_total: query.total } : {},
|
|
4080
4108
|
// Who asked. Capped, since it is client-supplied text and this is an event with a size bound.
|
|
4081
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.
|
|
4082
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),
|
|
4083
4136
|
// The state this detection was handled under, which is the whole point: `false` is a rule that
|
|
4084
4137
|
// saw traffic it would have stopped.
|
|
4085
4138
|
enforced: detection.mode === "block",
|
|
@@ -4701,8 +4754,9 @@ async function createProtection(options = {}) {
|
|
|
4701
4754
|
if (!permitsAnything(entry.plan)) return { plan: entry.reference };
|
|
4702
4755
|
return { plan: entry.reference, ...captureValues(entry.plan, result.resolver) };
|
|
4703
4756
|
};
|
|
4704
|
-
const decide = (phase, result, block, allow2,
|
|
4757
|
+
const decide = (phase, result, block, allow2, describe = () => ({})) => {
|
|
4705
4758
|
if (!result || !result.blocked) return allow2();
|
|
4759
|
+
const ctx = describe() ?? {};
|
|
4706
4760
|
const effectiveMode = ruleMode(result.rule);
|
|
4707
4761
|
onDetect({
|
|
4708
4762
|
phase,
|
|
@@ -4715,6 +4769,9 @@ async function createProtection(options = {}) {
|
|
|
4715
4769
|
method: ctx.method,
|
|
4716
4770
|
path: ctx.path,
|
|
4717
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,
|
|
4718
4775
|
// Provenance travels with the address. Without it a consumer cannot tell an observed peer from a
|
|
4719
4776
|
// value read out of a forwarded header, and `null` from "there was no address to establish".
|
|
4720
4777
|
clientIpSource: ctx.clientIpSource,
|
|
@@ -4846,7 +4903,7 @@ async function createProtection(options = {}) {
|
|
|
4846
4903
|
result,
|
|
4847
4904
|
() => blockResponse(result, request),
|
|
4848
4905
|
() => null,
|
|
4849
|
-
requestMeta(shaped, request)
|
|
4906
|
+
() => requestMeta(shaped, request)
|
|
4850
4907
|
);
|
|
4851
4908
|
return { blocked, client: shaped?._clientIp };
|
|
4852
4909
|
};
|
|
@@ -4861,7 +4918,12 @@ async function createProtection(options = {}) {
|
|
|
4861
4918
|
originalUrl: u.pathname + u.search,
|
|
4862
4919
|
headers,
|
|
4863
4920
|
ip: resolved.ip ?? "",
|
|
4864
|
-
_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
|
|
4865
4927
|
};
|
|
4866
4928
|
} catch {
|
|
4867
4929
|
return void 0;
|
|
@@ -4872,7 +4934,10 @@ async function createProtection(options = {}) {
|
|
|
4872
4934
|
originalUrl: req.url,
|
|
4873
4935
|
headers: req.headers || {},
|
|
4874
4936
|
ip: client?.ip ?? "",
|
|
4875
|
-
_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
|
|
4876
4941
|
} : void 0;
|
|
4877
4942
|
const screenResp = async (response, reqCtx) => {
|
|
4878
4943
|
const read = await readTextResponse(response, screenCap);
|
|
@@ -5119,6 +5184,7 @@ async function createProtection(options = {}) {
|
|
|
5119
5184
|
} catch {
|
|
5120
5185
|
egressPath = typeof url === "string" ? url : null;
|
|
5121
5186
|
}
|
|
5187
|
+
const egressEvent = mintEvent();
|
|
5122
5188
|
let block = false;
|
|
5123
5189
|
for (const { rule, engine: re } of egressRuleSet) {
|
|
5124
5190
|
let result;
|
|
@@ -5136,6 +5202,14 @@ async function createProtection(options = {}) {
|
|
|
5136
5202
|
category: rule?.category,
|
|
5137
5203
|
rule,
|
|
5138
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,
|
|
5139
5213
|
method: typeof method === "string" ? method : null,
|
|
5140
5214
|
path: egressPath,
|
|
5141
5215
|
capture: evidenceFrom(result)
|
|
@@ -5211,7 +5285,7 @@ async function createProtection(options = {}) {
|
|
|
5211
5285
|
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
|
|
5212
5286
|
next();
|
|
5213
5287
|
},
|
|
5214
|
-
nodeRequestMeta(req, client)
|
|
5288
|
+
() => nodeRequestMeta(req, client)
|
|
5215
5289
|
);
|
|
5216
5290
|
};
|
|
5217
5291
|
},
|
|
@@ -5275,7 +5349,7 @@ async function createProtection(options = {}) {
|
|
|
5275
5349
|
if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, shaped?._clientIp));
|
|
5276
5350
|
next();
|
|
5277
5351
|
},
|
|
5278
|
-
nodeRequestMeta(req, shaped?._clientIp)
|
|
5352
|
+
() => nodeRequestMeta(req, shaped?._clientIp)
|
|
5279
5353
|
);
|
|
5280
5354
|
}
|
|
5281
5355
|
}
|
|
@@ -5749,11 +5823,37 @@ function defaultOnDetect({ phase, mode, category, rule, message }) {
|
|
|
5749
5823
|
const tag = mode === "block" ? "BLOCK" : "DETECT (dry-run)";
|
|
5750
5824
|
console.warn(`[patchstack] ${tag} phase=${phase ?? "request"} category=${category ?? "?"} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
|
|
5751
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
|
+
}
|
|
5752
5849
|
function requestMetaFromContext(reqCtx) {
|
|
5753
5850
|
if (!reqCtx) return {};
|
|
5754
5851
|
const client = reqCtx._clientIp ?? { ip: null, source: "unavailable" };
|
|
5755
5852
|
return {
|
|
5756
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),
|
|
5757
5857
|
// Path AND query. The reporter is what drops the query's VALUES, keeping its parameter names, so
|
|
5758
5858
|
// trimming it here would leave a Fetch or response detection unable to say what was requested.
|
|
5759
5859
|
path: typeof reqCtx.originalUrl === "string" ? reqCtx.originalUrl : null,
|
|
@@ -5777,7 +5877,7 @@ function requestMeta(shaped, request) {
|
|
|
5777
5877
|
method = request.method ?? null;
|
|
5778
5878
|
userAgent = request.headers?.get?.("user-agent") ?? null;
|
|
5779
5879
|
}
|
|
5780
|
-
return { method, path, ip: client.ip, clientIpSource: client.source, userAgent };
|
|
5880
|
+
return { method, path, ip: client.ip, clientIpSource: client.source, userAgent, event: eventFor(request) };
|
|
5781
5881
|
}
|
|
5782
5882
|
function nodeRequestMeta(req, client) {
|
|
5783
5883
|
if (!req) return {};
|
|
@@ -5791,7 +5891,8 @@ function nodeRequestMeta(req, client) {
|
|
|
5791
5891
|
// verified, and a second derivation could disagree with the one the engine evaluated.
|
|
5792
5892
|
ip: resolved.ip,
|
|
5793
5893
|
clientIpSource: resolved.source,
|
|
5794
|
-
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)
|
|
5795
5896
|
};
|
|
5796
5897
|
}
|
|
5797
5898
|
export {
|