@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.cjs
CHANGED
|
@@ -141,6 +141,19 @@ function buildEndpointUrl(base, siteUuid) {
|
|
|
141
141
|
const trimmed = base.replace(/\/$/, "");
|
|
142
142
|
return siteUuid !== void 0 && siteUuid !== null && siteUuid.length > 0 ? `${trimmed}/${encodeURIComponent(siteUuid)}` : trimmed;
|
|
143
143
|
}
|
|
144
|
+
function authFailureMessage(status, config) {
|
|
145
|
+
const hasCredential = typeof config.pulseAuth === "string" && config.pulseAuth.length > 0;
|
|
146
|
+
if (status === 401 && !hasCredential) {
|
|
147
|
+
return "Patchstack requires an API credential for this site and none is configured. Run `npx patchstack-connect login`, or set PATCHSTACK_API_KEY.";
|
|
148
|
+
}
|
|
149
|
+
if (status === 401) {
|
|
150
|
+
return "Patchstack rejected this API credential. It may have expired, been revoked, or the site may no longer exist. Run `npx patchstack-connect login` to issue a new one.";
|
|
151
|
+
}
|
|
152
|
+
if (status === 403) {
|
|
153
|
+
return "This API credential is not permitted to act on this site. Check that siteUuid in .patchstackrc.json matches the credential (a credential is issued for one site).";
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
144
157
|
async function postManifest(config, payload) {
|
|
145
158
|
const url = buildEndpointUrl(config.endpoint, config.siteUuid);
|
|
146
159
|
const timeoutMs = config.timeoutMs;
|
|
@@ -189,6 +202,10 @@ async function postManifest(config, payload) {
|
|
|
189
202
|
"VALIDATION_ERROR"
|
|
190
203
|
);
|
|
191
204
|
}
|
|
205
|
+
const refused = authFailureMessage(response.status, config);
|
|
206
|
+
if (refused !== null) {
|
|
207
|
+
throw new PatchstackError(refused, "UNAUTHORIZED");
|
|
208
|
+
}
|
|
192
209
|
if (response.status < 200 || response.status >= 300) {
|
|
193
210
|
throw new PatchstackError(
|
|
194
211
|
`Patchstack returned ${response.status}: ${text.slice(0, 200)}`,
|
|
@@ -1785,6 +1802,35 @@ var RequestResolver = class {
|
|
|
1785
1802
|
}
|
|
1786
1803
|
};
|
|
1787
1804
|
|
|
1805
|
+
// src/protect/notify.js
|
|
1806
|
+
var reported = /* @__PURE__ */ new Set();
|
|
1807
|
+
function notify(fn, arg, label) {
|
|
1808
|
+
if (typeof fn !== "function") return false;
|
|
1809
|
+
try {
|
|
1810
|
+
const result = fn(arg);
|
|
1811
|
+
if (result !== null && typeof result === "object" && typeof result.then === "function") {
|
|
1812
|
+
try {
|
|
1813
|
+
result.then(void 0, (err) => warnOnce2(label, err));
|
|
1814
|
+
} catch {
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
return true;
|
|
1818
|
+
} catch (err) {
|
|
1819
|
+
warnOnce2(label, err);
|
|
1820
|
+
return false;
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
function warnOnce2(label, err) {
|
|
1824
|
+
if (reported.has(label)) return;
|
|
1825
|
+
reported.add(label);
|
|
1826
|
+
try {
|
|
1827
|
+
console.warn(
|
|
1828
|
+
`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)}`
|
|
1829
|
+
);
|
|
1830
|
+
} catch {
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1788
1834
|
// src/protect/engine/normalizer.js
|
|
1789
1835
|
var HTML_ENTITIES = {
|
|
1790
1836
|
"&": "&",
|
|
@@ -2069,7 +2115,7 @@ function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
|
|
|
2069
2115
|
return out;
|
|
2070
2116
|
}
|
|
2071
2117
|
var warnedKeys = /* @__PURE__ */ new Set();
|
|
2072
|
-
function
|
|
2118
|
+
function warnOnce3(key, message) {
|
|
2073
2119
|
if (warnedKeys.has(key)) return;
|
|
2074
2120
|
warnedKeys.add(key);
|
|
2075
2121
|
console.warn(message);
|
|
@@ -2084,6 +2130,26 @@ function warnUnsupportedMatchType(type) {
|
|
|
2084
2130
|
`[patchstack] Unsupported rule_v2 match type "${type}" \u2014 condition treated as no-match. Rules relying on it are not enforced by this engine.`
|
|
2085
2131
|
);
|
|
2086
2132
|
}
|
|
2133
|
+
function hostFromValue(value) {
|
|
2134
|
+
const raw = String(value ?? "").trim();
|
|
2135
|
+
if (raw === "") return "";
|
|
2136
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || raw.startsWith("//")) {
|
|
2137
|
+
try {
|
|
2138
|
+
return new URL(raw.startsWith("//") ? `http:${raw}` : raw).hostname;
|
|
2139
|
+
} catch {
|
|
2140
|
+
return raw;
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
if (raw.startsWith("[")) {
|
|
2144
|
+
const end = raw.indexOf("]");
|
|
2145
|
+
if (end > 0) return raw.slice(1, end);
|
|
2146
|
+
}
|
|
2147
|
+
const colon = raw.indexOf(":");
|
|
2148
|
+
if (colon > 0 && raw.indexOf(":", colon + 1) === -1 && /^\d+$/.test(raw.slice(colon + 1))) {
|
|
2149
|
+
return raw.slice(0, colon);
|
|
2150
|
+
}
|
|
2151
|
+
return raw;
|
|
2152
|
+
}
|
|
2087
2153
|
function isInternalHost(hostname) {
|
|
2088
2154
|
if (!hostname) return false;
|
|
2089
2155
|
let host = String(hostname).toLowerCase().replace(/^\[|\]$/g, "");
|
|
@@ -2173,8 +2239,20 @@ function expandIPv6(host) {
|
|
|
2173
2239
|
}
|
|
2174
2240
|
return out;
|
|
2175
2241
|
}
|
|
2242
|
+
var warnedScopes = /* @__PURE__ */ new Set();
|
|
2243
|
+
function warnUnrecognisedScope(when) {
|
|
2244
|
+
const key = Object.keys(when).sort().join(",");
|
|
2245
|
+
if (warnedScopes.has(key)) return;
|
|
2246
|
+
warnedScopes.add(key);
|
|
2247
|
+
console.warn(
|
|
2248
|
+
`[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.`
|
|
2249
|
+
);
|
|
2250
|
+
}
|
|
2176
2251
|
function ruleAppliesTo(when, resolver) {
|
|
2177
2252
|
try {
|
|
2253
|
+
if (when.method === void 0 && when.path === void 0 && Object.keys(when).length > 0) {
|
|
2254
|
+
warnUnrecognisedScope(when);
|
|
2255
|
+
}
|
|
2178
2256
|
if (when.method) {
|
|
2179
2257
|
const methods = (Array.isArray(when.method) ? when.method : [when.method]).map((m) => String(m).toUpperCase());
|
|
2180
2258
|
const actual = String(resolver.resolve("server.REQUEST_METHOD")[0] ?? "GET").toUpperCase();
|
|
@@ -2340,7 +2418,7 @@ function matchValue(type, value, matchVal, matchObj) {
|
|
|
2340
2418
|
}
|
|
2341
2419
|
}
|
|
2342
2420
|
case "internal_host":
|
|
2343
|
-
return isInternalHost(strValue);
|
|
2421
|
+
return isInternalHost(hostFromValue(strValue));
|
|
2344
2422
|
case "quotes":
|
|
2345
2423
|
// engine-php exposes `inline_js_xss` as an alias of `quotes`.
|
|
2346
2424
|
case "inline_js_xss":
|
|
@@ -2379,21 +2457,20 @@ var RuleEngine = class {
|
|
|
2379
2457
|
this.#whitelistKeys = whitelist_keys;
|
|
2380
2458
|
this.#onError = onError;
|
|
2381
2459
|
if (Array.isArray(whitelists) && whitelists.some((w) => w && Array.isArray(w.rule_v2) && !w.rule_id)) {
|
|
2382
|
-
|
|
2460
|
+
warnOnce3(
|
|
2383
2461
|
"whitelist-no-rule-id",
|
|
2384
2462
|
"[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."
|
|
2385
2463
|
);
|
|
2386
2464
|
}
|
|
2387
2465
|
if (whitelist_keys && typeof whitelist_keys === "object" && Object.keys(whitelist_keys).length > 0) {
|
|
2388
|
-
|
|
2466
|
+
warnOnce3("whitelist-keys-unimplemented", "[patchstack] `whitelist_keys` is not implemented and has no effect.");
|
|
2389
2467
|
}
|
|
2390
2468
|
}
|
|
2391
2469
|
// A mitigation engine must never take down the app it protects: any error while
|
|
2392
2470
|
// evaluating a rule is reported and the request is allowed through (fail open). A
|
|
2393
2471
|
// malformed rule is skipped without aborting the rest of the ruleset.
|
|
2394
2472
|
#reportError(err) {
|
|
2395
|
-
if (this.#onError) {
|
|
2396
|
-
this.#onError(err);
|
|
2473
|
+
if (notify(this.#onError, err, "onError")) {
|
|
2397
2474
|
return;
|
|
2398
2475
|
}
|
|
2399
2476
|
const key = err && err.message ? err.message : String(err);
|
|
@@ -2539,13 +2616,15 @@ var PulseRuleClient = class {
|
|
|
2539
2616
|
#ttlEffective = 0;
|
|
2540
2617
|
#etag;
|
|
2541
2618
|
#pulseAuth;
|
|
2542
|
-
|
|
2619
|
+
#reportsDetections;
|
|
2620
|
+
constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, reportsDetections } = {}) {
|
|
2543
2621
|
this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
|
|
2544
2622
|
this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
|
|
2545
2623
|
this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL, DEFAULT_BASE_URL2, "rule endpoint");
|
|
2546
2624
|
this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL2;
|
|
2547
2625
|
this.#etag = etag ?? null;
|
|
2548
2626
|
this.#pulseAuth = pulseAuth ?? null;
|
|
2627
|
+
this.#reportsDetections = reportsDetections === true;
|
|
2549
2628
|
if (!this.#siteUuid) {
|
|
2550
2629
|
throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
|
|
2551
2630
|
}
|
|
@@ -2557,13 +2636,14 @@ var PulseRuleClient = class {
|
|
|
2557
2636
|
}
|
|
2558
2637
|
const url = `${this.#baseUrl}/rules/${encodeURIComponent(this.#siteUuid)}`;
|
|
2559
2638
|
try {
|
|
2560
|
-
const
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2639
|
+
const auth = await pulseAuthHeader(
|
|
2640
|
+
{ pulseAuth: this.#pulseAuth, endpoint: this.#baseUrl, timeoutMs: this.#timeoutMs },
|
|
2641
|
+
fetch
|
|
2642
|
+
);
|
|
2643
|
+
const headers = { Accept: "application/json", ...auth };
|
|
2644
|
+
if (this.#reportsDetections && typeof auth.Authorization === "string") {
|
|
2645
|
+
headers["X-Patchstack-Detections"] = "enabled";
|
|
2646
|
+
}
|
|
2567
2647
|
if (this.#etag) headers["If-None-Match"] = this.#etag;
|
|
2568
2648
|
const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
|
|
2569
2649
|
if (response.status === 304) {
|
|
@@ -2845,7 +2925,7 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
2845
2925
|
const exempt = new Set((allowHosts ?? []).map((h) => String(h).toLowerCase()));
|
|
2846
2926
|
const block = (url, host, method) => {
|
|
2847
2927
|
if (!shouldBlock(url, host, method)) return false;
|
|
2848
|
-
onBlock
|
|
2928
|
+
notify(onBlock, { url, host, method }, "onEgressBlock");
|
|
2849
2929
|
return true;
|
|
2850
2930
|
};
|
|
2851
2931
|
let screen = null;
|
|
@@ -2860,12 +2940,7 @@ async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = tr
|
|
|
2860
2940
|
screen = null;
|
|
2861
2941
|
}
|
|
2862
2942
|
}
|
|
2863
|
-
const skip = (reason, detail) => {
|
|
2864
|
-
try {
|
|
2865
|
-
onSkip?.({ phase: "egress", reason, detail });
|
|
2866
|
-
} catch {
|
|
2867
|
-
}
|
|
2868
|
-
};
|
|
2943
|
+
const skip = (reason, detail) => notify(onSkip, { phase: "egress", reason, detail }, "onSkip");
|
|
2869
2944
|
const resolvesToDisallowed = (url, host, method) => new Promise((resolve) => {
|
|
2870
2945
|
if (!screen) {
|
|
2871
2946
|
if (host && dnsScreen) skip("resolver-unavailable", { host });
|
|
@@ -3451,22 +3526,19 @@ function reportRejections(rejected, options, label) {
|
|
|
3451
3526
|
const report = options.onRuleRejected;
|
|
3452
3527
|
for (const r of rejected) {
|
|
3453
3528
|
if (typeof report === "function") {
|
|
3454
|
-
|
|
3455
|
-
report({ ...r, accepted: false });
|
|
3456
|
-
} catch {
|
|
3457
|
-
}
|
|
3529
|
+
notify(report, { ...r, accepted: false }, "onRuleRejected");
|
|
3458
3530
|
}
|
|
3459
3531
|
}
|
|
3460
3532
|
const sample = rejected.slice(0, 3).map((r) => `${r.id} (${r.reason})`).join("; ");
|
|
3461
|
-
options.onError
|
|
3533
|
+
notify(options.onError, new Error(
|
|
3462
3534
|
`${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" : ""}`
|
|
3463
|
-
));
|
|
3535
|
+
), "onError");
|
|
3464
3536
|
}
|
|
3465
3537
|
async function resolveRules(options, store, ctx = {}) {
|
|
3466
3538
|
const timeoutMs = ctx.timeoutMs;
|
|
3467
3539
|
if (options.siteUuid) {
|
|
3468
3540
|
const prior = await store.read();
|
|
3469
|
-
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth });
|
|
3541
|
+
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, reportsDetections: options.reportDetections === true });
|
|
3470
3542
|
const res = await client.getRules();
|
|
3471
3543
|
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
3472
3544
|
if (res.success && !res.notModified) {
|
|
@@ -3482,14 +3554,14 @@ async function resolveRules(options, store, ctx = {}) {
|
|
|
3482
3554
|
return bundle;
|
|
3483
3555
|
}
|
|
3484
3556
|
if (prior?.bundle) {
|
|
3485
|
-
options.onError
|
|
3557
|
+
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
|
|
3486
3558
|
return normalizeBundle(prior.bundle, options);
|
|
3487
3559
|
}
|
|
3488
3560
|
if (options.rules) {
|
|
3489
|
-
options.onError
|
|
3561
|
+
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`), "onError");
|
|
3490
3562
|
return normalizeBundle(options.rules, options);
|
|
3491
3563
|
}
|
|
3492
|
-
options.onError
|
|
3564
|
+
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
|
|
3493
3565
|
return emptyBundle();
|
|
3494
3566
|
}
|
|
3495
3567
|
if (options.token) {
|
|
@@ -3510,10 +3582,10 @@ async function resolveRules(options, store, ctx = {}) {
|
|
|
3510
3582
|
return bundle;
|
|
3511
3583
|
}
|
|
3512
3584
|
if (prior?.bundle) {
|
|
3513
|
-
options.onError
|
|
3585
|
+
notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
|
|
3514
3586
|
return normalizeBundle(prior.bundle, options);
|
|
3515
3587
|
}
|
|
3516
|
-
options.onError
|
|
3588
|
+
notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
|
|
3517
3589
|
return emptyBundle();
|
|
3518
3590
|
}
|
|
3519
3591
|
if (options.rules) {
|
|
@@ -3534,10 +3606,7 @@ function normalizeBundle(b, options = {}) {
|
|
|
3534
3606
|
const report = options.onRuleRejected;
|
|
3535
3607
|
if (typeof report === "function") {
|
|
3536
3608
|
for (const r of rejected) {
|
|
3537
|
-
|
|
3538
|
-
report(r);
|
|
3539
|
-
} catch {
|
|
3540
|
-
}
|
|
3609
|
+
notify(report, r, "onRuleRejected");
|
|
3541
3610
|
}
|
|
3542
3611
|
} else {
|
|
3543
3612
|
const sample = rejected.slice(0, 3).map((r) => `${r.id} (${r.reason})`).join("; ");
|
|
@@ -3579,7 +3648,7 @@ function startRefresh(tick, { refreshMs, onError } = {}) {
|
|
|
3579
3648
|
failures = 0;
|
|
3580
3649
|
} catch (err) {
|
|
3581
3650
|
failures++;
|
|
3582
|
-
onError
|
|
3651
|
+
notify(onError, err, "onError");
|
|
3583
3652
|
}
|
|
3584
3653
|
schedule();
|
|
3585
3654
|
};
|
|
@@ -3611,10 +3680,125 @@ function makeRefreshHandler(tick, secret) {
|
|
|
3611
3680
|
};
|
|
3612
3681
|
}
|
|
3613
3682
|
|
|
3683
|
+
// src/protect/detections.js
|
|
3684
|
+
init_pulse_token();
|
|
3685
|
+
var DEFAULT_BASE_URL3 = "https://api.patchstack.com/monitor/pulse";
|
|
3686
|
+
var DEFAULT_FLUSH_MS = 5e3;
|
|
3687
|
+
var MAX_BATCH = 50;
|
|
3688
|
+
var MAX_QUEUE = 500;
|
|
3689
|
+
function ruleParameters(rule) {
|
|
3690
|
+
const out = /* @__PURE__ */ new Set();
|
|
3691
|
+
const walk2 = (conditions) => {
|
|
3692
|
+
if (!Array.isArray(conditions)) return;
|
|
3693
|
+
for (const condition of conditions) {
|
|
3694
|
+
if (!condition || typeof condition !== "object") continue;
|
|
3695
|
+
if (typeof condition.parameter === "string" && condition.parameter !== "rules") {
|
|
3696
|
+
out.add(condition.parameter);
|
|
3697
|
+
}
|
|
3698
|
+
if (Array.isArray(condition.rules)) walk2(condition.rules);
|
|
3699
|
+
}
|
|
3700
|
+
};
|
|
3701
|
+
walk2(rule?.rule_v2);
|
|
3702
|
+
return [...out];
|
|
3703
|
+
}
|
|
3704
|
+
function routeOf(path7) {
|
|
3705
|
+
if (typeof path7 !== "string" || path7 === "") return null;
|
|
3706
|
+
const cut = path7.search(/[?#]/);
|
|
3707
|
+
return cut === -1 ? path7 : path7.slice(0, cut);
|
|
3708
|
+
}
|
|
3709
|
+
function createDetectionReporter(opts) {
|
|
3710
|
+
const siteUuid = opts.siteUuid ?? process.env?.PATCHSTACK_SITE_UUID;
|
|
3711
|
+
if (!siteUuid) {
|
|
3712
|
+
return { record() {
|
|
3713
|
+
}, flush() {
|
|
3714
|
+
}, stop() {
|
|
3715
|
+
}, dropped: () => 0 };
|
|
3716
|
+
}
|
|
3717
|
+
const configured = opts.baseUrl ?? process.env?.PATCHSTACK_PULSE_RULES_URL;
|
|
3718
|
+
const baseUrl = typeof configured === "string" && isSafeOrigin(configured) ? configured.replace(/\/$/, "") : DEFAULT_BASE_URL3;
|
|
3719
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
3720
|
+
const flushMs = Number.isFinite(opts.flushMs) && opts.flushMs > 0 ? opts.flushMs : DEFAULT_FLUSH_MS;
|
|
3721
|
+
const maxQueue = Number.isFinite(opts.maxQueue) && opts.maxQueue > 0 ? opts.maxQueue : MAX_QUEUE;
|
|
3722
|
+
let queue = [];
|
|
3723
|
+
let timer = null;
|
|
3724
|
+
let stopped = false;
|
|
3725
|
+
let dropped = 0;
|
|
3726
|
+
const flush = () => {
|
|
3727
|
+
if (timer) {
|
|
3728
|
+
clearTimeout(timer);
|
|
3729
|
+
timer = null;
|
|
3730
|
+
}
|
|
3731
|
+
if (queue.length === 0 || typeof fetchImpl !== "function") return;
|
|
3732
|
+
const batch = queue.splice(0, MAX_BATCH);
|
|
3733
|
+
const droppedWith = dropped;
|
|
3734
|
+
dropped = 0;
|
|
3735
|
+
void (async () => {
|
|
3736
|
+
try {
|
|
3737
|
+
const res = await fetchImpl(`${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, {
|
|
3738
|
+
method: "POST",
|
|
3739
|
+
headers: {
|
|
3740
|
+
"Content-Type": "application/json",
|
|
3741
|
+
Accept: "application/json",
|
|
3742
|
+
"User-Agent": "@patchstack/connect",
|
|
3743
|
+
// Same credential path as the rules fetch, and unauthenticated when none resolves: the
|
|
3744
|
+
// server accepts the UUID, and reporting must never hinge on getting a token.
|
|
3745
|
+
...await pulseAuthHeader({ pulseAuth: opts.pulseAuth, endpoint: baseUrl }, fetchImpl)
|
|
3746
|
+
},
|
|
3747
|
+
body: JSON.stringify({ detections: batch, dropped: droppedWith })
|
|
3748
|
+
});
|
|
3749
|
+
if (res && typeof res.then === "function") res.catch(() => {
|
|
3750
|
+
});
|
|
3751
|
+
} catch {
|
|
3752
|
+
}
|
|
3753
|
+
})();
|
|
3754
|
+
};
|
|
3755
|
+
return {
|
|
3756
|
+
/**
|
|
3757
|
+
* @param {{
|
|
3758
|
+
* rule?: { id?: string, rule_v2?: unknown },
|
|
3759
|
+
* phase?: string,
|
|
3760
|
+
* mode?: string,
|
|
3761
|
+
* path?: string,
|
|
3762
|
+
* }} detection
|
|
3763
|
+
*/
|
|
3764
|
+
record(detection) {
|
|
3765
|
+
if (stopped) return;
|
|
3766
|
+
const ruleId = detection?.rule?.id;
|
|
3767
|
+
if (ruleId === void 0 || ruleId === null || ruleId === "") return;
|
|
3768
|
+
if (queue.length >= maxQueue) {
|
|
3769
|
+
queue.shift();
|
|
3770
|
+
dropped++;
|
|
3771
|
+
}
|
|
3772
|
+
queue.push({
|
|
3773
|
+
rule_id: ruleId,
|
|
3774
|
+
route: routeOf(detection.path),
|
|
3775
|
+
parameters: ruleParameters(detection.rule),
|
|
3776
|
+
phase: detection.phase ?? null,
|
|
3777
|
+
// The state this detection was handled under, which is the whole point: `false` is a rule that
|
|
3778
|
+
// saw traffic it would have stopped.
|
|
3779
|
+
enforced: detection.mode === "block",
|
|
3780
|
+
rules_etag: opts.rulesEtag ?? null,
|
|
3781
|
+
detected_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
3782
|
+
});
|
|
3783
|
+
if (queue.length >= MAX_BATCH) {
|
|
3784
|
+
flush();
|
|
3785
|
+
return;
|
|
3786
|
+
}
|
|
3787
|
+
if (!timer) timer = setTimeout(flush, flushMs);
|
|
3788
|
+
},
|
|
3789
|
+
flush,
|
|
3790
|
+
stop() {
|
|
3791
|
+
stopped = true;
|
|
3792
|
+
flush();
|
|
3793
|
+
},
|
|
3794
|
+
dropped: () => dropped
|
|
3795
|
+
};
|
|
3796
|
+
}
|
|
3797
|
+
|
|
3614
3798
|
// src/protect/firewall-log.js
|
|
3615
3799
|
var DEFAULT_API_BASE = "https://api.patchstack.com";
|
|
3616
|
-
var
|
|
3617
|
-
var
|
|
3800
|
+
var DEFAULT_FLUSH_MS2 = 1e3;
|
|
3801
|
+
var MAX_BATCH2 = 50;
|
|
3618
3802
|
var TOKEN_SKEW_MS2 = 6e4;
|
|
3619
3803
|
function parseApiKey(apiKey) {
|
|
3620
3804
|
if (typeof apiKey !== "string" || apiKey.length === 0) return null;
|
|
@@ -3659,7 +3843,7 @@ function createFirewallLogReporter(opts) {
|
|
|
3659
3843
|
}
|
|
3660
3844
|
const apiBase = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
|
|
3661
3845
|
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
3662
|
-
const flushMs = Number.isFinite(opts.flushMs) ? opts.flushMs :
|
|
3846
|
+
const flushMs = Number.isFinite(opts.flushMs) ? opts.flushMs : DEFAULT_FLUSH_MS2;
|
|
3663
3847
|
const sourceHost = typeof opts.sourceHost === "string" ? opts.sourceHost : "";
|
|
3664
3848
|
let queue = [];
|
|
3665
3849
|
let timer = null;
|
|
@@ -3708,7 +3892,7 @@ function createFirewallLogReporter(opts) {
|
|
|
3708
3892
|
timer = null;
|
|
3709
3893
|
}
|
|
3710
3894
|
if (queue.length === 0 || typeof fetchImpl !== "function") return;
|
|
3711
|
-
const batch = queue.splice(0,
|
|
3895
|
+
const batch = queue.splice(0, MAX_BATCH2);
|
|
3712
3896
|
void (async () => {
|
|
3713
3897
|
const token = await fetchAccessToken();
|
|
3714
3898
|
if (!token) return;
|
|
@@ -3755,7 +3939,7 @@ function createFirewallLogReporter(opts) {
|
|
|
3755
3939
|
user_agent: event.userAgent ?? null,
|
|
3756
3940
|
log_date: (/* @__PURE__ */ new Date()).toISOString()
|
|
3757
3941
|
});
|
|
3758
|
-
if (queue.length >=
|
|
3942
|
+
if (queue.length >= MAX_BATCH2) {
|
|
3759
3943
|
flush();
|
|
3760
3944
|
return;
|
|
3761
3945
|
}
|
|
@@ -3854,8 +4038,10 @@ async function createProtection(options = {}) {
|
|
|
3854
4038
|
sourceHost: options.sourceHost,
|
|
3855
4039
|
fetchImpl: options.fetchImpl
|
|
3856
4040
|
}) : null;
|
|
4041
|
+
let detections = null;
|
|
3857
4042
|
const onDetect = (detection) => {
|
|
3858
|
-
userOnDetect
|
|
4043
|
+
notify(userOnDetect, detection, "onDetect");
|
|
4044
|
+
if (detections) detections.record(detection);
|
|
3859
4045
|
if (firewallLog && detection?.mode === "block") {
|
|
3860
4046
|
firewallLog.record({
|
|
3861
4047
|
rule: detection.rule,
|
|
@@ -3869,7 +4055,24 @@ async function createProtection(options = {}) {
|
|
|
3869
4055
|
const store = makeStore(options);
|
|
3870
4056
|
const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
|
|
3871
4057
|
const pulseAuth = await resolvePulseAuth(options);
|
|
4058
|
+
if (options.siteUuid && !pulseAuth) {
|
|
4059
|
+
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.";
|
|
4060
|
+
notify(onError, new Error(message), "onError");
|
|
4061
|
+
console.warn(message);
|
|
4062
|
+
}
|
|
3872
4063
|
const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth });
|
|
4064
|
+
if (options.reportDetections === true && options.siteUuid && telemetryEnabled()) {
|
|
4065
|
+
detections = createDetectionReporter({
|
|
4066
|
+
siteUuid: options.siteUuid,
|
|
4067
|
+
baseUrl: options.pulseRulesUrl,
|
|
4068
|
+
pulseAuth,
|
|
4069
|
+
// The bundle the guard is actually running, so a hit can be attributed to the rules that produced
|
|
4070
|
+
// it rather than to whatever is current when the report is read.
|
|
4071
|
+
rulesEtag: (await store.read())?.etag ?? null,
|
|
4072
|
+
fetchImpl: options.fetchImpl,
|
|
4073
|
+
flushMs: options.detectionFlushMs
|
|
4074
|
+
});
|
|
4075
|
+
}
|
|
3873
4076
|
let mode = resolveMode(options, bundle);
|
|
3874
4077
|
let requestRules;
|
|
3875
4078
|
let responseRules;
|
|
@@ -3912,12 +4115,7 @@ async function createProtection(options = {}) {
|
|
|
3912
4115
|
const recordSkip = (phase, reason, detail) => {
|
|
3913
4116
|
const key = `${phase}:${reason}`;
|
|
3914
4117
|
skipCounts[key] = (skipCounts[key] ?? 0) + 1;
|
|
3915
|
-
|
|
3916
|
-
try {
|
|
3917
|
-
onSkip({ phase, reason, detail, count: skipCounts[key] });
|
|
3918
|
-
} catch {
|
|
3919
|
-
}
|
|
3920
|
-
}
|
|
4118
|
+
notify(onSkip, { phase, reason, detail, count: skipCounts[key] }, "onSkip");
|
|
3921
4119
|
};
|
|
3922
4120
|
const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
|
|
3923
4121
|
const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
|
|
@@ -3953,7 +4151,7 @@ async function createProtection(options = {}) {
|
|
|
3953
4151
|
try {
|
|
3954
4152
|
result = re.evaluate({ ...reqCtx || {}, _response: { ...meta, body: text } });
|
|
3955
4153
|
} catch (err) {
|
|
3956
|
-
onError
|
|
4154
|
+
notify(onError, err, "onError");
|
|
3957
4155
|
continue;
|
|
3958
4156
|
}
|
|
3959
4157
|
if (!result.blocked) continue;
|
|
@@ -4075,7 +4273,7 @@ async function createProtection(options = {}) {
|
|
|
4075
4273
|
try {
|
|
4076
4274
|
r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} }, reqCtx);
|
|
4077
4275
|
} catch (err) {
|
|
4078
|
-
onError
|
|
4276
|
+
notify(onError, err, "onError");
|
|
4079
4277
|
for (const c of chunks) origWrite(c);
|
|
4080
4278
|
return origEnd(cb);
|
|
4081
4279
|
}
|
|
@@ -4127,7 +4325,7 @@ async function createProtection(options = {}) {
|
|
|
4127
4325
|
try {
|
|
4128
4326
|
result = egressEngine.evaluate({ _egress: { url, host, method } });
|
|
4129
4327
|
} catch (err) {
|
|
4130
|
-
onError
|
|
4328
|
+
notify(onError, err, "onError");
|
|
4131
4329
|
return false;
|
|
4132
4330
|
}
|
|
4133
4331
|
if (!result.blocked) return false;
|
|
@@ -4161,7 +4359,7 @@ async function createProtection(options = {}) {
|
|
|
4161
4359
|
try {
|
|
4162
4360
|
result = engine.evaluate(await fromFetchRequest(request));
|
|
4163
4361
|
} catch (err) {
|
|
4164
|
-
onError
|
|
4362
|
+
notify(onError, err, "onError");
|
|
4165
4363
|
return null;
|
|
4166
4364
|
}
|
|
4167
4365
|
return decide("request", result, () => blockResponse(result, request), () => null, fetchRequestMeta(request));
|
|
@@ -4185,7 +4383,7 @@ async function createProtection(options = {}) {
|
|
|
4185
4383
|
try {
|
|
4186
4384
|
result = engine.evaluate(req);
|
|
4187
4385
|
} catch (err) {
|
|
4188
|
-
onError
|
|
4386
|
+
notify(onError, err, "onError");
|
|
4189
4387
|
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
4190
4388
|
return next();
|
|
4191
4389
|
}
|
|
@@ -4224,7 +4422,7 @@ async function createProtection(options = {}) {
|
|
|
4224
4422
|
chunks.push(chunk);
|
|
4225
4423
|
});
|
|
4226
4424
|
req.on("error", (err) => {
|
|
4227
|
-
onError
|
|
4425
|
+
notify(onError, err, "onError");
|
|
4228
4426
|
next();
|
|
4229
4427
|
});
|
|
4230
4428
|
req.on("end", () => {
|
|
@@ -4236,7 +4434,7 @@ async function createProtection(options = {}) {
|
|
|
4236
4434
|
shaped = fromNodeRequest(req, rawBody);
|
|
4237
4435
|
result = engine.evaluate(shaped);
|
|
4238
4436
|
} catch (err) {
|
|
4239
|
-
onError
|
|
4437
|
+
notify(onError, err, "onError");
|
|
4240
4438
|
return next();
|
|
4241
4439
|
}
|
|
4242
4440
|
decide(
|
|
@@ -4283,7 +4481,7 @@ async function createProtection(options = {}) {
|
|
|
4283
4481
|
try {
|
|
4284
4482
|
({ reportManifest: reporter } = await Promise.resolve().then(() => (init_refresh_manifest(), refresh_manifest_exports)));
|
|
4285
4483
|
} catch (err) {
|
|
4286
|
-
onError
|
|
4484
|
+
notify(onError, err, "onError");
|
|
4287
4485
|
}
|
|
4288
4486
|
}
|
|
4289
4487
|
const runRefreshTick = async () => {
|
|
@@ -4291,7 +4489,7 @@ async function createProtection(options = {}) {
|
|
|
4291
4489
|
try {
|
|
4292
4490
|
await reporter(cwd);
|
|
4293
4491
|
} catch (err) {
|
|
4294
|
-
onError
|
|
4492
|
+
notify(onError, err, "onError");
|
|
4295
4493
|
}
|
|
4296
4494
|
}
|
|
4297
4495
|
const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
|
|
@@ -4307,9 +4505,13 @@ async function createProtection(options = {}) {
|
|
|
4307
4505
|
protection.stopRefresh = () => {
|
|
4308
4506
|
loop.stop();
|
|
4309
4507
|
firewallLog?.stop();
|
|
4508
|
+
detections?.stop();
|
|
4310
4509
|
};
|
|
4311
4510
|
} else if (firewallLog) {
|
|
4312
|
-
protection.stopRefresh = () =>
|
|
4511
|
+
protection.stopRefresh = () => {
|
|
4512
|
+
firewallLog.stop();
|
|
4513
|
+
detections?.stop();
|
|
4514
|
+
};
|
|
4313
4515
|
}
|
|
4314
4516
|
return protection;
|
|
4315
4517
|
}
|