@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/AGENT-INSTALL.md +86 -7
- package/dist/cli.js +220 -74
- 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 +262 -60
- package/dist/protect.cjs.map +1 -1
- package/dist/protect.d.ts +17 -0
- package/dist/protect.edge.js +244 -60
- package/dist/protect.edge.js.map +4 -4
- package/dist/protect.js +245 -61
- package/dist/protect.js.map +1 -1
- package/dist/{refresh-manifest-KZSQD2X3.js → refresh-manifest-VRBE6RH6.js} +18 -1
- package/dist/refresh-manifest-VRBE6RH6.js.map +1 -0
- package/package.json +1 -1
- package/dist/refresh-manifest-KZSQD2X3.js.map +0 -1
package/dist/protect.d.ts
CHANGED
|
@@ -68,6 +68,23 @@ export interface CreateProtectionOptions {
|
|
|
68
68
|
* Also disabled when `PATCHSTACK_TELEMETRY=off` or when no apiKey is available.
|
|
69
69
|
*/
|
|
70
70
|
reportFirewallLog?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Report EVERY rule that fired — including one in `dry-run` that did not block — to the Pulse
|
|
73
|
+
* detections endpoint. Off unless explicitly `true`.
|
|
74
|
+
*
|
|
75
|
+
* Why it exists: a rule that blocks nothing reports nothing, so a rule that is quietly wrong and a
|
|
76
|
+
* rule that is protecting look identical from the outside.
|
|
77
|
+
*
|
|
78
|
+
* What it sends, per detection: the rule id, the request PATH with the query string removed, the
|
|
79
|
+
* parameters the rule reads, the phase, whether it was enforced, the rule-bundle ETag, and a
|
|
80
|
+
* timestamp. It does NOT send the matched value, the request body, headers, or query-string values —
|
|
81
|
+
* this is a counting channel, not a copy of your traffic.
|
|
82
|
+
*
|
|
83
|
+
* Off by default because switching it on adds an outbound request to every guard with a site UUID.
|
|
84
|
+
*/
|
|
85
|
+
reportDetections?: boolean;
|
|
86
|
+
/** How long to buffer detections before posting a batch. Default 5000ms. */
|
|
87
|
+
detectionFlushMs?: number;
|
|
71
88
|
/** Optional Source-Host header for connector hostname checks. */
|
|
72
89
|
sourceHost?: string;
|
|
73
90
|
/** Optional fetch override (tests). */
|
package/dist/protect.edge.js
CHANGED
|
@@ -512,6 +512,35 @@ var RequestResolver = class {
|
|
|
512
512
|
}
|
|
513
513
|
};
|
|
514
514
|
|
|
515
|
+
// src/protect/notify.js
|
|
516
|
+
var reported = /* @__PURE__ */ new Set();
|
|
517
|
+
function notify(fn, arg, label) {
|
|
518
|
+
if (typeof fn !== "function") return false;
|
|
519
|
+
try {
|
|
520
|
+
const result = fn(arg);
|
|
521
|
+
if (result !== null && typeof result === "object" && typeof result.then === "function") {
|
|
522
|
+
try {
|
|
523
|
+
result.then(void 0, (err) => warnOnce2(label, err));
|
|
524
|
+
} catch {
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return true;
|
|
528
|
+
} catch (err) {
|
|
529
|
+
warnOnce2(label, err);
|
|
530
|
+
return false;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
function warnOnce2(label, err) {
|
|
534
|
+
if (reported.has(label)) return;
|
|
535
|
+
reported.add(label);
|
|
536
|
+
try {
|
|
537
|
+
console.warn(
|
|
538
|
+
`Patchstack: the ${label} callback passed to createProtection failed and was ignored. Protection is unaffected; this is reported once per process. Cause: ${err && err.message ? err.message : String(err)}`
|
|
539
|
+
);
|
|
540
|
+
} catch {
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
515
544
|
// src/protect/engine/normalizer.js
|
|
516
545
|
var HTML_ENTITIES = {
|
|
517
546
|
"&": "&",
|
|
@@ -796,7 +825,7 @@ function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
|
|
|
796
825
|
return out;
|
|
797
826
|
}
|
|
798
827
|
var warnedKeys = /* @__PURE__ */ new Set();
|
|
799
|
-
function
|
|
828
|
+
function warnOnce3(key, message) {
|
|
800
829
|
if (warnedKeys.has(key)) return;
|
|
801
830
|
warnedKeys.add(key);
|
|
802
831
|
console.warn(message);
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
2406
|
-
var
|
|
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 :
|
|
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,
|
|
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 >=
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 = () =>
|
|
3282
|
+
protection.stopRefresh = () => {
|
|
3283
|
+
firewallLog.stop();
|
|
3284
|
+
detections?.stop();
|
|
3285
|
+
};
|
|
3102
3286
|
}
|
|
3103
3287
|
return protection;
|
|
3104
3288
|
}
|