@patchstack/connect 0.3.26 → 0.3.28
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 +86 -7
- package/dist/cli.js +220 -74
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +17 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -1
- package/dist/protect.cjs +262 -60
- package/dist/protect.cjs.map +1 -1
- package/dist/protect.d.ts +17 -0
- package/dist/protect.edge.js +244 -60
- package/dist/protect.edge.js.map +4 -4
- package/dist/protect.js +245 -61
- package/dist/protect.js.map +1 -1
- package/dist/{refresh-manifest-KZSQD2X3.js → refresh-manifest-VRBE6RH6.js} +18 -1
- package/dist/refresh-manifest-VRBE6RH6.js.map +1 -0
- package/package.json +1 -1
- package/dist/refresh-manifest-KZSQD2X3.js.map +0 -1
package/dist/protect.js
CHANGED
|
@@ -483,6 +483,35 @@ var RequestResolver = class {
|
|
|
483
483
|
}
|
|
484
484
|
};
|
|
485
485
|
|
|
486
|
+
// src/protect/notify.js
|
|
487
|
+
var reported = /* @__PURE__ */ new Set();
|
|
488
|
+
function notify(fn, arg, label) {
|
|
489
|
+
if (typeof fn !== "function") return false;
|
|
490
|
+
try {
|
|
491
|
+
const result = fn(arg);
|
|
492
|
+
if (result !== null && typeof result === "object" && typeof result.then === "function") {
|
|
493
|
+
try {
|
|
494
|
+
result.then(void 0, (err) => warnOnce2(label, err));
|
|
495
|
+
} catch {
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return true;
|
|
499
|
+
} catch (err) {
|
|
500
|
+
warnOnce2(label, err);
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
function warnOnce2(label, err) {
|
|
505
|
+
if (reported.has(label)) return;
|
|
506
|
+
reported.add(label);
|
|
507
|
+
try {
|
|
508
|
+
console.warn(
|
|
509
|
+
`Patchstack: the ${label} callback passed to createProtection failed and was ignored. Protection is unaffected; this is reported once per process. Cause: ${err && err.message ? err.message : String(err)}`
|
|
510
|
+
);
|
|
511
|
+
} catch {
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
486
515
|
// src/protect/engine/normalizer.js
|
|
487
516
|
var HTML_ENTITIES = {
|
|
488
517
|
"&": "&",
|
|
@@ -767,7 +796,7 @@ function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
|
|
|
767
796
|
return out;
|
|
768
797
|
}
|
|
769
798
|
var warnedKeys = /* @__PURE__ */ new Set();
|
|
770
|
-
function
|
|
799
|
+
function warnOnce3(key, message) {
|
|
771
800
|
if (warnedKeys.has(key)) return;
|
|
772
801
|
warnedKeys.add(key);
|
|
773
802
|
console.warn(message);
|
|
@@ -782,6 +811,26 @@ function warnUnsupportedMatchType(type) {
|
|
|
782
811
|
`[patchstack] Unsupported rule_v2 match type "${type}" \u2014 condition treated as no-match. Rules relying on it are not enforced by this engine.`
|
|
783
812
|
);
|
|
784
813
|
}
|
|
814
|
+
function hostFromValue(value) {
|
|
815
|
+
const raw = String(value ?? "").trim();
|
|
816
|
+
if (raw === "") return "";
|
|
817
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || raw.startsWith("//")) {
|
|
818
|
+
try {
|
|
819
|
+
return new URL(raw.startsWith("//") ? `http:${raw}` : raw).hostname;
|
|
820
|
+
} catch {
|
|
821
|
+
return raw;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
if (raw.startsWith("[")) {
|
|
825
|
+
const end = raw.indexOf("]");
|
|
826
|
+
if (end > 0) return raw.slice(1, end);
|
|
827
|
+
}
|
|
828
|
+
const colon = raw.indexOf(":");
|
|
829
|
+
if (colon > 0 && raw.indexOf(":", colon + 1) === -1 && /^\d+$/.test(raw.slice(colon + 1))) {
|
|
830
|
+
return raw.slice(0, colon);
|
|
831
|
+
}
|
|
832
|
+
return raw;
|
|
833
|
+
}
|
|
785
834
|
function isInternalHost(hostname) {
|
|
786
835
|
if (!hostname) return false;
|
|
787
836
|
let host = String(hostname).toLowerCase().replace(/^\[|\]$/g, "");
|
|
@@ -871,8 +920,20 @@ function expandIPv6(host) {
|
|
|
871
920
|
}
|
|
872
921
|
return out;
|
|
873
922
|
}
|
|
923
|
+
var warnedScopes = /* @__PURE__ */ new Set();
|
|
924
|
+
function warnUnrecognisedScope(when) {
|
|
925
|
+
const key = Object.keys(when).sort().join(",");
|
|
926
|
+
if (warnedScopes.has(key)) return;
|
|
927
|
+
warnedScopes.add(key);
|
|
928
|
+
console.warn(
|
|
929
|
+
`[patchstack] Rule scope \`when: { ${key} }\` names no supported key \u2014 the engine understands \`method\` and \`path\`. The scope is IGNORED and the rule applies to every request.`
|
|
930
|
+
);
|
|
931
|
+
}
|
|
874
932
|
function ruleAppliesTo(when, resolver) {
|
|
875
933
|
try {
|
|
934
|
+
if (when.method === void 0 && when.path === void 0 && Object.keys(when).length > 0) {
|
|
935
|
+
warnUnrecognisedScope(when);
|
|
936
|
+
}
|
|
876
937
|
if (when.method) {
|
|
877
938
|
const methods = (Array.isArray(when.method) ? when.method : [when.method]).map((m) => String(m).toUpperCase());
|
|
878
939
|
const actual = String(resolver.resolve("server.REQUEST_METHOD")[0] ?? "GET").toUpperCase();
|
|
@@ -1038,7 +1099,7 @@ function matchValue(type, value, matchVal, matchObj) {
|
|
|
1038
1099
|
}
|
|
1039
1100
|
}
|
|
1040
1101
|
case "internal_host":
|
|
1041
|
-
return isInternalHost(strValue);
|
|
1102
|
+
return isInternalHost(hostFromValue(strValue));
|
|
1042
1103
|
case "quotes":
|
|
1043
1104
|
// engine-php exposes `inline_js_xss` as an alias of `quotes`.
|
|
1044
1105
|
case "inline_js_xss":
|
|
@@ -1077,21 +1138,20 @@ var RuleEngine = class {
|
|
|
1077
1138
|
this.#whitelistKeys = whitelist_keys;
|
|
1078
1139
|
this.#onError = onError;
|
|
1079
1140
|
if (Array.isArray(whitelists) && whitelists.some((w) => w && Array.isArray(w.rule_v2) && !w.rule_id)) {
|
|
1080
|
-
|
|
1141
|
+
warnOnce3(
|
|
1081
1142
|
"whitelist-no-rule-id",
|
|
1082
1143
|
"[patchstack] a whitelist has no `rule_id` \u2014 it suppresses ALL rules when it matches. Scope each whitelist to a specific rule_id, and key it only on values an attacker cannot set."
|
|
1083
1144
|
);
|
|
1084
1145
|
}
|
|
1085
1146
|
if (whitelist_keys && typeof whitelist_keys === "object" && Object.keys(whitelist_keys).length > 0) {
|
|
1086
|
-
|
|
1147
|
+
warnOnce3("whitelist-keys-unimplemented", "[patchstack] `whitelist_keys` is not implemented and has no effect.");
|
|
1087
1148
|
}
|
|
1088
1149
|
}
|
|
1089
1150
|
// A mitigation engine must never take down the app it protects: any error while
|
|
1090
1151
|
// evaluating a rule is reported and the request is allowed through (fail open). A
|
|
1091
1152
|
// malformed rule is skipped without aborting the rest of the ruleset.
|
|
1092
1153
|
#reportError(err) {
|
|
1093
|
-
if (this.#onError) {
|
|
1094
|
-
this.#onError(err);
|
|
1154
|
+
if (notify(this.#onError, err, "onError")) {
|
|
1095
1155
|
return;
|
|
1096
1156
|
}
|
|
1097
1157
|
const key = err && err.message ? err.message : String(err);
|
|
@@ -1236,13 +1296,15 @@ var PulseRuleClient = class {
|
|
|
1236
1296
|
#ttlEffective = 0;
|
|
1237
1297
|
#etag;
|
|
1238
1298
|
#pulseAuth;
|
|
1239
|
-
|
|
1299
|
+
#reportsDetections;
|
|
1300
|
+
constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, reportsDetections } = {}) {
|
|
1240
1301
|
this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
|
|
1241
1302
|
this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
|
|
1242
1303
|
this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL, DEFAULT_BASE_URL2, "rule endpoint");
|
|
1243
1304
|
this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL2;
|
|
1244
1305
|
this.#etag = etag ?? null;
|
|
1245
1306
|
this.#pulseAuth = pulseAuth ?? null;
|
|
1307
|
+
this.#reportsDetections = reportsDetections === true;
|
|
1246
1308
|
if (!this.#siteUuid) {
|
|
1247
1309
|
throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
|
|
1248
1310
|
}
|
|
@@ -1254,13 +1316,14 @@ var PulseRuleClient = class {
|
|
|
1254
1316
|
}
|
|
1255
1317
|
const url = `${this.#baseUrl}/rules/${encodeURIComponent(this.#siteUuid)}`;
|
|
1256
1318
|
try {
|
|
1257
|
-
const
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1319
|
+
const auth = await pulseAuthHeader(
|
|
1320
|
+
{ pulseAuth: this.#pulseAuth, endpoint: this.#baseUrl, timeoutMs: this.#timeoutMs },
|
|
1321
|
+
fetch
|
|
1322
|
+
);
|
|
1323
|
+
const headers = { Accept: "application/json", ...auth };
|
|
1324
|
+
if (this.#reportsDetections && typeof auth.Authorization === "string") {
|
|
1325
|
+
headers["X-Patchstack-Detections"] = "enabled";
|
|
1326
|
+
}
|
|
1264
1327
|
if (this.#etag) headers["If-None-Match"] = this.#etag;
|
|
1265
1328
|
const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
|
|
1266
1329
|
if (response.status === 304) {
|
|
@@ -1542,7 +1605,7 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
1542
1605
|
const exempt = new Set((allowHosts ?? []).map((h) => String(h).toLowerCase()));
|
|
1543
1606
|
const block = (url, host, method) => {
|
|
1544
1607
|
if (!shouldBlock(url, host, method)) return false;
|
|
1545
|
-
onBlock
|
|
1608
|
+
notify(onBlock, { url, host, method }, "onEgressBlock");
|
|
1546
1609
|
return true;
|
|
1547
1610
|
};
|
|
1548
1611
|
let screen = null;
|
|
@@ -1557,12 +1620,7 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
1557
1620
|
screen = null;
|
|
1558
1621
|
}
|
|
1559
1622
|
}
|
|
1560
|
-
const skip = (reason, detail) => {
|
|
1561
|
-
try {
|
|
1562
|
-
onSkip?.({ phase: "egress", reason, detail });
|
|
1563
|
-
} catch {
|
|
1564
|
-
}
|
|
1565
|
-
};
|
|
1623
|
+
const skip = (reason, detail) => notify(onSkip, { phase: "egress", reason, detail }, "onSkip");
|
|
1566
1624
|
const resolvesToDisallowed = (url, host, method) => new Promise((resolve) => {
|
|
1567
1625
|
if (!screen) {
|
|
1568
1626
|
if (host && dnsScreen) skip("resolver-unavailable", { host });
|
|
@@ -2148,22 +2206,19 @@ function reportRejections(rejected, options, label) {
|
|
|
2148
2206
|
const report = options.onRuleRejected;
|
|
2149
2207
|
for (const r of rejected) {
|
|
2150
2208
|
if (typeof report === "function") {
|
|
2151
|
-
|
|
2152
|
-
report({ ...r, accepted: false });
|
|
2153
|
-
} catch {
|
|
2154
|
-
}
|
|
2209
|
+
notify(report, { ...r, accepted: false }, "onRuleRejected");
|
|
2155
2210
|
}
|
|
2156
2211
|
}
|
|
2157
2212
|
const sample = rejected.slice(0, 3).map((r) => `${r.id} (${r.reason})`).join("; ");
|
|
2158
|
-
options.onError
|
|
2213
|
+
notify(options.onError, new Error(
|
|
2159
2214
|
`${label}: rejected the entire update because ${rejected.length} rule(s) failed validation \u2014 keeping the previous ruleset and NOT caching this response: ${sample}${rejected.length > 3 ? ", \u2026" : ""}`
|
|
2160
|
-
));
|
|
2215
|
+
), "onError");
|
|
2161
2216
|
}
|
|
2162
2217
|
async function resolveRules(options, store, ctx = {}) {
|
|
2163
2218
|
const timeoutMs = ctx.timeoutMs;
|
|
2164
2219
|
if (options.siteUuid) {
|
|
2165
2220
|
const prior = await store.read();
|
|
2166
|
-
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth });
|
|
2221
|
+
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, reportsDetections: options.reportDetections === true });
|
|
2167
2222
|
const res = await client.getRules();
|
|
2168
2223
|
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
2169
2224
|
if (res.success && !res.notModified) {
|
|
@@ -2179,14 +2234,14 @@ async function resolveRules(options, store, ctx = {}) {
|
|
|
2179
2234
|
return bundle;
|
|
2180
2235
|
}
|
|
2181
2236
|
if (prior?.bundle) {
|
|
2182
|
-
options.onError
|
|
2237
|
+
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
|
|
2183
2238
|
return normalizeBundle(prior.bundle, options);
|
|
2184
2239
|
}
|
|
2185
2240
|
if (options.rules) {
|
|
2186
|
-
options.onError
|
|
2241
|
+
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`), "onError");
|
|
2187
2242
|
return normalizeBundle(options.rules, options);
|
|
2188
2243
|
}
|
|
2189
|
-
options.onError
|
|
2244
|
+
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
|
|
2190
2245
|
return emptyBundle();
|
|
2191
2246
|
}
|
|
2192
2247
|
if (options.token) {
|
|
@@ -2207,10 +2262,10 @@ async function resolveRules(options, store, ctx = {}) {
|
|
|
2207
2262
|
return bundle;
|
|
2208
2263
|
}
|
|
2209
2264
|
if (prior?.bundle) {
|
|
2210
|
-
options.onError
|
|
2265
|
+
notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
|
|
2211
2266
|
return normalizeBundle(prior.bundle, options);
|
|
2212
2267
|
}
|
|
2213
|
-
options.onError
|
|
2268
|
+
notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
|
|
2214
2269
|
return emptyBundle();
|
|
2215
2270
|
}
|
|
2216
2271
|
if (options.rules) {
|
|
@@ -2231,10 +2286,7 @@ function normalizeBundle(b, options = {}) {
|
|
|
2231
2286
|
const report = options.onRuleRejected;
|
|
2232
2287
|
if (typeof report === "function") {
|
|
2233
2288
|
for (const r of rejected) {
|
|
2234
|
-
|
|
2235
|
-
report(r);
|
|
2236
|
-
} catch {
|
|
2237
|
-
}
|
|
2289
|
+
notify(report, r, "onRuleRejected");
|
|
2238
2290
|
}
|
|
2239
2291
|
} else {
|
|
2240
2292
|
const sample = rejected.slice(0, 3).map((r) => `${r.id} (${r.reason})`).join("; ");
|
|
@@ -2276,7 +2328,7 @@ function startRefresh(tick, { refreshMs, onError } = {}) {
|
|
|
2276
2328
|
failures = 0;
|
|
2277
2329
|
} catch (err) {
|
|
2278
2330
|
failures++;
|
|
2279
|
-
onError
|
|
2331
|
+
notify(onError, err, "onError");
|
|
2280
2332
|
}
|
|
2281
2333
|
schedule();
|
|
2282
2334
|
};
|
|
@@ -2308,10 +2360,124 @@ function makeRefreshHandler(tick, secret) {
|
|
|
2308
2360
|
};
|
|
2309
2361
|
}
|
|
2310
2362
|
|
|
2363
|
+
// src/protect/detections.js
|
|
2364
|
+
var DEFAULT_BASE_URL3 = "https://api.patchstack.com/monitor/pulse";
|
|
2365
|
+
var DEFAULT_FLUSH_MS = 5e3;
|
|
2366
|
+
var MAX_BATCH = 50;
|
|
2367
|
+
var MAX_QUEUE = 500;
|
|
2368
|
+
function ruleParameters(rule) {
|
|
2369
|
+
const out = /* @__PURE__ */ new Set();
|
|
2370
|
+
const walk = (conditions) => {
|
|
2371
|
+
if (!Array.isArray(conditions)) return;
|
|
2372
|
+
for (const condition of conditions) {
|
|
2373
|
+
if (!condition || typeof condition !== "object") continue;
|
|
2374
|
+
if (typeof condition.parameter === "string" && condition.parameter !== "rules") {
|
|
2375
|
+
out.add(condition.parameter);
|
|
2376
|
+
}
|
|
2377
|
+
if (Array.isArray(condition.rules)) walk(condition.rules);
|
|
2378
|
+
}
|
|
2379
|
+
};
|
|
2380
|
+
walk(rule?.rule_v2);
|
|
2381
|
+
return [...out];
|
|
2382
|
+
}
|
|
2383
|
+
function routeOf(path) {
|
|
2384
|
+
if (typeof path !== "string" || path === "") return null;
|
|
2385
|
+
const cut = path.search(/[?#]/);
|
|
2386
|
+
return cut === -1 ? path : path.slice(0, cut);
|
|
2387
|
+
}
|
|
2388
|
+
function createDetectionReporter(opts) {
|
|
2389
|
+
const siteUuid = opts.siteUuid ?? process.env?.PATCHSTACK_SITE_UUID;
|
|
2390
|
+
if (!siteUuid) {
|
|
2391
|
+
return { record() {
|
|
2392
|
+
}, flush() {
|
|
2393
|
+
}, stop() {
|
|
2394
|
+
}, dropped: () => 0 };
|
|
2395
|
+
}
|
|
2396
|
+
const configured = opts.baseUrl ?? process.env?.PATCHSTACK_PULSE_RULES_URL;
|
|
2397
|
+
const baseUrl = typeof configured === "string" && isSafeOrigin(configured) ? configured.replace(/\/$/, "") : DEFAULT_BASE_URL3;
|
|
2398
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
2399
|
+
const flushMs = Number.isFinite(opts.flushMs) && opts.flushMs > 0 ? opts.flushMs : DEFAULT_FLUSH_MS;
|
|
2400
|
+
const maxQueue = Number.isFinite(opts.maxQueue) && opts.maxQueue > 0 ? opts.maxQueue : MAX_QUEUE;
|
|
2401
|
+
let queue = [];
|
|
2402
|
+
let timer = null;
|
|
2403
|
+
let stopped = false;
|
|
2404
|
+
let dropped = 0;
|
|
2405
|
+
const flush = () => {
|
|
2406
|
+
if (timer) {
|
|
2407
|
+
clearTimeout(timer);
|
|
2408
|
+
timer = null;
|
|
2409
|
+
}
|
|
2410
|
+
if (queue.length === 0 || typeof fetchImpl !== "function") return;
|
|
2411
|
+
const batch = queue.splice(0, MAX_BATCH);
|
|
2412
|
+
const droppedWith = dropped;
|
|
2413
|
+
dropped = 0;
|
|
2414
|
+
void (async () => {
|
|
2415
|
+
try {
|
|
2416
|
+
const res = await fetchImpl(`${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, {
|
|
2417
|
+
method: "POST",
|
|
2418
|
+
headers: {
|
|
2419
|
+
"Content-Type": "application/json",
|
|
2420
|
+
Accept: "application/json",
|
|
2421
|
+
"User-Agent": "@patchstack/connect",
|
|
2422
|
+
// Same credential path as the rules fetch, and unauthenticated when none resolves: the
|
|
2423
|
+
// server accepts the UUID, and reporting must never hinge on getting a token.
|
|
2424
|
+
...await pulseAuthHeader({ pulseAuth: opts.pulseAuth, endpoint: baseUrl }, fetchImpl)
|
|
2425
|
+
},
|
|
2426
|
+
body: JSON.stringify({ detections: batch, dropped: droppedWith })
|
|
2427
|
+
});
|
|
2428
|
+
if (res && typeof res.then === "function") res.catch(() => {
|
|
2429
|
+
});
|
|
2430
|
+
} catch {
|
|
2431
|
+
}
|
|
2432
|
+
})();
|
|
2433
|
+
};
|
|
2434
|
+
return {
|
|
2435
|
+
/**
|
|
2436
|
+
* @param {{
|
|
2437
|
+
* rule?: { id?: string, rule_v2?: unknown },
|
|
2438
|
+
* phase?: string,
|
|
2439
|
+
* mode?: string,
|
|
2440
|
+
* path?: string,
|
|
2441
|
+
* }} detection
|
|
2442
|
+
*/
|
|
2443
|
+
record(detection) {
|
|
2444
|
+
if (stopped) return;
|
|
2445
|
+
const ruleId = detection?.rule?.id;
|
|
2446
|
+
if (ruleId === void 0 || ruleId === null || ruleId === "") return;
|
|
2447
|
+
if (queue.length >= maxQueue) {
|
|
2448
|
+
queue.shift();
|
|
2449
|
+
dropped++;
|
|
2450
|
+
}
|
|
2451
|
+
queue.push({
|
|
2452
|
+
rule_id: ruleId,
|
|
2453
|
+
route: routeOf(detection.path),
|
|
2454
|
+
parameters: ruleParameters(detection.rule),
|
|
2455
|
+
phase: detection.phase ?? null,
|
|
2456
|
+
// The state this detection was handled under, which is the whole point: `false` is a rule that
|
|
2457
|
+
// saw traffic it would have stopped.
|
|
2458
|
+
enforced: detection.mode === "block",
|
|
2459
|
+
rules_etag: opts.rulesEtag ?? null,
|
|
2460
|
+
detected_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2461
|
+
});
|
|
2462
|
+
if (queue.length >= MAX_BATCH) {
|
|
2463
|
+
flush();
|
|
2464
|
+
return;
|
|
2465
|
+
}
|
|
2466
|
+
if (!timer) timer = setTimeout(flush, flushMs);
|
|
2467
|
+
},
|
|
2468
|
+
flush,
|
|
2469
|
+
stop() {
|
|
2470
|
+
stopped = true;
|
|
2471
|
+
flush();
|
|
2472
|
+
},
|
|
2473
|
+
dropped: () => dropped
|
|
2474
|
+
};
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2311
2477
|
// src/protect/firewall-log.js
|
|
2312
2478
|
var DEFAULT_API_BASE = "https://api.patchstack.com";
|
|
2313
|
-
var
|
|
2314
|
-
var
|
|
2479
|
+
var DEFAULT_FLUSH_MS2 = 1e3;
|
|
2480
|
+
var MAX_BATCH2 = 50;
|
|
2315
2481
|
var TOKEN_SKEW_MS = 6e4;
|
|
2316
2482
|
function parseApiKey(apiKey) {
|
|
2317
2483
|
if (typeof apiKey !== "string" || apiKey.length === 0) return null;
|
|
@@ -2356,7 +2522,7 @@ function createFirewallLogReporter(opts) {
|
|
|
2356
2522
|
}
|
|
2357
2523
|
const apiBase = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
|
|
2358
2524
|
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
2359
|
-
const flushMs = Number.isFinite(opts.flushMs) ? opts.flushMs :
|
|
2525
|
+
const flushMs = Number.isFinite(opts.flushMs) ? opts.flushMs : DEFAULT_FLUSH_MS2;
|
|
2360
2526
|
const sourceHost = typeof opts.sourceHost === "string" ? opts.sourceHost : "";
|
|
2361
2527
|
let queue = [];
|
|
2362
2528
|
let timer = null;
|
|
@@ -2405,7 +2571,7 @@ function createFirewallLogReporter(opts) {
|
|
|
2405
2571
|
timer = null;
|
|
2406
2572
|
}
|
|
2407
2573
|
if (queue.length === 0 || typeof fetchImpl !== "function") return;
|
|
2408
|
-
const batch = queue.splice(0,
|
|
2574
|
+
const batch = queue.splice(0, MAX_BATCH2);
|
|
2409
2575
|
void (async () => {
|
|
2410
2576
|
const token = await fetchAccessToken();
|
|
2411
2577
|
if (!token) return;
|
|
@@ -2452,7 +2618,7 @@ function createFirewallLogReporter(opts) {
|
|
|
2452
2618
|
user_agent: event.userAgent ?? null,
|
|
2453
2619
|
log_date: (/* @__PURE__ */ new Date()).toISOString()
|
|
2454
2620
|
});
|
|
2455
|
-
if (queue.length >=
|
|
2621
|
+
if (queue.length >= MAX_BATCH2) {
|
|
2456
2622
|
flush();
|
|
2457
2623
|
return;
|
|
2458
2624
|
}
|
|
@@ -2551,8 +2717,10 @@ async function createProtection(options = {}) {
|
|
|
2551
2717
|
sourceHost: options.sourceHost,
|
|
2552
2718
|
fetchImpl: options.fetchImpl
|
|
2553
2719
|
}) : null;
|
|
2720
|
+
let detections = null;
|
|
2554
2721
|
const onDetect = (detection) => {
|
|
2555
|
-
userOnDetect
|
|
2722
|
+
notify(userOnDetect, detection, "onDetect");
|
|
2723
|
+
if (detections) detections.record(detection);
|
|
2556
2724
|
if (firewallLog && detection?.mode === "block") {
|
|
2557
2725
|
firewallLog.record({
|
|
2558
2726
|
rule: detection.rule,
|
|
@@ -2566,7 +2734,24 @@ async function createProtection(options = {}) {
|
|
|
2566
2734
|
const store = makeStore(options);
|
|
2567
2735
|
const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
|
|
2568
2736
|
const pulseAuth = await resolvePulseAuth(options);
|
|
2737
|
+
if (options.siteUuid && !pulseAuth) {
|
|
2738
|
+
const message = "Patchstack: no API credential resolved for site " + options.siteUuid + ". Rule updates may be rejected and this guard would keep running on its cached rules. Set PATCHSTACK_API_KEY (or pass { pulseAuth }) \u2014 required on runtimes without a filesystem.";
|
|
2739
|
+
notify(onError, new Error(message), "onError");
|
|
2740
|
+
console.warn(message);
|
|
2741
|
+
}
|
|
2569
2742
|
const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth });
|
|
2743
|
+
if (options.reportDetections === true && options.siteUuid && telemetryEnabled()) {
|
|
2744
|
+
detections = createDetectionReporter({
|
|
2745
|
+
siteUuid: options.siteUuid,
|
|
2746
|
+
baseUrl: options.pulseRulesUrl,
|
|
2747
|
+
pulseAuth,
|
|
2748
|
+
// The bundle the guard is actually running, so a hit can be attributed to the rules that produced
|
|
2749
|
+
// it rather than to whatever is current when the report is read.
|
|
2750
|
+
rulesEtag: (await store.read())?.etag ?? null,
|
|
2751
|
+
fetchImpl: options.fetchImpl,
|
|
2752
|
+
flushMs: options.detectionFlushMs
|
|
2753
|
+
});
|
|
2754
|
+
}
|
|
2570
2755
|
let mode = resolveMode(options, bundle);
|
|
2571
2756
|
let requestRules;
|
|
2572
2757
|
let responseRules;
|
|
@@ -2609,12 +2794,7 @@ async function createProtection(options = {}) {
|
|
|
2609
2794
|
const recordSkip = (phase, reason, detail) => {
|
|
2610
2795
|
const key = `${phase}:${reason}`;
|
|
2611
2796
|
skipCounts[key] = (skipCounts[key] ?? 0) + 1;
|
|
2612
|
-
|
|
2613
|
-
try {
|
|
2614
|
-
onSkip({ phase, reason, detail, count: skipCounts[key] });
|
|
2615
|
-
} catch {
|
|
2616
|
-
}
|
|
2617
|
-
}
|
|
2797
|
+
notify(onSkip, { phase, reason, detail, count: skipCounts[key] }, "onSkip");
|
|
2618
2798
|
};
|
|
2619
2799
|
const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
|
|
2620
2800
|
const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
|
|
@@ -2650,7 +2830,7 @@ async function createProtection(options = {}) {
|
|
|
2650
2830
|
try {
|
|
2651
2831
|
result = re.evaluate({ ...reqCtx || {}, _response: { ...meta, body: text } });
|
|
2652
2832
|
} catch (err) {
|
|
2653
|
-
onError
|
|
2833
|
+
notify(onError, err, "onError");
|
|
2654
2834
|
continue;
|
|
2655
2835
|
}
|
|
2656
2836
|
if (!result.blocked) continue;
|
|
@@ -2772,7 +2952,7 @@ async function createProtection(options = {}) {
|
|
|
2772
2952
|
try {
|
|
2773
2953
|
r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} }, reqCtx);
|
|
2774
2954
|
} catch (err) {
|
|
2775
|
-
onError
|
|
2955
|
+
notify(onError, err, "onError");
|
|
2776
2956
|
for (const c of chunks) origWrite(c);
|
|
2777
2957
|
return origEnd(cb);
|
|
2778
2958
|
}
|
|
@@ -2824,7 +3004,7 @@ async function createProtection(options = {}) {
|
|
|
2824
3004
|
try {
|
|
2825
3005
|
result = egressEngine.evaluate({ _egress: { url, host, method } });
|
|
2826
3006
|
} catch (err) {
|
|
2827
|
-
onError
|
|
3007
|
+
notify(onError, err, "onError");
|
|
2828
3008
|
return false;
|
|
2829
3009
|
}
|
|
2830
3010
|
if (!result.blocked) return false;
|
|
@@ -2858,7 +3038,7 @@ async function createProtection(options = {}) {
|
|
|
2858
3038
|
try {
|
|
2859
3039
|
result = engine.evaluate(await fromFetchRequest(request));
|
|
2860
3040
|
} catch (err) {
|
|
2861
|
-
onError
|
|
3041
|
+
notify(onError, err, "onError");
|
|
2862
3042
|
return null;
|
|
2863
3043
|
}
|
|
2864
3044
|
return decide("request", result, () => blockResponse(result, request), () => null, fetchRequestMeta(request));
|
|
@@ -2882,7 +3062,7 @@ async function createProtection(options = {}) {
|
|
|
2882
3062
|
try {
|
|
2883
3063
|
result = engine.evaluate(req);
|
|
2884
3064
|
} catch (err) {
|
|
2885
|
-
onError
|
|
3065
|
+
notify(onError, err, "onError");
|
|
2886
3066
|
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
2887
3067
|
return next();
|
|
2888
3068
|
}
|
|
@@ -2921,7 +3101,7 @@ async function createProtection(options = {}) {
|
|
|
2921
3101
|
chunks.push(chunk);
|
|
2922
3102
|
});
|
|
2923
3103
|
req.on("error", (err) => {
|
|
2924
|
-
onError
|
|
3104
|
+
notify(onError, err, "onError");
|
|
2925
3105
|
next();
|
|
2926
3106
|
});
|
|
2927
3107
|
req.on("end", () => {
|
|
@@ -2933,7 +3113,7 @@ async function createProtection(options = {}) {
|
|
|
2933
3113
|
shaped = fromNodeRequest(req, rawBody);
|
|
2934
3114
|
result = engine.evaluate(shaped);
|
|
2935
3115
|
} catch (err) {
|
|
2936
|
-
onError
|
|
3116
|
+
notify(onError, err, "onError");
|
|
2937
3117
|
return next();
|
|
2938
3118
|
}
|
|
2939
3119
|
decide(
|
|
@@ -2978,9 +3158,9 @@ async function createProtection(options = {}) {
|
|
|
2978
3158
|
let reporter = null;
|
|
2979
3159
|
if (refreshable && options.siteUuid && options.reportManifest !== false && cwd) {
|
|
2980
3160
|
try {
|
|
2981
|
-
({ reportManifest: reporter } = await import("./refresh-manifest-
|
|
3161
|
+
({ reportManifest: reporter } = await import("./refresh-manifest-VRBE6RH6.js"));
|
|
2982
3162
|
} catch (err) {
|
|
2983
|
-
onError
|
|
3163
|
+
notify(onError, err, "onError");
|
|
2984
3164
|
}
|
|
2985
3165
|
}
|
|
2986
3166
|
const runRefreshTick = async () => {
|
|
@@ -2988,7 +3168,7 @@ async function createProtection(options = {}) {
|
|
|
2988
3168
|
try {
|
|
2989
3169
|
await reporter(cwd);
|
|
2990
3170
|
} catch (err) {
|
|
2991
|
-
onError
|
|
3171
|
+
notify(onError, err, "onError");
|
|
2992
3172
|
}
|
|
2993
3173
|
}
|
|
2994
3174
|
const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
|
|
@@ -3004,9 +3184,13 @@ async function createProtection(options = {}) {
|
|
|
3004
3184
|
protection.stopRefresh = () => {
|
|
3005
3185
|
loop.stop();
|
|
3006
3186
|
firewallLog?.stop();
|
|
3187
|
+
detections?.stop();
|
|
3007
3188
|
};
|
|
3008
3189
|
} else if (firewallLog) {
|
|
3009
|
-
protection.stopRefresh = () =>
|
|
3190
|
+
protection.stopRefresh = () => {
|
|
3191
|
+
firewallLog.stop();
|
|
3192
|
+
detections?.stop();
|
|
3193
|
+
};
|
|
3010
3194
|
}
|
|
3011
3195
|
return protection;
|
|
3012
3196
|
}
|