@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.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);
|
|
@@ -1109,21 +1138,20 @@ var RuleEngine = class {
|
|
|
1109
1138
|
this.#whitelistKeys = whitelist_keys;
|
|
1110
1139
|
this.#onError = onError;
|
|
1111
1140
|
if (Array.isArray(whitelists) && whitelists.some((w) => w && Array.isArray(w.rule_v2) && !w.rule_id)) {
|
|
1112
|
-
|
|
1141
|
+
warnOnce3(
|
|
1113
1142
|
"whitelist-no-rule-id",
|
|
1114
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."
|
|
1115
1144
|
);
|
|
1116
1145
|
}
|
|
1117
1146
|
if (whitelist_keys && typeof whitelist_keys === "object" && Object.keys(whitelist_keys).length > 0) {
|
|
1118
|
-
|
|
1147
|
+
warnOnce3("whitelist-keys-unimplemented", "[patchstack] `whitelist_keys` is not implemented and has no effect.");
|
|
1119
1148
|
}
|
|
1120
1149
|
}
|
|
1121
1150
|
// A mitigation engine must never take down the app it protects: any error while
|
|
1122
1151
|
// evaluating a rule is reported and the request is allowed through (fail open). A
|
|
1123
1152
|
// malformed rule is skipped without aborting the rest of the ruleset.
|
|
1124
1153
|
#reportError(err) {
|
|
1125
|
-
if (this.#onError) {
|
|
1126
|
-
this.#onError(err);
|
|
1154
|
+
if (notify(this.#onError, err, "onError")) {
|
|
1127
1155
|
return;
|
|
1128
1156
|
}
|
|
1129
1157
|
const key = err && err.message ? err.message : String(err);
|
|
@@ -1268,13 +1296,15 @@ var PulseRuleClient = class {
|
|
|
1268
1296
|
#ttlEffective = 0;
|
|
1269
1297
|
#etag;
|
|
1270
1298
|
#pulseAuth;
|
|
1271
|
-
|
|
1299
|
+
#reportsDetections;
|
|
1300
|
+
constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, reportsDetections } = {}) {
|
|
1272
1301
|
this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
|
|
1273
1302
|
this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
|
|
1274
1303
|
this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL, DEFAULT_BASE_URL2, "rule endpoint");
|
|
1275
1304
|
this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL2;
|
|
1276
1305
|
this.#etag = etag ?? null;
|
|
1277
1306
|
this.#pulseAuth = pulseAuth ?? null;
|
|
1307
|
+
this.#reportsDetections = reportsDetections === true;
|
|
1278
1308
|
if (!this.#siteUuid) {
|
|
1279
1309
|
throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
|
|
1280
1310
|
}
|
|
@@ -1286,13 +1316,14 @@ var PulseRuleClient = class {
|
|
|
1286
1316
|
}
|
|
1287
1317
|
const url = `${this.#baseUrl}/rules/${encodeURIComponent(this.#siteUuid)}`;
|
|
1288
1318
|
try {
|
|
1289
|
-
const
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
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
|
+
}
|
|
1296
1327
|
if (this.#etag) headers["If-None-Match"] = this.#etag;
|
|
1297
1328
|
const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
|
|
1298
1329
|
if (response.status === 304) {
|
|
@@ -1574,7 +1605,7 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
1574
1605
|
const exempt = new Set((allowHosts ?? []).map((h) => String(h).toLowerCase()));
|
|
1575
1606
|
const block = (url, host, method) => {
|
|
1576
1607
|
if (!shouldBlock(url, host, method)) return false;
|
|
1577
|
-
onBlock
|
|
1608
|
+
notify(onBlock, { url, host, method }, "onEgressBlock");
|
|
1578
1609
|
return true;
|
|
1579
1610
|
};
|
|
1580
1611
|
let screen = null;
|
|
@@ -1589,12 +1620,7 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
1589
1620
|
screen = null;
|
|
1590
1621
|
}
|
|
1591
1622
|
}
|
|
1592
|
-
const skip = (reason, detail) => {
|
|
1593
|
-
try {
|
|
1594
|
-
onSkip?.({ phase: "egress", reason, detail });
|
|
1595
|
-
} catch {
|
|
1596
|
-
}
|
|
1597
|
-
};
|
|
1623
|
+
const skip = (reason, detail) => notify(onSkip, { phase: "egress", reason, detail }, "onSkip");
|
|
1598
1624
|
const resolvesToDisallowed = (url, host, method) => new Promise((resolve) => {
|
|
1599
1625
|
if (!screen) {
|
|
1600
1626
|
if (host && dnsScreen) skip("resolver-unavailable", { host });
|
|
@@ -2180,22 +2206,19 @@ function reportRejections(rejected, options, label) {
|
|
|
2180
2206
|
const report = options.onRuleRejected;
|
|
2181
2207
|
for (const r of rejected) {
|
|
2182
2208
|
if (typeof report === "function") {
|
|
2183
|
-
|
|
2184
|
-
report({ ...r, accepted: false });
|
|
2185
|
-
} catch {
|
|
2186
|
-
}
|
|
2209
|
+
notify(report, { ...r, accepted: false }, "onRuleRejected");
|
|
2187
2210
|
}
|
|
2188
2211
|
}
|
|
2189
2212
|
const sample = rejected.slice(0, 3).map((r) => `${r.id} (${r.reason})`).join("; ");
|
|
2190
|
-
options.onError
|
|
2213
|
+
notify(options.onError, new Error(
|
|
2191
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" : ""}`
|
|
2192
|
-
));
|
|
2215
|
+
), "onError");
|
|
2193
2216
|
}
|
|
2194
2217
|
async function resolveRules(options, store, ctx = {}) {
|
|
2195
2218
|
const timeoutMs = ctx.timeoutMs;
|
|
2196
2219
|
if (options.siteUuid) {
|
|
2197
2220
|
const prior = await store.read();
|
|
2198
|
-
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 });
|
|
2199
2222
|
const res = await client.getRules();
|
|
2200
2223
|
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
2201
2224
|
if (res.success && !res.notModified) {
|
|
@@ -2211,14 +2234,14 @@ async function resolveRules(options, store, ctx = {}) {
|
|
|
2211
2234
|
return bundle;
|
|
2212
2235
|
}
|
|
2213
2236
|
if (prior?.bundle) {
|
|
2214
|
-
options.onError
|
|
2237
|
+
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
|
|
2215
2238
|
return normalizeBundle(prior.bundle, options);
|
|
2216
2239
|
}
|
|
2217
2240
|
if (options.rules) {
|
|
2218
|
-
options.onError
|
|
2241
|
+
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`), "onError");
|
|
2219
2242
|
return normalizeBundle(options.rules, options);
|
|
2220
2243
|
}
|
|
2221
|
-
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");
|
|
2222
2245
|
return emptyBundle();
|
|
2223
2246
|
}
|
|
2224
2247
|
if (options.token) {
|
|
@@ -2239,10 +2262,10 @@ async function resolveRules(options, store, ctx = {}) {
|
|
|
2239
2262
|
return bundle;
|
|
2240
2263
|
}
|
|
2241
2264
|
if (prior?.bundle) {
|
|
2242
|
-
options.onError
|
|
2265
|
+
notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
|
|
2243
2266
|
return normalizeBundle(prior.bundle, options);
|
|
2244
2267
|
}
|
|
2245
|
-
options.onError
|
|
2268
|
+
notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
|
|
2246
2269
|
return emptyBundle();
|
|
2247
2270
|
}
|
|
2248
2271
|
if (options.rules) {
|
|
@@ -2263,10 +2286,7 @@ function normalizeBundle(b, options = {}) {
|
|
|
2263
2286
|
const report = options.onRuleRejected;
|
|
2264
2287
|
if (typeof report === "function") {
|
|
2265
2288
|
for (const r of rejected) {
|
|
2266
|
-
|
|
2267
|
-
report(r);
|
|
2268
|
-
} catch {
|
|
2269
|
-
}
|
|
2289
|
+
notify(report, r, "onRuleRejected");
|
|
2270
2290
|
}
|
|
2271
2291
|
} else {
|
|
2272
2292
|
const sample = rejected.slice(0, 3).map((r) => `${r.id} (${r.reason})`).join("; ");
|
|
@@ -2308,7 +2328,7 @@ function startRefresh(tick, { refreshMs, onError } = {}) {
|
|
|
2308
2328
|
failures = 0;
|
|
2309
2329
|
} catch (err) {
|
|
2310
2330
|
failures++;
|
|
2311
|
-
onError
|
|
2331
|
+
notify(onError, err, "onError");
|
|
2312
2332
|
}
|
|
2313
2333
|
schedule();
|
|
2314
2334
|
};
|
|
@@ -2340,10 +2360,124 @@ function makeRefreshHandler(tick, secret) {
|
|
|
2340
2360
|
};
|
|
2341
2361
|
}
|
|
2342
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
|
+
|
|
2343
2477
|
// src/protect/firewall-log.js
|
|
2344
2478
|
var DEFAULT_API_BASE = "https://api.patchstack.com";
|
|
2345
|
-
var
|
|
2346
|
-
var
|
|
2479
|
+
var DEFAULT_FLUSH_MS2 = 1e3;
|
|
2480
|
+
var MAX_BATCH2 = 50;
|
|
2347
2481
|
var TOKEN_SKEW_MS = 6e4;
|
|
2348
2482
|
function parseApiKey(apiKey) {
|
|
2349
2483
|
if (typeof apiKey !== "string" || apiKey.length === 0) return null;
|
|
@@ -2388,7 +2522,7 @@ function createFirewallLogReporter(opts) {
|
|
|
2388
2522
|
}
|
|
2389
2523
|
const apiBase = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
|
|
2390
2524
|
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
2391
|
-
const flushMs = Number.isFinite(opts.flushMs) ? opts.flushMs :
|
|
2525
|
+
const flushMs = Number.isFinite(opts.flushMs) ? opts.flushMs : DEFAULT_FLUSH_MS2;
|
|
2392
2526
|
const sourceHost = typeof opts.sourceHost === "string" ? opts.sourceHost : "";
|
|
2393
2527
|
let queue = [];
|
|
2394
2528
|
let timer = null;
|
|
@@ -2437,7 +2571,7 @@ function createFirewallLogReporter(opts) {
|
|
|
2437
2571
|
timer = null;
|
|
2438
2572
|
}
|
|
2439
2573
|
if (queue.length === 0 || typeof fetchImpl !== "function") return;
|
|
2440
|
-
const batch = queue.splice(0,
|
|
2574
|
+
const batch = queue.splice(0, MAX_BATCH2);
|
|
2441
2575
|
void (async () => {
|
|
2442
2576
|
const token = await fetchAccessToken();
|
|
2443
2577
|
if (!token) return;
|
|
@@ -2484,7 +2618,7 @@ function createFirewallLogReporter(opts) {
|
|
|
2484
2618
|
user_agent: event.userAgent ?? null,
|
|
2485
2619
|
log_date: (/* @__PURE__ */ new Date()).toISOString()
|
|
2486
2620
|
});
|
|
2487
|
-
if (queue.length >=
|
|
2621
|
+
if (queue.length >= MAX_BATCH2) {
|
|
2488
2622
|
flush();
|
|
2489
2623
|
return;
|
|
2490
2624
|
}
|
|
@@ -2583,8 +2717,10 @@ async function createProtection(options = {}) {
|
|
|
2583
2717
|
sourceHost: options.sourceHost,
|
|
2584
2718
|
fetchImpl: options.fetchImpl
|
|
2585
2719
|
}) : null;
|
|
2720
|
+
let detections = null;
|
|
2586
2721
|
const onDetect = (detection) => {
|
|
2587
|
-
userOnDetect
|
|
2722
|
+
notify(userOnDetect, detection, "onDetect");
|
|
2723
|
+
if (detections) detections.record(detection);
|
|
2588
2724
|
if (firewallLog && detection?.mode === "block") {
|
|
2589
2725
|
firewallLog.record({
|
|
2590
2726
|
rule: detection.rule,
|
|
@@ -2598,7 +2734,24 @@ async function createProtection(options = {}) {
|
|
|
2598
2734
|
const store = makeStore(options);
|
|
2599
2735
|
const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
|
|
2600
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
|
+
}
|
|
2601
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
|
+
}
|
|
2602
2755
|
let mode = resolveMode(options, bundle);
|
|
2603
2756
|
let requestRules;
|
|
2604
2757
|
let responseRules;
|
|
@@ -2641,12 +2794,7 @@ async function createProtection(options = {}) {
|
|
|
2641
2794
|
const recordSkip = (phase, reason, detail) => {
|
|
2642
2795
|
const key = `${phase}:${reason}`;
|
|
2643
2796
|
skipCounts[key] = (skipCounts[key] ?? 0) + 1;
|
|
2644
|
-
|
|
2645
|
-
try {
|
|
2646
|
-
onSkip({ phase, reason, detail, count: skipCounts[key] });
|
|
2647
|
-
} catch {
|
|
2648
|
-
}
|
|
2649
|
-
}
|
|
2797
|
+
notify(onSkip, { phase, reason, detail, count: skipCounts[key] }, "onSkip");
|
|
2650
2798
|
};
|
|
2651
2799
|
const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
|
|
2652
2800
|
const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
|
|
@@ -2682,7 +2830,7 @@ async function createProtection(options = {}) {
|
|
|
2682
2830
|
try {
|
|
2683
2831
|
result = re.evaluate({ ...reqCtx || {}, _response: { ...meta, body: text } });
|
|
2684
2832
|
} catch (err) {
|
|
2685
|
-
onError
|
|
2833
|
+
notify(onError, err, "onError");
|
|
2686
2834
|
continue;
|
|
2687
2835
|
}
|
|
2688
2836
|
if (!result.blocked) continue;
|
|
@@ -2804,7 +2952,7 @@ async function createProtection(options = {}) {
|
|
|
2804
2952
|
try {
|
|
2805
2953
|
r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} }, reqCtx);
|
|
2806
2954
|
} catch (err) {
|
|
2807
|
-
onError
|
|
2955
|
+
notify(onError, err, "onError");
|
|
2808
2956
|
for (const c of chunks) origWrite(c);
|
|
2809
2957
|
return origEnd(cb);
|
|
2810
2958
|
}
|
|
@@ -2856,7 +3004,7 @@ async function createProtection(options = {}) {
|
|
|
2856
3004
|
try {
|
|
2857
3005
|
result = egressEngine.evaluate({ _egress: { url, host, method } });
|
|
2858
3006
|
} catch (err) {
|
|
2859
|
-
onError
|
|
3007
|
+
notify(onError, err, "onError");
|
|
2860
3008
|
return false;
|
|
2861
3009
|
}
|
|
2862
3010
|
if (!result.blocked) return false;
|
|
@@ -2890,7 +3038,7 @@ async function createProtection(options = {}) {
|
|
|
2890
3038
|
try {
|
|
2891
3039
|
result = engine.evaluate(await fromFetchRequest(request));
|
|
2892
3040
|
} catch (err) {
|
|
2893
|
-
onError
|
|
3041
|
+
notify(onError, err, "onError");
|
|
2894
3042
|
return null;
|
|
2895
3043
|
}
|
|
2896
3044
|
return decide("request", result, () => blockResponse(result, request), () => null, fetchRequestMeta(request));
|
|
@@ -2914,7 +3062,7 @@ async function createProtection(options = {}) {
|
|
|
2914
3062
|
try {
|
|
2915
3063
|
result = engine.evaluate(req);
|
|
2916
3064
|
} catch (err) {
|
|
2917
|
-
onError
|
|
3065
|
+
notify(onError, err, "onError");
|
|
2918
3066
|
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
2919
3067
|
return next();
|
|
2920
3068
|
}
|
|
@@ -2953,7 +3101,7 @@ async function createProtection(options = {}) {
|
|
|
2953
3101
|
chunks.push(chunk);
|
|
2954
3102
|
});
|
|
2955
3103
|
req.on("error", (err) => {
|
|
2956
|
-
onError
|
|
3104
|
+
notify(onError, err, "onError");
|
|
2957
3105
|
next();
|
|
2958
3106
|
});
|
|
2959
3107
|
req.on("end", () => {
|
|
@@ -2965,7 +3113,7 @@ async function createProtection(options = {}) {
|
|
|
2965
3113
|
shaped = fromNodeRequest(req, rawBody);
|
|
2966
3114
|
result = engine.evaluate(shaped);
|
|
2967
3115
|
} catch (err) {
|
|
2968
|
-
onError
|
|
3116
|
+
notify(onError, err, "onError");
|
|
2969
3117
|
return next();
|
|
2970
3118
|
}
|
|
2971
3119
|
decide(
|
|
@@ -3010,9 +3158,9 @@ async function createProtection(options = {}) {
|
|
|
3010
3158
|
let reporter = null;
|
|
3011
3159
|
if (refreshable && options.siteUuid && options.reportManifest !== false && cwd) {
|
|
3012
3160
|
try {
|
|
3013
|
-
({ reportManifest: reporter } = await import("./refresh-manifest-
|
|
3161
|
+
({ reportManifest: reporter } = await import("./refresh-manifest-VRBE6RH6.js"));
|
|
3014
3162
|
} catch (err) {
|
|
3015
|
-
onError
|
|
3163
|
+
notify(onError, err, "onError");
|
|
3016
3164
|
}
|
|
3017
3165
|
}
|
|
3018
3166
|
const runRefreshTick = async () => {
|
|
@@ -3020,7 +3168,7 @@ async function createProtection(options = {}) {
|
|
|
3020
3168
|
try {
|
|
3021
3169
|
await reporter(cwd);
|
|
3022
3170
|
} catch (err) {
|
|
3023
|
-
onError
|
|
3171
|
+
notify(onError, err, "onError");
|
|
3024
3172
|
}
|
|
3025
3173
|
}
|
|
3026
3174
|
const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
|
|
@@ -3036,9 +3184,13 @@ async function createProtection(options = {}) {
|
|
|
3036
3184
|
protection.stopRefresh = () => {
|
|
3037
3185
|
loop.stop();
|
|
3038
3186
|
firewallLog?.stop();
|
|
3187
|
+
detections?.stop();
|
|
3039
3188
|
};
|
|
3040
3189
|
} else if (firewallLog) {
|
|
3041
|
-
protection.stopRefresh = () =>
|
|
3190
|
+
protection.stopRefresh = () => {
|
|
3191
|
+
firewallLog.stop();
|
|
3192
|
+
detections?.stop();
|
|
3193
|
+
};
|
|
3042
3194
|
}
|
|
3043
3195
|
return protection;
|
|
3044
3196
|
}
|