@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.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);
@@ -811,6 +840,26 @@ function warnUnsupportedMatchType(type) {
811
840
  `[patchstack] Unsupported rule_v2 match type "${type}" \u2014 condition treated as no-match. Rules relying on it are not enforced by this engine.`
812
841
  );
813
842
  }
843
+ function hostFromValue(value) {
844
+ const raw = String(value ?? "").trim();
845
+ if (raw === "") return "";
846
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || raw.startsWith("//")) {
847
+ try {
848
+ return new URL(raw.startsWith("//") ? `http:${raw}` : raw).hostname;
849
+ } catch {
850
+ return raw;
851
+ }
852
+ }
853
+ if (raw.startsWith("[")) {
854
+ const end = raw.indexOf("]");
855
+ if (end > 0) return raw.slice(1, end);
856
+ }
857
+ const colon = raw.indexOf(":");
858
+ if (colon > 0 && raw.indexOf(":", colon + 1) === -1 && /^\d+$/.test(raw.slice(colon + 1))) {
859
+ return raw.slice(0, colon);
860
+ }
861
+ return raw;
862
+ }
814
863
  function isInternalHost(hostname) {
815
864
  if (!hostname) return false;
816
865
  let host = String(hostname).toLowerCase().replace(/^\[|\]$/g, "");
@@ -900,8 +949,20 @@ function expandIPv6(host) {
900
949
  }
901
950
  return out;
902
951
  }
952
+ var warnedScopes = /* @__PURE__ */ new Set();
953
+ function warnUnrecognisedScope(when) {
954
+ const key = Object.keys(when).sort().join(",");
955
+ if (warnedScopes.has(key)) return;
956
+ warnedScopes.add(key);
957
+ console.warn(
958
+ `[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.`
959
+ );
960
+ }
903
961
  function ruleAppliesTo(when, resolver) {
904
962
  try {
963
+ if (when.method === void 0 && when.path === void 0 && Object.keys(when).length > 0) {
964
+ warnUnrecognisedScope(when);
965
+ }
905
966
  if (when.method) {
906
967
  const methods = (Array.isArray(when.method) ? when.method : [when.method]).map((m) => String(m).toUpperCase());
907
968
  const actual = String(resolver.resolve("server.REQUEST_METHOD")[0] ?? "GET").toUpperCase();
@@ -1067,7 +1128,7 @@ function matchValue(type, value, matchVal, matchObj) {
1067
1128
  }
1068
1129
  }
1069
1130
  case "internal_host":
1070
- return isInternalHost(strValue);
1131
+ return isInternalHost(hostFromValue(strValue));
1071
1132
  case "quotes":
1072
1133
  // engine-php exposes `inline_js_xss` as an alias of `quotes`.
1073
1134
  case "inline_js_xss":
@@ -1106,21 +1167,20 @@ var RuleEngine = class {
1106
1167
  this.#whitelistKeys = whitelist_keys;
1107
1168
  this.#onError = onError;
1108
1169
  if (Array.isArray(whitelists) && whitelists.some((w) => w && Array.isArray(w.rule_v2) && !w.rule_id)) {
1109
- warnOnce2(
1170
+ warnOnce3(
1110
1171
  "whitelist-no-rule-id",
1111
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."
1112
1173
  );
1113
1174
  }
1114
1175
  if (whitelist_keys && typeof whitelist_keys === "object" && Object.keys(whitelist_keys).length > 0) {
1115
- 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.");
1116
1177
  }
1117
1178
  }
1118
1179
  // A mitigation engine must never take down the app it protects: any error while
1119
1180
  // evaluating a rule is reported and the request is allowed through (fail open). A
1120
1181
  // malformed rule is skipped without aborting the rest of the ruleset.
1121
1182
  #reportError(err) {
1122
- if (this.#onError) {
1123
- this.#onError(err);
1183
+ if (notify(this.#onError, err, "onError")) {
1124
1184
  return;
1125
1185
  }
1126
1186
  const key = err && err.message ? err.message : String(err);
@@ -1328,13 +1388,15 @@ var PulseRuleClient = class {
1328
1388
  #ttlEffective = 0;
1329
1389
  #etag;
1330
1390
  #pulseAuth;
1331
- constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth } = {}) {
1391
+ #reportsDetections;
1392
+ constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, reportsDetections } = {}) {
1332
1393
  this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
1333
1394
  this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
1334
1395
  this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL, DEFAULT_BASE_URL2, "rule endpoint");
1335
1396
  this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL2;
1336
1397
  this.#etag = etag ?? null;
1337
1398
  this.#pulseAuth = pulseAuth ?? null;
1399
+ this.#reportsDetections = reportsDetections === true;
1338
1400
  if (!this.#siteUuid) {
1339
1401
  throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
1340
1402
  }
@@ -1346,13 +1408,14 @@ var PulseRuleClient = class {
1346
1408
  }
1347
1409
  const url = `${this.#baseUrl}/rules/${encodeURIComponent(this.#siteUuid)}`;
1348
1410
  try {
1349
- const headers = {
1350
- Accept: "application/json",
1351
- ...await pulseAuthHeader(
1352
- { pulseAuth: this.#pulseAuth, endpoint: this.#baseUrl, timeoutMs: this.#timeoutMs },
1353
- fetch
1354
- )
1355
- };
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
+ }
1356
1419
  if (this.#etag) headers["If-None-Match"] = this.#etag;
1357
1420
  const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
1358
1421
  if (response.status === 304) {
@@ -1634,7 +1697,7 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
1634
1697
  const exempt = new Set((allowHosts ?? []).map((h) => String(h).toLowerCase()));
1635
1698
  const block = (url, host, method) => {
1636
1699
  if (!shouldBlock(url, host, method)) return false;
1637
- onBlock?.({ url, host, method });
1700
+ notify(onBlock, { url, host, method }, "onEgressBlock");
1638
1701
  return true;
1639
1702
  };
1640
1703
  let screen = null;
@@ -1649,12 +1712,7 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
1649
1712
  screen = null;
1650
1713
  }
1651
1714
  }
1652
- const skip = (reason, detail) => {
1653
- try {
1654
- onSkip?.({ phase: "egress", reason, detail });
1655
- } catch {
1656
- }
1657
- };
1715
+ const skip = (reason, detail) => notify(onSkip, { phase: "egress", reason, detail }, "onSkip");
1658
1716
  const resolvesToDisallowed = (url, host, method) => new Promise((resolve) => {
1659
1717
  if (!screen) {
1660
1718
  if (host && dnsScreen) skip("resolver-unavailable", { host });
@@ -2240,22 +2298,19 @@ function reportRejections(rejected, options, label) {
2240
2298
  const report = options.onRuleRejected;
2241
2299
  for (const r of rejected) {
2242
2300
  if (typeof report === "function") {
2243
- try {
2244
- report({ ...r, accepted: false });
2245
- } catch {
2246
- }
2301
+ notify(report, { ...r, accepted: false }, "onRuleRejected");
2247
2302
  }
2248
2303
  }
2249
2304
  const sample = rejected.slice(0, 3).map((r) => `${r.id} (${r.reason})`).join("; ");
2250
- options.onError?.(new Error(
2305
+ notify(options.onError, new Error(
2251
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" : ""}`
2252
- ));
2307
+ ), "onError");
2253
2308
  }
2254
2309
  async function resolveRules(options, store, ctx = {}) {
2255
2310
  const timeoutMs = ctx.timeoutMs;
2256
2311
  if (options.siteUuid) {
2257
2312
  const prior = await store.read();
2258
- 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 });
2259
2314
  const res = await client.getRules();
2260
2315
  if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
2261
2316
  if (res.success && !res.notModified) {
@@ -2271,14 +2326,14 @@ async function resolveRules(options, store, ctx = {}) {
2271
2326
  return bundle;
2272
2327
  }
2273
2328
  if (prior?.bundle) {
2274
- 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");
2275
2330
  return normalizeBundle(prior.bundle, options);
2276
2331
  }
2277
2332
  if (options.rules) {
2278
- 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");
2279
2334
  return normalizeBundle(options.rules, options);
2280
2335
  }
2281
- 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");
2282
2337
  return emptyBundle();
2283
2338
  }
2284
2339
  if (options.token) {
@@ -2299,10 +2354,10 @@ async function resolveRules(options, store, ctx = {}) {
2299
2354
  return bundle;
2300
2355
  }
2301
2356
  if (prior?.bundle) {
2302
- 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");
2303
2358
  return normalizeBundle(prior.bundle, options);
2304
2359
  }
2305
- 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");
2306
2361
  return emptyBundle();
2307
2362
  }
2308
2363
  if (options.rules) {
@@ -2323,10 +2378,7 @@ function normalizeBundle(b, options = {}) {
2323
2378
  const report = options.onRuleRejected;
2324
2379
  if (typeof report === "function") {
2325
2380
  for (const r of rejected) {
2326
- try {
2327
- report(r);
2328
- } catch {
2329
- }
2381
+ notify(report, r, "onRuleRejected");
2330
2382
  }
2331
2383
  } else {
2332
2384
  const sample = rejected.slice(0, 3).map((r) => `${r.id} (${r.reason})`).join("; ");
@@ -2368,7 +2420,7 @@ function startRefresh(tick, { refreshMs, onError } = {}) {
2368
2420
  failures = 0;
2369
2421
  } catch (err) {
2370
2422
  failures++;
2371
- onError?.(err);
2423
+ notify(onError, err, "onError");
2372
2424
  }
2373
2425
  schedule();
2374
2426
  };
@@ -2400,10 +2452,124 @@ function makeRefreshHandler(tick, secret) {
2400
2452
  };
2401
2453
  }
2402
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
+
2403
2569
  // src/protect/firewall-log.js
2404
2570
  var DEFAULT_API_BASE = "https://api.patchstack.com";
2405
- var DEFAULT_FLUSH_MS = 1e3;
2406
- var MAX_BATCH = 50;
2571
+ var DEFAULT_FLUSH_MS2 = 1e3;
2572
+ var MAX_BATCH2 = 50;
2407
2573
  var TOKEN_SKEW_MS2 = 6e4;
2408
2574
  function parseApiKey(apiKey) {
2409
2575
  if (typeof apiKey !== "string" || apiKey.length === 0) return null;
@@ -2448,7 +2614,7 @@ function createFirewallLogReporter(opts) {
2448
2614
  }
2449
2615
  const apiBase = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
2450
2616
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
2451
- const flushMs = Number.isFinite(opts.flushMs) ? opts.flushMs : DEFAULT_FLUSH_MS;
2617
+ const flushMs = Number.isFinite(opts.flushMs) ? opts.flushMs : DEFAULT_FLUSH_MS2;
2452
2618
  const sourceHost = typeof opts.sourceHost === "string" ? opts.sourceHost : "";
2453
2619
  let queue = [];
2454
2620
  let timer = null;
@@ -2497,7 +2663,7 @@ function createFirewallLogReporter(opts) {
2497
2663
  timer = null;
2498
2664
  }
2499
2665
  if (queue.length === 0 || typeof fetchImpl !== "function") return;
2500
- const batch = queue.splice(0, MAX_BATCH);
2666
+ const batch = queue.splice(0, MAX_BATCH2);
2501
2667
  void (async () => {
2502
2668
  const token = await fetchAccessToken();
2503
2669
  if (!token) return;
@@ -2544,7 +2710,7 @@ function createFirewallLogReporter(opts) {
2544
2710
  user_agent: event.userAgent ?? null,
2545
2711
  log_date: (/* @__PURE__ */ new Date()).toISOString()
2546
2712
  });
2547
- if (queue.length >= MAX_BATCH) {
2713
+ if (queue.length >= MAX_BATCH2) {
2548
2714
  flush();
2549
2715
  return;
2550
2716
  }
@@ -2643,8 +2809,10 @@ async function createProtection(options = {}) {
2643
2809
  sourceHost: options.sourceHost,
2644
2810
  fetchImpl: options.fetchImpl
2645
2811
  }) : null;
2812
+ let detections = null;
2646
2813
  const onDetect = (detection) => {
2647
- userOnDetect(detection);
2814
+ notify(userOnDetect, detection, "onDetect");
2815
+ if (detections) detections.record(detection);
2648
2816
  if (firewallLog && detection?.mode === "block") {
2649
2817
  firewallLog.record({
2650
2818
  rule: detection.rule,
@@ -2658,7 +2826,24 @@ async function createProtection(options = {}) {
2658
2826
  const store = makeStore(options);
2659
2827
  const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
2660
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
+ }
2661
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
+ }
2662
2847
  let mode = resolveMode(options, bundle);
2663
2848
  let requestRules;
2664
2849
  let responseRules;
@@ -2701,12 +2886,7 @@ async function createProtection(options = {}) {
2701
2886
  const recordSkip = (phase, reason, detail) => {
2702
2887
  const key = `${phase}:${reason}`;
2703
2888
  skipCounts[key] = (skipCounts[key] ?? 0) + 1;
2704
- if (onSkip) {
2705
- try {
2706
- onSkip({ phase, reason, detail, count: skipCounts[key] });
2707
- } catch {
2708
- }
2709
- }
2889
+ notify(onSkip, { phase, reason, detail, count: skipCounts[key] }, "onSkip");
2710
2890
  };
2711
2891
  const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
2712
2892
  const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
@@ -2742,7 +2922,7 @@ async function createProtection(options = {}) {
2742
2922
  try {
2743
2923
  result = re.evaluate({ ...reqCtx || {}, _response: { ...meta, body: text } });
2744
2924
  } catch (err) {
2745
- onError?.(err);
2925
+ notify(onError, err, "onError");
2746
2926
  continue;
2747
2927
  }
2748
2928
  if (!result.blocked) continue;
@@ -2864,7 +3044,7 @@ async function createProtection(options = {}) {
2864
3044
  try {
2865
3045
  r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} }, reqCtx);
2866
3046
  } catch (err) {
2867
- onError?.(err);
3047
+ notify(onError, err, "onError");
2868
3048
  for (const c of chunks) origWrite(c);
2869
3049
  return origEnd(cb);
2870
3050
  }
@@ -2916,7 +3096,7 @@ async function createProtection(options = {}) {
2916
3096
  try {
2917
3097
  result = egressEngine.evaluate({ _egress: { url, host, method } });
2918
3098
  } catch (err) {
2919
- onError?.(err);
3099
+ notify(onError, err, "onError");
2920
3100
  return false;
2921
3101
  }
2922
3102
  if (!result.blocked) return false;
@@ -2950,7 +3130,7 @@ async function createProtection(options = {}) {
2950
3130
  try {
2951
3131
  result = engine.evaluate(await fromFetchRequest(request));
2952
3132
  } catch (err) {
2953
- onError?.(err);
3133
+ notify(onError, err, "onError");
2954
3134
  return null;
2955
3135
  }
2956
3136
  return decide("request", result, () => blockResponse(result, request), () => null, fetchRequestMeta(request));
@@ -2974,7 +3154,7 @@ async function createProtection(options = {}) {
2974
3154
  try {
2975
3155
  result = engine.evaluate(req);
2976
3156
  } catch (err) {
2977
- onError?.(err);
3157
+ notify(onError, err, "onError");
2978
3158
  if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
2979
3159
  return next();
2980
3160
  }
@@ -3013,7 +3193,7 @@ async function createProtection(options = {}) {
3013
3193
  chunks.push(chunk);
3014
3194
  });
3015
3195
  req.on("error", (err) => {
3016
- onError?.(err);
3196
+ notify(onError, err, "onError");
3017
3197
  next();
3018
3198
  });
3019
3199
  req.on("end", () => {
@@ -3025,7 +3205,7 @@ async function createProtection(options = {}) {
3025
3205
  shaped = fromNodeRequest(req, rawBody);
3026
3206
  result = engine.evaluate(shaped);
3027
3207
  } catch (err) {
3028
- onError?.(err);
3208
+ notify(onError, err, "onError");
3029
3209
  return next();
3030
3210
  }
3031
3211
  decide(
@@ -3072,7 +3252,7 @@ async function createProtection(options = {}) {
3072
3252
  try {
3073
3253
  ({ reportManifest: reporter } = await Promise.resolve().then(() => __toESM(require_ps_edge_stub(), 1)));
3074
3254
  } catch (err) {
3075
- onError?.(err);
3255
+ notify(onError, err, "onError");
3076
3256
  }
3077
3257
  }
3078
3258
  const runRefreshTick = async () => {
@@ -3080,7 +3260,7 @@ async function createProtection(options = {}) {
3080
3260
  try {
3081
3261
  await reporter(cwd);
3082
3262
  } catch (err) {
3083
- onError?.(err);
3263
+ notify(onError, err, "onError");
3084
3264
  }
3085
3265
  }
3086
3266
  const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
@@ -3096,9 +3276,13 @@ async function createProtection(options = {}) {
3096
3276
  protection.stopRefresh = () => {
3097
3277
  loop.stop();
3098
3278
  firewallLog?.stop();
3279
+ detections?.stop();
3099
3280
  };
3100
3281
  } else if (firewallLog) {
3101
- protection.stopRefresh = () => firewallLog.stop();
3282
+ protection.stopRefresh = () => {
3283
+ firewallLog.stop();
3284
+ detections?.stop();
3285
+ };
3102
3286
  }
3103
3287
  return protection;
3104
3288
  }