@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/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). */
@@ -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 warnOnce2(key, message) {
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
- warnOnce2(
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
- warnOnce2("whitelist-keys-unimplemented", "[patchstack] `whitelist_keys` is not implemented and has no effect.");
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
- constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth } = {}) {
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 headers = {
1382
- Accept: "application/json",
1383
- ...await pulseAuthHeader(
1384
- { pulseAuth: this.#pulseAuth, endpoint: this.#baseUrl, timeoutMs: this.#timeoutMs },
1385
- fetch
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?.({ url, host, method });
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
- try {
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?.(new Error(
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?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
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?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`));
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?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
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?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
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?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
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
- try {
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?.(err);
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 DEFAULT_FLUSH_MS = 1e3;
2438
- var MAX_BATCH = 50;
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 : DEFAULT_FLUSH_MS;
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, MAX_BATCH);
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 >= MAX_BATCH) {
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(detection);
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
- if (onSkip) {
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?.(err);
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?.(err);
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?.(err);
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?.(err);
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?.(err);
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?.(err);
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?.(err);
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?.(err);
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?.(err);
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 = () => firewallLog.stop();
3282
+ protection.stopRefresh = () => {
3283
+ firewallLog.stop();
3284
+ detections?.stop();
3285
+ };
3134
3286
  }
3135
3287
  return protection;
3136
3288
  }