@patchstack/connect 0.3.27 → 0.3.29
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 +65 -2
- package/dist/cli.js +243 -45
- 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 +229 -59
- package/dist/protect.cjs.map +1 -1
- package/dist/protect.d.ts +17 -0
- package/dist/protect.edge.js +211 -59
- package/dist/protect.edge.js.map +4 -4
- package/dist/protect.js +212 -60
- 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.d.ts
CHANGED
|
@@ -68,6 +68,23 @@ export interface CreateProtectionOptions {
|
|
|
68
68
|
* Also disabled when `PATCHSTACK_TELEMETRY=off` or when no apiKey is available.
|
|
69
69
|
*/
|
|
70
70
|
reportFirewallLog?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Report EVERY rule that fired — including one in `dry-run` that did not block — to the Pulse
|
|
73
|
+
* detections endpoint. Off unless explicitly `true`.
|
|
74
|
+
*
|
|
75
|
+
* Why it exists: a rule that blocks nothing reports nothing, so a rule that is quietly wrong and a
|
|
76
|
+
* rule that is protecting look identical from the outside.
|
|
77
|
+
*
|
|
78
|
+
* What it sends, per detection: the rule id, the request PATH with the query string removed, the
|
|
79
|
+
* parameters the rule reads, the phase, whether it was enforced, the rule-bundle ETag, and a
|
|
80
|
+
* timestamp. It does NOT send the matched value, the request body, headers, or query-string values —
|
|
81
|
+
* this is a counting channel, not a copy of your traffic.
|
|
82
|
+
*
|
|
83
|
+
* Off by default because switching it on adds an outbound request to every guard with a site UUID.
|
|
84
|
+
*/
|
|
85
|
+
reportDetections?: boolean;
|
|
86
|
+
/** How long to buffer detections before posting a batch. Default 5000ms. */
|
|
87
|
+
detectionFlushMs?: number;
|
|
71
88
|
/** Optional Source-Host header for connector hostname checks. */
|
|
72
89
|
sourceHost?: string;
|
|
73
90
|
/** Optional fetch override (tests). */
|
package/dist/protect.edge.js
CHANGED
|
@@ -512,6 +512,35 @@ var RequestResolver = class {
|
|
|
512
512
|
}
|
|
513
513
|
};
|
|
514
514
|
|
|
515
|
+
// src/protect/notify.js
|
|
516
|
+
var reported = /* @__PURE__ */ new Set();
|
|
517
|
+
function notify(fn, arg, label) {
|
|
518
|
+
if (typeof fn !== "function") return false;
|
|
519
|
+
try {
|
|
520
|
+
const result = fn(arg);
|
|
521
|
+
if (result !== null && typeof result === "object" && typeof result.then === "function") {
|
|
522
|
+
try {
|
|
523
|
+
result.then(void 0, (err) => warnOnce2(label, err));
|
|
524
|
+
} catch {
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return true;
|
|
528
|
+
} catch (err) {
|
|
529
|
+
warnOnce2(label, err);
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
function warnOnce2(label, err) {
|
|
534
|
+
if (reported.has(label)) return;
|
|
535
|
+
reported.add(label);
|
|
536
|
+
try {
|
|
537
|
+
console.warn(
|
|
538
|
+
`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)}`
|
|
539
|
+
);
|
|
540
|
+
} catch {
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
515
544
|
// src/protect/engine/normalizer.js
|
|
516
545
|
var HTML_ENTITIES = {
|
|
517
546
|
"&": "&",
|
|
@@ -796,7 +825,7 @@ function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
|
|
|
796
825
|
return out;
|
|
797
826
|
}
|
|
798
827
|
var warnedKeys = /* @__PURE__ */ new Set();
|
|
799
|
-
function
|
|
828
|
+
function warnOnce3(key, message) {
|
|
800
829
|
if (warnedKeys.has(key)) return;
|
|
801
830
|
warnedKeys.add(key);
|
|
802
831
|
console.warn(message);
|
|
@@ -1138,21 +1167,20 @@ var RuleEngine = class {
|
|
|
1138
1167
|
this.#whitelistKeys = whitelist_keys;
|
|
1139
1168
|
this.#onError = onError;
|
|
1140
1169
|
if (Array.isArray(whitelists) && whitelists.some((w) => w && Array.isArray(w.rule_v2) && !w.rule_id)) {
|
|
1141
|
-
|
|
1170
|
+
warnOnce3(
|
|
1142
1171
|
"whitelist-no-rule-id",
|
|
1143
1172
|
"[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."
|
|
1144
1173
|
);
|
|
1145
1174
|
}
|
|
1146
1175
|
if (whitelist_keys && typeof whitelist_keys === "object" && Object.keys(whitelist_keys).length > 0) {
|
|
1147
|
-
|
|
1176
|
+
warnOnce3("whitelist-keys-unimplemented", "[patchstack] `whitelist_keys` is not implemented and has no effect.");
|
|
1148
1177
|
}
|
|
1149
1178
|
}
|
|
1150
1179
|
// A mitigation engine must never take down the app it protects: any error while
|
|
1151
1180
|
// evaluating a rule is reported and the request is allowed through (fail open). A
|
|
1152
1181
|
// malformed rule is skipped without aborting the rest of the ruleset.
|
|
1153
1182
|
#reportError(err) {
|
|
1154
|
-
if (this.#onError) {
|
|
1155
|
-
this.#onError(err);
|
|
1183
|
+
if (notify(this.#onError, err, "onError")) {
|
|
1156
1184
|
return;
|
|
1157
1185
|
}
|
|
1158
1186
|
const key = err && err.message ? err.message : String(err);
|
|
@@ -1360,13 +1388,15 @@ var PulseRuleClient = class {
|
|
|
1360
1388
|
#ttlEffective = 0;
|
|
1361
1389
|
#etag;
|
|
1362
1390
|
#pulseAuth;
|
|
1363
|
-
|
|
1391
|
+
#reportsDetections;
|
|
1392
|
+
constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, reportsDetections } = {}) {
|
|
1364
1393
|
this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
|
|
1365
1394
|
this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
|
|
1366
1395
|
this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL, DEFAULT_BASE_URL2, "rule endpoint");
|
|
1367
1396
|
this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL2;
|
|
1368
1397
|
this.#etag = etag ?? null;
|
|
1369
1398
|
this.#pulseAuth = pulseAuth ?? null;
|
|
1399
|
+
this.#reportsDetections = reportsDetections === true;
|
|
1370
1400
|
if (!this.#siteUuid) {
|
|
1371
1401
|
throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
|
|
1372
1402
|
}
|
|
@@ -1378,13 +1408,14 @@ var PulseRuleClient = class {
|
|
|
1378
1408
|
}
|
|
1379
1409
|
const url = `${this.#baseUrl}/rules/${encodeURIComponent(this.#siteUuid)}`;
|
|
1380
1410
|
try {
|
|
1381
|
-
const
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1411
|
+
const auth = await pulseAuthHeader(
|
|
1412
|
+
{ pulseAuth: this.#pulseAuth, endpoint: this.#baseUrl, timeoutMs: this.#timeoutMs },
|
|
1413
|
+
fetch
|
|
1414
|
+
);
|
|
1415
|
+
const headers = { Accept: "application/json", ...auth };
|
|
1416
|
+
if (this.#reportsDetections && typeof auth.Authorization === "string") {
|
|
1417
|
+
headers["X-Patchstack-Detections"] = "enabled";
|
|
1418
|
+
}
|
|
1388
1419
|
if (this.#etag) headers["If-None-Match"] = this.#etag;
|
|
1389
1420
|
const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
|
|
1390
1421
|
if (response.status === 304) {
|
|
@@ -1666,7 +1697,7 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
1666
1697
|
const exempt = new Set((allowHosts ?? []).map((h) => String(h).toLowerCase()));
|
|
1667
1698
|
const block = (url, host, method) => {
|
|
1668
1699
|
if (!shouldBlock(url, host, method)) return false;
|
|
1669
|
-
onBlock
|
|
1700
|
+
notify(onBlock, { url, host, method }, "onEgressBlock");
|
|
1670
1701
|
return true;
|
|
1671
1702
|
};
|
|
1672
1703
|
let screen = null;
|
|
@@ -1681,12 +1712,7 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
1681
1712
|
screen = null;
|
|
1682
1713
|
}
|
|
1683
1714
|
}
|
|
1684
|
-
const skip = (reason, detail) => {
|
|
1685
|
-
try {
|
|
1686
|
-
onSkip?.({ phase: "egress", reason, detail });
|
|
1687
|
-
} catch {
|
|
1688
|
-
}
|
|
1689
|
-
};
|
|
1715
|
+
const skip = (reason, detail) => notify(onSkip, { phase: "egress", reason, detail }, "onSkip");
|
|
1690
1716
|
const resolvesToDisallowed = (url, host, method) => new Promise((resolve) => {
|
|
1691
1717
|
if (!screen) {
|
|
1692
1718
|
if (host && dnsScreen) skip("resolver-unavailable", { host });
|
|
@@ -2272,22 +2298,19 @@ function reportRejections(rejected, options, label) {
|
|
|
2272
2298
|
const report = options.onRuleRejected;
|
|
2273
2299
|
for (const r of rejected) {
|
|
2274
2300
|
if (typeof report === "function") {
|
|
2275
|
-
|
|
2276
|
-
report({ ...r, accepted: false });
|
|
2277
|
-
} catch {
|
|
2278
|
-
}
|
|
2301
|
+
notify(report, { ...r, accepted: false }, "onRuleRejected");
|
|
2279
2302
|
}
|
|
2280
2303
|
}
|
|
2281
2304
|
const sample = rejected.slice(0, 3).map((r) => `${r.id} (${r.reason})`).join("; ");
|
|
2282
|
-
options.onError
|
|
2305
|
+
notify(options.onError, new Error(
|
|
2283
2306
|
`${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" : ""}`
|
|
2284
|
-
));
|
|
2307
|
+
), "onError");
|
|
2285
2308
|
}
|
|
2286
2309
|
async function resolveRules(options, store, ctx = {}) {
|
|
2287
2310
|
const timeoutMs = ctx.timeoutMs;
|
|
2288
2311
|
if (options.siteUuid) {
|
|
2289
2312
|
const prior = await store.read();
|
|
2290
|
-
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth });
|
|
2313
|
+
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, reportsDetections: options.reportDetections === true });
|
|
2291
2314
|
const res = await client.getRules();
|
|
2292
2315
|
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
2293
2316
|
if (res.success && !res.notModified) {
|
|
@@ -2303,14 +2326,14 @@ async function resolveRules(options, store, ctx = {}) {
|
|
|
2303
2326
|
return bundle;
|
|
2304
2327
|
}
|
|
2305
2328
|
if (prior?.bundle) {
|
|
2306
|
-
options.onError
|
|
2329
|
+
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
|
|
2307
2330
|
return normalizeBundle(prior.bundle, options);
|
|
2308
2331
|
}
|
|
2309
2332
|
if (options.rules) {
|
|
2310
|
-
options.onError
|
|
2333
|
+
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`), "onError");
|
|
2311
2334
|
return normalizeBundle(options.rules, options);
|
|
2312
2335
|
}
|
|
2313
|
-
options.onError
|
|
2336
|
+
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
|
|
2314
2337
|
return emptyBundle();
|
|
2315
2338
|
}
|
|
2316
2339
|
if (options.token) {
|
|
@@ -2331,10 +2354,10 @@ async function resolveRules(options, store, ctx = {}) {
|
|
|
2331
2354
|
return bundle;
|
|
2332
2355
|
}
|
|
2333
2356
|
if (prior?.bundle) {
|
|
2334
|
-
options.onError
|
|
2357
|
+
notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
|
|
2335
2358
|
return normalizeBundle(prior.bundle, options);
|
|
2336
2359
|
}
|
|
2337
|
-
options.onError
|
|
2360
|
+
notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
|
|
2338
2361
|
return emptyBundle();
|
|
2339
2362
|
}
|
|
2340
2363
|
if (options.rules) {
|
|
@@ -2355,10 +2378,7 @@ function normalizeBundle(b, options = {}) {
|
|
|
2355
2378
|
const report = options.onRuleRejected;
|
|
2356
2379
|
if (typeof report === "function") {
|
|
2357
2380
|
for (const r of rejected) {
|
|
2358
|
-
|
|
2359
|
-
report(r);
|
|
2360
|
-
} catch {
|
|
2361
|
-
}
|
|
2381
|
+
notify(report, r, "onRuleRejected");
|
|
2362
2382
|
}
|
|
2363
2383
|
} else {
|
|
2364
2384
|
const sample = rejected.slice(0, 3).map((r) => `${r.id} (${r.reason})`).join("; ");
|
|
@@ -2400,7 +2420,7 @@ function startRefresh(tick, { refreshMs, onError } = {}) {
|
|
|
2400
2420
|
failures = 0;
|
|
2401
2421
|
} catch (err) {
|
|
2402
2422
|
failures++;
|
|
2403
|
-
onError
|
|
2423
|
+
notify(onError, err, "onError");
|
|
2404
2424
|
}
|
|
2405
2425
|
schedule();
|
|
2406
2426
|
};
|
|
@@ -2432,10 +2452,124 @@ function makeRefreshHandler(tick, secret) {
|
|
|
2432
2452
|
};
|
|
2433
2453
|
}
|
|
2434
2454
|
|
|
2455
|
+
// src/protect/detections.js
|
|
2456
|
+
var DEFAULT_BASE_URL3 = "https://api.patchstack.com/monitor/pulse";
|
|
2457
|
+
var DEFAULT_FLUSH_MS = 5e3;
|
|
2458
|
+
var MAX_BATCH = 50;
|
|
2459
|
+
var MAX_QUEUE = 500;
|
|
2460
|
+
function ruleParameters(rule) {
|
|
2461
|
+
const out = /* @__PURE__ */ new Set();
|
|
2462
|
+
const walk = (conditions) => {
|
|
2463
|
+
if (!Array.isArray(conditions)) return;
|
|
2464
|
+
for (const condition of conditions) {
|
|
2465
|
+
if (!condition || typeof condition !== "object") continue;
|
|
2466
|
+
if (typeof condition.parameter === "string" && condition.parameter !== "rules") {
|
|
2467
|
+
out.add(condition.parameter);
|
|
2468
|
+
}
|
|
2469
|
+
if (Array.isArray(condition.rules)) walk(condition.rules);
|
|
2470
|
+
}
|
|
2471
|
+
};
|
|
2472
|
+
walk(rule?.rule_v2);
|
|
2473
|
+
return [...out];
|
|
2474
|
+
}
|
|
2475
|
+
function routeOf(path) {
|
|
2476
|
+
if (typeof path !== "string" || path === "") return null;
|
|
2477
|
+
const cut = path.search(/[?#]/);
|
|
2478
|
+
return cut === -1 ? path : path.slice(0, cut);
|
|
2479
|
+
}
|
|
2480
|
+
function createDetectionReporter(opts) {
|
|
2481
|
+
const siteUuid = opts.siteUuid ?? process.env?.PATCHSTACK_SITE_UUID;
|
|
2482
|
+
if (!siteUuid) {
|
|
2483
|
+
return { record() {
|
|
2484
|
+
}, flush() {
|
|
2485
|
+
}, stop() {
|
|
2486
|
+
}, dropped: () => 0 };
|
|
2487
|
+
}
|
|
2488
|
+
const configured = opts.baseUrl ?? process.env?.PATCHSTACK_PULSE_RULES_URL;
|
|
2489
|
+
const baseUrl = typeof configured === "string" && isSafeOrigin(configured) ? configured.replace(/\/$/, "") : DEFAULT_BASE_URL3;
|
|
2490
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
2491
|
+
const flushMs = Number.isFinite(opts.flushMs) && opts.flushMs > 0 ? opts.flushMs : DEFAULT_FLUSH_MS;
|
|
2492
|
+
const maxQueue = Number.isFinite(opts.maxQueue) && opts.maxQueue > 0 ? opts.maxQueue : MAX_QUEUE;
|
|
2493
|
+
let queue = [];
|
|
2494
|
+
let timer = null;
|
|
2495
|
+
let stopped = false;
|
|
2496
|
+
let dropped = 0;
|
|
2497
|
+
const flush = () => {
|
|
2498
|
+
if (timer) {
|
|
2499
|
+
clearTimeout(timer);
|
|
2500
|
+
timer = null;
|
|
2501
|
+
}
|
|
2502
|
+
if (queue.length === 0 || typeof fetchImpl !== "function") return;
|
|
2503
|
+
const batch = queue.splice(0, MAX_BATCH);
|
|
2504
|
+
const droppedWith = dropped;
|
|
2505
|
+
dropped = 0;
|
|
2506
|
+
void (async () => {
|
|
2507
|
+
try {
|
|
2508
|
+
const res = await fetchImpl(`${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, {
|
|
2509
|
+
method: "POST",
|
|
2510
|
+
headers: {
|
|
2511
|
+
"Content-Type": "application/json",
|
|
2512
|
+
Accept: "application/json",
|
|
2513
|
+
"User-Agent": "@patchstack/connect",
|
|
2514
|
+
// Same credential path as the rules fetch, and unauthenticated when none resolves: the
|
|
2515
|
+
// server accepts the UUID, and reporting must never hinge on getting a token.
|
|
2516
|
+
...await pulseAuthHeader({ pulseAuth: opts.pulseAuth, endpoint: baseUrl }, fetchImpl)
|
|
2517
|
+
},
|
|
2518
|
+
body: JSON.stringify({ detections: batch, dropped: droppedWith })
|
|
2519
|
+
});
|
|
2520
|
+
if (res && typeof res.then === "function") res.catch(() => {
|
|
2521
|
+
});
|
|
2522
|
+
} catch {
|
|
2523
|
+
}
|
|
2524
|
+
})();
|
|
2525
|
+
};
|
|
2526
|
+
return {
|
|
2527
|
+
/**
|
|
2528
|
+
* @param {{
|
|
2529
|
+
* rule?: { id?: string, rule_v2?: unknown },
|
|
2530
|
+
* phase?: string,
|
|
2531
|
+
* mode?: string,
|
|
2532
|
+
* path?: string,
|
|
2533
|
+
* }} detection
|
|
2534
|
+
*/
|
|
2535
|
+
record(detection) {
|
|
2536
|
+
if (stopped) return;
|
|
2537
|
+
const ruleId = detection?.rule?.id;
|
|
2538
|
+
if (ruleId === void 0 || ruleId === null || ruleId === "") return;
|
|
2539
|
+
if (queue.length >= maxQueue) {
|
|
2540
|
+
queue.shift();
|
|
2541
|
+
dropped++;
|
|
2542
|
+
}
|
|
2543
|
+
queue.push({
|
|
2544
|
+
rule_id: ruleId,
|
|
2545
|
+
route: routeOf(detection.path),
|
|
2546
|
+
parameters: ruleParameters(detection.rule),
|
|
2547
|
+
phase: detection.phase ?? null,
|
|
2548
|
+
// The state this detection was handled under, which is the whole point: `false` is a rule that
|
|
2549
|
+
// saw traffic it would have stopped.
|
|
2550
|
+
enforced: detection.mode === "block",
|
|
2551
|
+
rules_etag: opts.rulesEtag ?? null,
|
|
2552
|
+
detected_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2553
|
+
});
|
|
2554
|
+
if (queue.length >= MAX_BATCH) {
|
|
2555
|
+
flush();
|
|
2556
|
+
return;
|
|
2557
|
+
}
|
|
2558
|
+
if (!timer) timer = setTimeout(flush, flushMs);
|
|
2559
|
+
},
|
|
2560
|
+
flush,
|
|
2561
|
+
stop() {
|
|
2562
|
+
stopped = true;
|
|
2563
|
+
flush();
|
|
2564
|
+
},
|
|
2565
|
+
dropped: () => dropped
|
|
2566
|
+
};
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2435
2569
|
// src/protect/firewall-log.js
|
|
2436
2570
|
var DEFAULT_API_BASE = "https://api.patchstack.com";
|
|
2437
|
-
var
|
|
2438
|
-
var
|
|
2571
|
+
var DEFAULT_FLUSH_MS2 = 1e3;
|
|
2572
|
+
var MAX_BATCH2 = 50;
|
|
2439
2573
|
var TOKEN_SKEW_MS2 = 6e4;
|
|
2440
2574
|
function parseApiKey(apiKey) {
|
|
2441
2575
|
if (typeof apiKey !== "string" || apiKey.length === 0) return null;
|
|
@@ -2480,7 +2614,7 @@ function createFirewallLogReporter(opts) {
|
|
|
2480
2614
|
}
|
|
2481
2615
|
const apiBase = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
|
|
2482
2616
|
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
2483
|
-
const flushMs = Number.isFinite(opts.flushMs) ? opts.flushMs :
|
|
2617
|
+
const flushMs = Number.isFinite(opts.flushMs) ? opts.flushMs : DEFAULT_FLUSH_MS2;
|
|
2484
2618
|
const sourceHost = typeof opts.sourceHost === "string" ? opts.sourceHost : "";
|
|
2485
2619
|
let queue = [];
|
|
2486
2620
|
let timer = null;
|
|
@@ -2529,7 +2663,7 @@ function createFirewallLogReporter(opts) {
|
|
|
2529
2663
|
timer = null;
|
|
2530
2664
|
}
|
|
2531
2665
|
if (queue.length === 0 || typeof fetchImpl !== "function") return;
|
|
2532
|
-
const batch = queue.splice(0,
|
|
2666
|
+
const batch = queue.splice(0, MAX_BATCH2);
|
|
2533
2667
|
void (async () => {
|
|
2534
2668
|
const token = await fetchAccessToken();
|
|
2535
2669
|
if (!token) return;
|
|
@@ -2576,7 +2710,7 @@ function createFirewallLogReporter(opts) {
|
|
|
2576
2710
|
user_agent: event.userAgent ?? null,
|
|
2577
2711
|
log_date: (/* @__PURE__ */ new Date()).toISOString()
|
|
2578
2712
|
});
|
|
2579
|
-
if (queue.length >=
|
|
2713
|
+
if (queue.length >= MAX_BATCH2) {
|
|
2580
2714
|
flush();
|
|
2581
2715
|
return;
|
|
2582
2716
|
}
|
|
@@ -2675,8 +2809,10 @@ async function createProtection(options = {}) {
|
|
|
2675
2809
|
sourceHost: options.sourceHost,
|
|
2676
2810
|
fetchImpl: options.fetchImpl
|
|
2677
2811
|
}) : null;
|
|
2812
|
+
let detections = null;
|
|
2678
2813
|
const onDetect = (detection) => {
|
|
2679
|
-
userOnDetect
|
|
2814
|
+
notify(userOnDetect, detection, "onDetect");
|
|
2815
|
+
if (detections) detections.record(detection);
|
|
2680
2816
|
if (firewallLog && detection?.mode === "block") {
|
|
2681
2817
|
firewallLog.record({
|
|
2682
2818
|
rule: detection.rule,
|
|
@@ -2690,7 +2826,24 @@ async function createProtection(options = {}) {
|
|
|
2690
2826
|
const store = makeStore(options);
|
|
2691
2827
|
const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
|
|
2692
2828
|
const pulseAuth = await resolvePulseAuth(options);
|
|
2829
|
+
if (options.siteUuid && !pulseAuth) {
|
|
2830
|
+
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.";
|
|
2831
|
+
notify(onError, new Error(message), "onError");
|
|
2832
|
+
console.warn(message);
|
|
2833
|
+
}
|
|
2693
2834
|
const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth });
|
|
2835
|
+
if (options.reportDetections === true && options.siteUuid && telemetryEnabled()) {
|
|
2836
|
+
detections = createDetectionReporter({
|
|
2837
|
+
siteUuid: options.siteUuid,
|
|
2838
|
+
baseUrl: options.pulseRulesUrl,
|
|
2839
|
+
pulseAuth,
|
|
2840
|
+
// The bundle the guard is actually running, so a hit can be attributed to the rules that produced
|
|
2841
|
+
// it rather than to whatever is current when the report is read.
|
|
2842
|
+
rulesEtag: (await store.read())?.etag ?? null,
|
|
2843
|
+
fetchImpl: options.fetchImpl,
|
|
2844
|
+
flushMs: options.detectionFlushMs
|
|
2845
|
+
});
|
|
2846
|
+
}
|
|
2694
2847
|
let mode = resolveMode(options, bundle);
|
|
2695
2848
|
let requestRules;
|
|
2696
2849
|
let responseRules;
|
|
@@ -2733,12 +2886,7 @@ async function createProtection(options = {}) {
|
|
|
2733
2886
|
const recordSkip = (phase, reason, detail) => {
|
|
2734
2887
|
const key = `${phase}:${reason}`;
|
|
2735
2888
|
skipCounts[key] = (skipCounts[key] ?? 0) + 1;
|
|
2736
|
-
|
|
2737
|
-
try {
|
|
2738
|
-
onSkip({ phase, reason, detail, count: skipCounts[key] });
|
|
2739
|
-
} catch {
|
|
2740
|
-
}
|
|
2741
|
-
}
|
|
2889
|
+
notify(onSkip, { phase, reason, detail, count: skipCounts[key] }, "onSkip");
|
|
2742
2890
|
};
|
|
2743
2891
|
const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
|
|
2744
2892
|
const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
|
|
@@ -2774,7 +2922,7 @@ async function createProtection(options = {}) {
|
|
|
2774
2922
|
try {
|
|
2775
2923
|
result = re.evaluate({ ...reqCtx || {}, _response: { ...meta, body: text } });
|
|
2776
2924
|
} catch (err) {
|
|
2777
|
-
onError
|
|
2925
|
+
notify(onError, err, "onError");
|
|
2778
2926
|
continue;
|
|
2779
2927
|
}
|
|
2780
2928
|
if (!result.blocked) continue;
|
|
@@ -2896,7 +3044,7 @@ async function createProtection(options = {}) {
|
|
|
2896
3044
|
try {
|
|
2897
3045
|
r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} }, reqCtx);
|
|
2898
3046
|
} catch (err) {
|
|
2899
|
-
onError
|
|
3047
|
+
notify(onError, err, "onError");
|
|
2900
3048
|
for (const c of chunks) origWrite(c);
|
|
2901
3049
|
return origEnd(cb);
|
|
2902
3050
|
}
|
|
@@ -2948,7 +3096,7 @@ async function createProtection(options = {}) {
|
|
|
2948
3096
|
try {
|
|
2949
3097
|
result = egressEngine.evaluate({ _egress: { url, host, method } });
|
|
2950
3098
|
} catch (err) {
|
|
2951
|
-
onError
|
|
3099
|
+
notify(onError, err, "onError");
|
|
2952
3100
|
return false;
|
|
2953
3101
|
}
|
|
2954
3102
|
if (!result.blocked) return false;
|
|
@@ -2982,7 +3130,7 @@ async function createProtection(options = {}) {
|
|
|
2982
3130
|
try {
|
|
2983
3131
|
result = engine.evaluate(await fromFetchRequest(request));
|
|
2984
3132
|
} catch (err) {
|
|
2985
|
-
onError
|
|
3133
|
+
notify(onError, err, "onError");
|
|
2986
3134
|
return null;
|
|
2987
3135
|
}
|
|
2988
3136
|
return decide("request", result, () => blockResponse(result, request), () => null, fetchRequestMeta(request));
|
|
@@ -3006,7 +3154,7 @@ async function createProtection(options = {}) {
|
|
|
3006
3154
|
try {
|
|
3007
3155
|
result = engine.evaluate(req);
|
|
3008
3156
|
} catch (err) {
|
|
3009
|
-
onError
|
|
3157
|
+
notify(onError, err, "onError");
|
|
3010
3158
|
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
3011
3159
|
return next();
|
|
3012
3160
|
}
|
|
@@ -3045,7 +3193,7 @@ async function createProtection(options = {}) {
|
|
|
3045
3193
|
chunks.push(chunk);
|
|
3046
3194
|
});
|
|
3047
3195
|
req.on("error", (err) => {
|
|
3048
|
-
onError
|
|
3196
|
+
notify(onError, err, "onError");
|
|
3049
3197
|
next();
|
|
3050
3198
|
});
|
|
3051
3199
|
req.on("end", () => {
|
|
@@ -3057,7 +3205,7 @@ async function createProtection(options = {}) {
|
|
|
3057
3205
|
shaped = fromNodeRequest(req, rawBody);
|
|
3058
3206
|
result = engine.evaluate(shaped);
|
|
3059
3207
|
} catch (err) {
|
|
3060
|
-
onError
|
|
3208
|
+
notify(onError, err, "onError");
|
|
3061
3209
|
return next();
|
|
3062
3210
|
}
|
|
3063
3211
|
decide(
|
|
@@ -3104,7 +3252,7 @@ async function createProtection(options = {}) {
|
|
|
3104
3252
|
try {
|
|
3105
3253
|
({ reportManifest: reporter } = await Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1)));
|
|
3106
3254
|
} catch (err) {
|
|
3107
|
-
onError
|
|
3255
|
+
notify(onError, err, "onError");
|
|
3108
3256
|
}
|
|
3109
3257
|
}
|
|
3110
3258
|
const runRefreshTick = async () => {
|
|
@@ -3112,7 +3260,7 @@ async function createProtection(options = {}) {
|
|
|
3112
3260
|
try {
|
|
3113
3261
|
await reporter(cwd);
|
|
3114
3262
|
} catch (err) {
|
|
3115
|
-
onError
|
|
3263
|
+
notify(onError, err, "onError");
|
|
3116
3264
|
}
|
|
3117
3265
|
}
|
|
3118
3266
|
const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
|
|
@@ -3128,9 +3276,13 @@ async function createProtection(options = {}) {
|
|
|
3128
3276
|
protection.stopRefresh = () => {
|
|
3129
3277
|
loop.stop();
|
|
3130
3278
|
firewallLog?.stop();
|
|
3279
|
+
detections?.stop();
|
|
3131
3280
|
};
|
|
3132
3281
|
} else if (firewallLog) {
|
|
3133
|
-
protection.stopRefresh = () =>
|
|
3282
|
+
protection.stopRefresh = () => {
|
|
3283
|
+
firewallLog.stop();
|
|
3284
|
+
detections?.stop();
|
|
3285
|
+
};
|
|
3134
3286
|
}
|
|
3135
3287
|
return protection;
|
|
3136
3288
|
}
|