@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/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 warnOnce2(key, message) {
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
- warnOnce2(
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
- warnOnce2("whitelist-keys-unimplemented", "[patchstack] `whitelist_keys` is not implemented and has no effect.");
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
- constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth } = {}) {
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 headers = {
1258
- Accept: "application/json",
1259
- ...await pulseAuthHeader(
1260
- { pulseAuth: this.#pulseAuth, endpoint: this.#baseUrl, timeoutMs: this.#timeoutMs },
1261
- fetch
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?.({ url, host, method });
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
- try {
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?.(new Error(
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?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
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?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`));
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?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
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?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
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?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
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
- try {
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?.(err);
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 DEFAULT_FLUSH_MS = 1e3;
2314
- var MAX_BATCH = 50;
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 : DEFAULT_FLUSH_MS;
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, MAX_BATCH);
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 >= MAX_BATCH) {
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(detection);
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
- if (onSkip) {
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?.(err);
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?.(err);
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?.(err);
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?.(err);
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?.(err);
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?.(err);
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?.(err);
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-KZSQD2X3.js"));
3161
+ ({ reportManifest: reporter } = await import("./refresh-manifest-VRBE6RH6.js"));
2982
3162
  } catch (err) {
2983
- onError?.(err);
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?.(err);
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 = () => firewallLog.stop();
3190
+ protection.stopRefresh = () => {
3191
+ firewallLog.stop();
3192
+ detections?.stop();
3193
+ };
3010
3194
  }
3011
3195
  return protection;
3012
3196
  }