@patchstack/connect 0.4.0 → 0.4.2
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/index.cjs.map +1 -1
- package/dist/index.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 +110 -9
- package/dist/protect.js.map +1 -1
- package/dist/{refresh-manifest-SWCPQ52Z.js → refresh-manifest-JNCTIAM5.js} +1 -1
- package/dist/refresh-manifest-JNCTIAM5.js.map +1 -0
- package/package.json +2 -2
- package/dist/refresh-manifest-SWCPQ52Z.js.map +0 -1
package/dist/protect.js
CHANGED
|
@@ -3109,6 +3109,11 @@ var DEFAULT_EGRESS_RULES = [
|
|
|
3109
3109
|
title: "Outbound request to an internal / metadata address (SSRF)",
|
|
3110
3110
|
phase: "egress",
|
|
3111
3111
|
category: "ssrf",
|
|
3112
|
+
// Declared, not implied. Nothing in the egress path reads it — a match refuses the call, and the
|
|
3113
|
+
// rule's mode decides whether it actually did — but the action is part of how a rule describes
|
|
3114
|
+
// itself to whatever reports on it, and a rule that declares nothing is reported as a rule nobody
|
|
3115
|
+
// can classify.
|
|
3116
|
+
action: "block",
|
|
3112
3117
|
rule_v2: [{ parameter: "egress.host", match: { type: "internal_host" } }]
|
|
3113
3118
|
}
|
|
3114
3119
|
];
|
|
@@ -3475,10 +3480,25 @@ function worthRetrying(status) {
|
|
|
3475
3480
|
}
|
|
3476
3481
|
var ATTEMPT_TIMEOUT_MS = 1e4;
|
|
3477
3482
|
var STOP_BUDGET_MS = 5e3;
|
|
3483
|
+
var CATEGORY_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
3484
|
+
var RESERVED_CATEGORY = "unknown";
|
|
3485
|
+
function declaredClass(declared, recognised) {
|
|
3486
|
+
if (typeof declared !== "string" || declared === "") return { value: null, dropped: false };
|
|
3487
|
+
if (!recognised(declared)) return { value: null, dropped: false };
|
|
3488
|
+
if (declared.length > MAX_CLASS_CHARS) return { value: null, dropped: true };
|
|
3489
|
+
return { value: declared, dropped: false };
|
|
3490
|
+
}
|
|
3491
|
+
var recognisedCategory = (value) => value !== RESERVED_CATEGORY && CATEGORY_PATTERN.test(value);
|
|
3492
|
+
var recognisedAction = (value) => ACTIONS.includes(value);
|
|
3478
3493
|
var MAX_ROUTE_CHARS = 256;
|
|
3479
3494
|
var MAX_PARAMETERS = 25;
|
|
3480
3495
|
var MAX_PARAMETER_CHARS = 64;
|
|
3481
3496
|
var MAX_IDENTIFIER_CHARS = 256;
|
|
3497
|
+
var MAX_CLASS_CHARS = 64;
|
|
3498
|
+
var EVENT_PATTERN = /^[0-9a-f]{32}$/;
|
|
3499
|
+
function eventIdentity(value) {
|
|
3500
|
+
return typeof value === "string" && EVENT_PATTERN.test(value) ? value : null;
|
|
3501
|
+
}
|
|
3482
3502
|
var MAX_CAPTURED_VALUES = 10;
|
|
3483
3503
|
var MAX_CAPTURED_VALUE_CHARS = 512;
|
|
3484
3504
|
var MAX_QUERY_KEYS = 10;
|
|
@@ -3938,6 +3958,8 @@ function createDetectionReporter(opts) {
|
|
|
3938
3958
|
const id = capText(String(ruleId), MAX_IDENTIFIER_CHARS);
|
|
3939
3959
|
const revision = capText(revisionOf(detection.rule) ?? "", MAX_IDENTIFIER_CHARS);
|
|
3940
3960
|
const etag = capText(rulesEtag ?? "", MAX_IDENTIFIER_CHARS);
|
|
3961
|
+
const classCategory = declaredClass(detection.rule?.category, recognisedCategory);
|
|
3962
|
+
const classAction = declaredClass(detection.rule?.action, recognisedAction);
|
|
3941
3963
|
for (const [name, field] of [
|
|
3942
3964
|
["rule_id", id],
|
|
3943
3965
|
["rule_revision", revision],
|
|
@@ -3945,6 +3967,12 @@ function createDetectionReporter(opts) {
|
|
|
3945
3967
|
]) {
|
|
3946
3968
|
if (field.truncated) truncated.push(name);
|
|
3947
3969
|
}
|
|
3970
|
+
for (const [name, field] of [
|
|
3971
|
+
["category", classCategory],
|
|
3972
|
+
["action", classAction]
|
|
3973
|
+
]) {
|
|
3974
|
+
if (field.dropped) truncated.push(name);
|
|
3975
|
+
}
|
|
3948
3976
|
queue.push({
|
|
3949
3977
|
rule_id: id.value,
|
|
3950
3978
|
route: route.value,
|
|
@@ -3965,7 +3993,32 @@ function createDetectionReporter(opts) {
|
|
|
3965
3993
|
...query.total > queryKeys.length ? { query_keys_total: query.total } : {},
|
|
3966
3994
|
// Who asked. Capped, since it is client-supplied text and this is an event with a size bound.
|
|
3967
3995
|
user_agent: userAgent === null ? null : userAgent.value,
|
|
3996
|
+
// What KIND of match this was: which phase it happened in, what class of thing the rule is for,
|
|
3997
|
+
// and what the rule DECLARES it does about it.
|
|
3998
|
+
//
|
|
3999
|
+
// Three separate facts, and none of them is `enforced` below. A rule declaring `block` while
|
|
4000
|
+
// observing reports exactly that — `action: 'block'`, `enforced: false` — which is what a
|
|
4001
|
+
// dry-run window consists of. Reading either off the other would describe such a window as
|
|
4002
|
+
// protection that never happened, or as rules that do nothing.
|
|
4003
|
+
//
|
|
4004
|
+
// `null` where the rule says nothing, never a guess. A consumer can tell "this rule is for
|
|
4005
|
+
// secret exposure" from "we cannot say what this rule is for", and a filled-in value would take
|
|
4006
|
+
// that distinction away for the sake of a tidier field.
|
|
3968
4007
|
phase: detection.phase ?? null,
|
|
4008
|
+
// `null` where the rule declared nothing, and also where what it declared could not be carried:
|
|
4009
|
+
// both are "we cannot say what this rule is for", which is a different fact from a class we do
|
|
4010
|
+
// know, and `truncated` distinguishes the second from the first.
|
|
4011
|
+
category: classCategory.value,
|
|
4012
|
+
action: classAction.value,
|
|
4013
|
+
// Which call this detection belongs to, so a consumer can tell one call two rules saw from two
|
|
4014
|
+
// separate calls. Two rules matching one call is the ordinary case — a rule that enforces and a
|
|
4015
|
+
// rule that only observes are meant to match the same thing — so without this, adding these up
|
|
4016
|
+
// reports one call more than once.
|
|
4017
|
+
//
|
|
4018
|
+
// Nothing about the request goes into it: it is only ever compared with other identities, so
|
|
4019
|
+
// deriving it from the address or the path would carry something about whoever made the request
|
|
4020
|
+
// into a place nothing needs it. That is a property of how it is minted, not of its shape.
|
|
4021
|
+
event: eventIdentity(detection.event),
|
|
3969
4022
|
// The state this detection was handled under, which is the whole point: `false` is a rule that
|
|
3970
4023
|
// saw traffic it would have stopped.
|
|
3971
4024
|
enforced: detection.mode === "block",
|
|
@@ -4587,8 +4640,9 @@ async function createProtection(options = {}) {
|
|
|
4587
4640
|
if (!permitsAnything(entry.plan)) return { plan: entry.reference };
|
|
4588
4641
|
return { plan: entry.reference, ...captureValues(entry.plan, result.resolver) };
|
|
4589
4642
|
};
|
|
4590
|
-
const decide = (phase, result, block, allow2,
|
|
4643
|
+
const decide = (phase, result, block, allow2, describe = () => ({})) => {
|
|
4591
4644
|
if (!result || !result.blocked) return allow2();
|
|
4645
|
+
const ctx = describe() ?? {};
|
|
4592
4646
|
const effectiveMode = ruleMode(result.rule);
|
|
4593
4647
|
onDetect({
|
|
4594
4648
|
phase,
|
|
@@ -4601,6 +4655,9 @@ async function createProtection(options = {}) {
|
|
|
4601
4655
|
method: ctx.method,
|
|
4602
4656
|
path: ctx.path,
|
|
4603
4657
|
ip: ctx.ip,
|
|
4658
|
+
// Which call this was. Named here rather than spread from `ctx` for the same reason as everything
|
|
4659
|
+
// else in this payload: a field reaches the wire because someone listed it.
|
|
4660
|
+
event: ctx.event ?? null,
|
|
4604
4661
|
// Provenance travels with the address. Without it a consumer cannot tell an observed peer from a
|
|
4605
4662
|
// value read out of a forwarded header, and `null` from "there was no address to establish".
|
|
4606
4663
|
clientIpSource: ctx.clientIpSource,
|
|
@@ -4732,7 +4789,7 @@ async function createProtection(options = {}) {
|
|
|
4732
4789
|
result,
|
|
4733
4790
|
() => blockResponse(result, request),
|
|
4734
4791
|
() => null,
|
|
4735
|
-
requestMeta(shaped, request)
|
|
4792
|
+
() => requestMeta(shaped, request)
|
|
4736
4793
|
);
|
|
4737
4794
|
return { blocked, client: shaped?._clientIp };
|
|
4738
4795
|
};
|
|
@@ -4747,7 +4804,12 @@ async function createProtection(options = {}) {
|
|
|
4747
4804
|
originalUrl: u.pathname + u.search,
|
|
4748
4805
|
headers,
|
|
4749
4806
|
ip: resolved.ip ?? "",
|
|
4750
|
-
_clientIp: resolved
|
|
4807
|
+
_clientIp: resolved,
|
|
4808
|
+
// The request itself, so the identity can be asked for LATER. This context is built on every
|
|
4809
|
+
// request whether or not anything matches, so asking here would mint one for every request —
|
|
4810
|
+
// and asking of the context rather than of the request would give the response a different
|
|
4811
|
+
// identity from the request that caused it, reporting one call as two.
|
|
4812
|
+
_eventOf: request
|
|
4751
4813
|
};
|
|
4752
4814
|
} catch {
|
|
4753
4815
|
return void 0;
|
|
@@ -4758,7 +4820,10 @@ async function createProtection(options = {}) {
|
|
|
4758
4820
|
originalUrl: req.url,
|
|
4759
4821
|
headers: req.headers || {},
|
|
4760
4822
|
ip: client?.ip ?? "",
|
|
4761
|
-
_clientIp: client ?? { ip: null, source: "unavailable" }
|
|
4823
|
+
_clientIp: client ?? { ip: null, source: "unavailable" },
|
|
4824
|
+
// The request itself, for the same reasons as on the fetch path above: asked for later, and
|
|
4825
|
+
// asked of the request rather than of this context.
|
|
4826
|
+
_eventOf: req
|
|
4762
4827
|
} : void 0;
|
|
4763
4828
|
const screenResp = async (response, reqCtx) => {
|
|
4764
4829
|
const read = await readTextResponse(response, screenCap);
|
|
@@ -5005,6 +5070,7 @@ async function createProtection(options = {}) {
|
|
|
5005
5070
|
} catch {
|
|
5006
5071
|
egressPath = typeof url === "string" ? url : null;
|
|
5007
5072
|
}
|
|
5073
|
+
const egressEvent = mintEvent();
|
|
5008
5074
|
let block = false;
|
|
5009
5075
|
for (const { rule, engine: re } of egressRuleSet) {
|
|
5010
5076
|
let result;
|
|
@@ -5022,6 +5088,14 @@ async function createProtection(options = {}) {
|
|
|
5022
5088
|
category: rule?.category,
|
|
5023
5089
|
rule,
|
|
5024
5090
|
message: result.message,
|
|
5091
|
+
// One identity for this outbound call, shared by every rule that matches it — the egress phase
|
|
5092
|
+
// evaluates all of them rather than stopping at the first, so without this two rules refusing one
|
|
5093
|
+
// call would count as two calls refused.
|
|
5094
|
+
//
|
|
5095
|
+
// Its own identity, not the identity of whatever request the application was serving when it made
|
|
5096
|
+
// the call. An outbound attempt is a thing that happened in its own right, and a call made outside
|
|
5097
|
+
// any request — a job, a timer — has no request to belong to.
|
|
5098
|
+
event: egressEvent,
|
|
5025
5099
|
method: typeof method === "string" ? method : null,
|
|
5026
5100
|
path: egressPath,
|
|
5027
5101
|
capture: evidenceFrom(result)
|
|
@@ -5097,7 +5171,7 @@ async function createProtection(options = {}) {
|
|
|
5097
5171
|
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, client));
|
|
5098
5172
|
next();
|
|
5099
5173
|
},
|
|
5100
|
-
nodeRequestMeta(req, client)
|
|
5174
|
+
() => nodeRequestMeta(req, client)
|
|
5101
5175
|
);
|
|
5102
5176
|
};
|
|
5103
5177
|
},
|
|
@@ -5161,7 +5235,7 @@ async function createProtection(options = {}) {
|
|
|
5161
5235
|
if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req, shaped?._clientIp));
|
|
5162
5236
|
next();
|
|
5163
5237
|
},
|
|
5164
|
-
nodeRequestMeta(req, shaped?._clientIp)
|
|
5238
|
+
() => nodeRequestMeta(req, shaped?._clientIp)
|
|
5165
5239
|
);
|
|
5166
5240
|
}
|
|
5167
5241
|
}
|
|
@@ -5184,7 +5258,7 @@ async function createProtection(options = {}) {
|
|
|
5184
5258
|
let reporter = null;
|
|
5185
5259
|
if (refreshable && options.siteUuid && options.reportManifest !== false && cwd) {
|
|
5186
5260
|
try {
|
|
5187
|
-
({ reportManifest: reporter } = await import("./refresh-manifest-
|
|
5261
|
+
({ reportManifest: reporter } = await import("./refresh-manifest-JNCTIAM5.js"));
|
|
5188
5262
|
} catch (err) {
|
|
5189
5263
|
notify(onError, err, "onError");
|
|
5190
5264
|
}
|
|
@@ -5635,11 +5709,37 @@ function defaultOnDetect({ phase, mode, category, rule, message }) {
|
|
|
5635
5709
|
const tag = mode === "block" ? "BLOCK" : "DETECT (dry-run)";
|
|
5636
5710
|
console.warn(`[patchstack] ${tag} phase=${phase ?? "request"} category=${category ?? "?"} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
|
|
5637
5711
|
}
|
|
5712
|
+
var eventIdentities = /* @__PURE__ */ new WeakMap();
|
|
5713
|
+
function mintEvent() {
|
|
5714
|
+
try {
|
|
5715
|
+
const bytes = globalThis.crypto?.getRandomValues?.(new Uint8Array(16));
|
|
5716
|
+
if (bytes) return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
5717
|
+
} catch {
|
|
5718
|
+
}
|
|
5719
|
+
try {
|
|
5720
|
+
let hex = Date.now().toString(16);
|
|
5721
|
+
while (hex.length < 32) hex += Math.floor(Math.random() * 4294967295).toString(16).padStart(8, "0");
|
|
5722
|
+
return hex.slice(0, 32);
|
|
5723
|
+
} catch {
|
|
5724
|
+
return null;
|
|
5725
|
+
}
|
|
5726
|
+
}
|
|
5727
|
+
function eventFor(request) {
|
|
5728
|
+
if (request === null || typeof request !== "object") return mintEvent();
|
|
5729
|
+
const existing = eventIdentities.get(request);
|
|
5730
|
+
if (existing !== void 0) return existing;
|
|
5731
|
+
const minted = mintEvent();
|
|
5732
|
+
if (minted !== null) eventIdentities.set(request, minted);
|
|
5733
|
+
return minted;
|
|
5734
|
+
}
|
|
5638
5735
|
function requestMetaFromContext(reqCtx) {
|
|
5639
5736
|
if (!reqCtx) return {};
|
|
5640
5737
|
const client = reqCtx._clientIp ?? { ip: null, source: "unavailable" };
|
|
5641
5738
|
return {
|
|
5642
5739
|
method: reqCtx.method ?? null,
|
|
5740
|
+
// The originating request's identity, so a response detection and the request detection for the same
|
|
5741
|
+
// request are one event rather than two. Asked for here, which is inside a detection being raised.
|
|
5742
|
+
event: eventFor(reqCtx._eventOf),
|
|
5643
5743
|
// Path AND query. The reporter is what drops the query's VALUES, keeping its parameter names, so
|
|
5644
5744
|
// trimming it here would leave a Fetch or response detection unable to say what was requested.
|
|
5645
5745
|
path: typeof reqCtx.originalUrl === "string" ? reqCtx.originalUrl : null,
|
|
@@ -5663,7 +5763,7 @@ function requestMeta(shaped, request) {
|
|
|
5663
5763
|
method = request.method ?? null;
|
|
5664
5764
|
userAgent = request.headers?.get?.("user-agent") ?? null;
|
|
5665
5765
|
}
|
|
5666
|
-
return { method, path, ip: client.ip, clientIpSource: client.source, userAgent };
|
|
5766
|
+
return { method, path, ip: client.ip, clientIpSource: client.source, userAgent, event: eventFor(request) };
|
|
5667
5767
|
}
|
|
5668
5768
|
function nodeRequestMeta(req, client) {
|
|
5669
5769
|
if (!req) return {};
|
|
@@ -5677,7 +5777,8 @@ function nodeRequestMeta(req, client) {
|
|
|
5677
5777
|
// verified, and a second derivation could disagree with the one the engine evaluated.
|
|
5678
5778
|
ip: resolved.ip,
|
|
5679
5779
|
clientIpSource: resolved.source,
|
|
5680
|
-
userAgent: typeof ua === "string" ? ua : Array.isArray(ua) ? ua[0] : null
|
|
5780
|
+
userAgent: typeof ua === "string" ? ua : Array.isArray(ua) ? ua[0] : null,
|
|
5781
|
+
event: eventFor(req)
|
|
5681
5782
|
};
|
|
5682
5783
|
}
|
|
5683
5784
|
export {
|