@patchstack/connect 0.3.22 → 0.3.24
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 +16 -7
- package/README.md +22 -2
- package/dist/chunk-MJOTFUDE.js +87 -0
- package/dist/chunk-MJOTFUDE.js.map +1 -0
- package/dist/cli.js +2868 -129
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +105 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +37 -2
- package/dist/index.d.ts +37 -2
- package/dist/index.js +103 -1
- package/dist/index.js.map +1 -1
- package/dist/protect.cjs +1182 -217
- package/dist/protect.cjs.map +1 -1
- package/dist/protect.d.ts +27 -1
- package/dist/protect.edge.js +3530 -0
- package/dist/protect.edge.js.map +7 -0
- package/dist/protect.js +1066 -193
- package/dist/protect.js.map +1 -1
- package/dist/{refresh-manifest-OW7WT5A6.js → refresh-manifest-MUWVIPFR.js} +12 -2
- package/dist/refresh-manifest-MUWVIPFR.js.map +1 -0
- package/package.json +11 -3
- package/dist/refresh-manifest-OW7WT5A6.js.map +0 -1
package/dist/protect.cjs
CHANGED
|
@@ -30,6 +30,94 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
30
30
|
));
|
|
31
31
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
32
32
|
|
|
33
|
+
// src/pulse-token.ts
|
|
34
|
+
function buildTokenUrl(manifestEndpoint) {
|
|
35
|
+
const url = new URL(manifestEndpoint);
|
|
36
|
+
const path7 = url.pathname.replace(/\/$/, "");
|
|
37
|
+
url.pathname = path7.endsWith("/manifest") ? `${path7.slice(0, -"/manifest".length)}/token` : "/monitor/pulse/token";
|
|
38
|
+
url.search = "";
|
|
39
|
+
url.hash = "";
|
|
40
|
+
return url.toString();
|
|
41
|
+
}
|
|
42
|
+
function parsePulseAuth(credential) {
|
|
43
|
+
const index = credential.lastIndexOf("-");
|
|
44
|
+
if (index <= 0 || index === credential.length - 1) return null;
|
|
45
|
+
const clientId = credential.slice(index + 1);
|
|
46
|
+
if (!/^\d+$/.test(clientId)) return null;
|
|
47
|
+
return { clientId, clientSecret: credential.slice(0, index) };
|
|
48
|
+
}
|
|
49
|
+
function clearPulseToken() {
|
|
50
|
+
cached = null;
|
|
51
|
+
}
|
|
52
|
+
async function getPulseToken(config, fetchImpl = fetch) {
|
|
53
|
+
if (typeof config.pulseAuth !== "string" || config.pulseAuth.length === 0) return null;
|
|
54
|
+
if (cached !== null && Date.now() < cached.expiresAt - TOKEN_SKEW_MS) {
|
|
55
|
+
return cached.token;
|
|
56
|
+
}
|
|
57
|
+
if (inflight !== null) return inflight;
|
|
58
|
+
const credentials = parsePulseAuth(config.pulseAuth);
|
|
59
|
+
if (credentials === null) return null;
|
|
60
|
+
inflight = (async () => {
|
|
61
|
+
try {
|
|
62
|
+
const response = await fetchImpl(buildTokenUrl(config.endpoint), {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: {
|
|
65
|
+
"Content-Type": "application/json",
|
|
66
|
+
Accept: "application/json",
|
|
67
|
+
"User-Agent": "@patchstack/connect"
|
|
68
|
+
},
|
|
69
|
+
body: JSON.stringify({
|
|
70
|
+
grant_type: "client_credentials",
|
|
71
|
+
client_id: credentials.clientId,
|
|
72
|
+
client_secret: credentials.clientSecret
|
|
73
|
+
}),
|
|
74
|
+
signal: AbortSignal.timeout(config.timeoutMs)
|
|
75
|
+
});
|
|
76
|
+
if (!response.ok) return null;
|
|
77
|
+
const body = await response.json();
|
|
78
|
+
if (typeof body.access_token !== "string" || body.access_token.length === 0) return null;
|
|
79
|
+
const expiresIn = Number(body.expires_in);
|
|
80
|
+
const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1e3 : 36e5;
|
|
81
|
+
cached = { token: body.access_token, expiresAt: Date.now() + ttlMs };
|
|
82
|
+
return body.access_token;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
} finally {
|
|
86
|
+
inflight = null;
|
|
87
|
+
}
|
|
88
|
+
})();
|
|
89
|
+
return inflight;
|
|
90
|
+
}
|
|
91
|
+
async function pulseAuthHeader(config, fetchImpl = fetch) {
|
|
92
|
+
const token = await getPulseToken(config, fetchImpl);
|
|
93
|
+
return token === null ? {} : { Authorization: `Bearer ${token}` };
|
|
94
|
+
}
|
|
95
|
+
async function pulseFetch(config, url, init, fetchImpl = fetch) {
|
|
96
|
+
const send = async () => {
|
|
97
|
+
const auth = await pulseAuthHeader(config, fetchImpl);
|
|
98
|
+
const response = await fetchImpl(url, {
|
|
99
|
+
...init,
|
|
100
|
+
headers: { ...init.headers, ...auth }
|
|
101
|
+
});
|
|
102
|
+
return { response, authenticated: auth.Authorization !== void 0 };
|
|
103
|
+
};
|
|
104
|
+
const first = await send();
|
|
105
|
+
if (first.response.status === 401 && first.authenticated) {
|
|
106
|
+
clearPulseToken();
|
|
107
|
+
return (await send()).response;
|
|
108
|
+
}
|
|
109
|
+
return first.response;
|
|
110
|
+
}
|
|
111
|
+
var TOKEN_SKEW_MS, cached, inflight;
|
|
112
|
+
var init_pulse_token = __esm({
|
|
113
|
+
"src/pulse-token.ts"() {
|
|
114
|
+
"use strict";
|
|
115
|
+
TOKEN_SKEW_MS = 6e4;
|
|
116
|
+
cached = null;
|
|
117
|
+
inflight = null;
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
33
121
|
// src/types.ts
|
|
34
122
|
var PatchstackError;
|
|
35
123
|
var init_types = __esm({
|
|
@@ -58,7 +146,7 @@ async function postManifest(config, payload) {
|
|
|
58
146
|
const timeoutMs = config.timeoutMs;
|
|
59
147
|
let response;
|
|
60
148
|
try {
|
|
61
|
-
response = await
|
|
149
|
+
response = await pulseFetch(config, url, {
|
|
62
150
|
method: "POST",
|
|
63
151
|
headers: {
|
|
64
152
|
"Content-Type": "application/json",
|
|
@@ -123,6 +211,7 @@ var init_client = __esm({
|
|
|
123
211
|
"src/client.ts"() {
|
|
124
212
|
"use strict";
|
|
125
213
|
init_types();
|
|
214
|
+
init_pulse_token();
|
|
126
215
|
DEFAULT_ENDPOINT = "https://api.patchstack.com/monitor/pulse/manifest";
|
|
127
216
|
DEFAULT_TIMEOUT_MS = 3e4;
|
|
128
217
|
}
|
|
@@ -155,8 +244,12 @@ async function resolveConfig(options) {
|
|
|
155
244
|
"CONFIG_MISSING"
|
|
156
245
|
);
|
|
157
246
|
}
|
|
247
|
+
const apiKeyRaw = fromEnv.apiKey ?? fromFile.apiKey ?? null;
|
|
248
|
+
const pulseAuthRaw = fromEnv.pulseAuth ?? fromFile.pulseAuth ?? apiKeyRaw;
|
|
158
249
|
return {
|
|
159
250
|
siteUuid: siteUuid === null || siteUuid.length === 0 ? null : siteUuid,
|
|
251
|
+
apiKey: apiKeyRaw === null || apiKeyRaw.length === 0 ? null : apiKeyRaw,
|
|
252
|
+
pulseAuth: pulseAuthRaw === null || pulseAuthRaw.length === 0 ? null : pulseAuthRaw,
|
|
160
253
|
endpoint,
|
|
161
254
|
timeoutMs,
|
|
162
255
|
environment,
|
|
@@ -164,7 +257,7 @@ async function resolveConfig(options) {
|
|
|
164
257
|
};
|
|
165
258
|
}
|
|
166
259
|
async function readConfigFile(cwd) {
|
|
167
|
-
const target =
|
|
260
|
+
const target = import_node_path.default.join(cwd, CONFIG_FILENAME);
|
|
168
261
|
let raw;
|
|
169
262
|
try {
|
|
170
263
|
raw = await (0, import_promises.readFile)(target, "utf8");
|
|
@@ -204,6 +297,8 @@ function readEnv() {
|
|
|
204
297
|
const environmentRaw = process.env.PATCHSTACK_ENVIRONMENT;
|
|
205
298
|
return {
|
|
206
299
|
siteUuid: process.env.PATCHSTACK_SITE_UUID ?? void 0,
|
|
300
|
+
apiKey: process.env.PATCHSTACK_API_KEY ?? void 0,
|
|
301
|
+
pulseAuth: process.env.PATCHSTACK_PULSE_AUTH ?? void 0,
|
|
207
302
|
endpoint: process.env.PATCHSTACK_ENDPOINT ?? void 0,
|
|
208
303
|
timeoutMs,
|
|
209
304
|
environment: environmentRaw !== void 0 && environmentRaw.length > 0 ? environmentRaw : void 0
|
|
@@ -215,12 +310,12 @@ function isUuid(value) {
|
|
|
215
310
|
function isEnvironment(value) {
|
|
216
311
|
return value === "production" || value === "sandbox";
|
|
217
312
|
}
|
|
218
|
-
var import_promises,
|
|
313
|
+
var import_promises, import_node_path, CONFIG_FILENAME, DEFAULT_ENVIRONMENT;
|
|
219
314
|
var init_config = __esm({
|
|
220
315
|
"src/config.ts"() {
|
|
221
316
|
"use strict";
|
|
222
317
|
import_promises = require("fs/promises");
|
|
223
|
-
|
|
318
|
+
import_node_path = __toESM(require("path"), 1);
|
|
224
319
|
init_types();
|
|
225
320
|
init_client();
|
|
226
321
|
CONFIG_FILENAME = ".patchstackrc.json";
|
|
@@ -232,7 +327,7 @@ var init_config = __esm({
|
|
|
232
327
|
async function readDeclaredDependencyNames(cwd) {
|
|
233
328
|
let raw;
|
|
234
329
|
try {
|
|
235
|
-
raw = await (0, import_promises2.readFile)(
|
|
330
|
+
raw = await (0, import_promises2.readFile)(import_node_path2.default.join(cwd, "package.json"), "utf8");
|
|
236
331
|
} catch {
|
|
237
332
|
return [];
|
|
238
333
|
}
|
|
@@ -264,12 +359,12 @@ function missingDependencies(declared, packages) {
|
|
|
264
359
|
const present = new Set(packages.map((entry) => entry.name));
|
|
265
360
|
return declared.filter((name) => !present.has(name));
|
|
266
361
|
}
|
|
267
|
-
var import_promises2,
|
|
362
|
+
var import_promises2, import_node_path2;
|
|
268
363
|
var init_consistency = __esm({
|
|
269
364
|
"src/parsers/consistency.ts"() {
|
|
270
365
|
"use strict";
|
|
271
366
|
import_promises2 = require("fs/promises");
|
|
272
|
-
|
|
367
|
+
import_node_path2 = __toESM(require("path"), 1);
|
|
273
368
|
}
|
|
274
369
|
});
|
|
275
370
|
|
|
@@ -335,7 +430,7 @@ function extractFromV1(deps, acc = [], depth = 0) {
|
|
|
335
430
|
return acc;
|
|
336
431
|
}
|
|
337
432
|
function extractNameFromPath(pkgPath) {
|
|
338
|
-
const segments = pkgPath.split("node_modules" +
|
|
433
|
+
const segments = pkgPath.split("node_modules" + import_node_path3.default.sep === pkgPath ? import_node_path3.default.sep : "/");
|
|
339
434
|
const parts = pkgPath.split("/");
|
|
340
435
|
const nmIndex = parts.lastIndexOf("node_modules");
|
|
341
436
|
if (nmIndex === -1 || nmIndex >= parts.length - 1) {
|
|
@@ -356,19 +451,19 @@ function isDirectV2(pkgPath) {
|
|
|
356
451
|
const nmCount = parts.filter((p) => p === "node_modules").length;
|
|
357
452
|
return nmCount === 1;
|
|
358
453
|
}
|
|
359
|
-
var import_promises3,
|
|
454
|
+
var import_promises3, import_node_path3;
|
|
360
455
|
var init_npm = __esm({
|
|
361
456
|
"src/parsers/npm.ts"() {
|
|
362
457
|
"use strict";
|
|
363
458
|
import_promises3 = require("fs/promises");
|
|
364
|
-
|
|
459
|
+
import_node_path3 = __toESM(require("path"), 1);
|
|
365
460
|
init_types();
|
|
366
461
|
}
|
|
367
462
|
});
|
|
368
463
|
|
|
369
464
|
// src/parsers/node_modules.ts
|
|
370
465
|
async function walkNodeModules(cwd) {
|
|
371
|
-
const root =
|
|
466
|
+
const root = import_node_path4.default.join(cwd, "node_modules");
|
|
372
467
|
try {
|
|
373
468
|
const info = await (0, import_promises4.stat)(root);
|
|
374
469
|
if (!info.isDirectory()) {
|
|
@@ -402,7 +497,7 @@ async function walk(dir, acc, depth) {
|
|
|
402
497
|
if (name.startsWith(".")) {
|
|
403
498
|
continue;
|
|
404
499
|
}
|
|
405
|
-
const fullPath =
|
|
500
|
+
const fullPath = import_node_path4.default.join(dir, name);
|
|
406
501
|
if (!await isPlainDirectory(fullPath)) {
|
|
407
502
|
continue;
|
|
408
503
|
}
|
|
@@ -417,7 +512,7 @@ async function walk(dir, acc, depth) {
|
|
|
417
512
|
if (sub.startsWith(".")) {
|
|
418
513
|
continue;
|
|
419
514
|
}
|
|
420
|
-
const scopedDir =
|
|
515
|
+
const scopedDir = import_node_path4.default.join(fullPath, sub);
|
|
421
516
|
if (!await isPlainDirectory(scopedDir)) {
|
|
422
517
|
continue;
|
|
423
518
|
}
|
|
@@ -433,7 +528,7 @@ async function walk(dir, acc, depth) {
|
|
|
433
528
|
async function readPackage(pkgDir, depth, acc) {
|
|
434
529
|
let raw;
|
|
435
530
|
try {
|
|
436
|
-
raw = await (0, import_promises4.readFile)(
|
|
531
|
+
raw = await (0, import_promises4.readFile)(import_node_path4.default.join(pkgDir, "package.json"), "utf8");
|
|
437
532
|
} catch {
|
|
438
533
|
return;
|
|
439
534
|
}
|
|
@@ -456,7 +551,7 @@ async function readPackage(pkgDir, depth, acc) {
|
|
|
456
551
|
});
|
|
457
552
|
}
|
|
458
553
|
async function walkNested(pkgDir, acc, depth) {
|
|
459
|
-
const nested =
|
|
554
|
+
const nested = import_node_path4.default.join(pkgDir, "node_modules");
|
|
460
555
|
if (!await isPlainDirectory(nested)) {
|
|
461
556
|
return;
|
|
462
557
|
}
|
|
@@ -470,12 +565,12 @@ async function isPlainDirectory(dir) {
|
|
|
470
565
|
return false;
|
|
471
566
|
}
|
|
472
567
|
}
|
|
473
|
-
var import_promises4,
|
|
568
|
+
var import_promises4, import_node_path4;
|
|
474
569
|
var init_node_modules = __esm({
|
|
475
570
|
"src/parsers/node_modules.ts"() {
|
|
476
571
|
"use strict";
|
|
477
572
|
import_promises4 = require("fs/promises");
|
|
478
|
-
|
|
573
|
+
import_node_path4 = __toESM(require("path"), 1);
|
|
479
574
|
init_types();
|
|
480
575
|
}
|
|
481
576
|
});
|
|
@@ -758,7 +853,7 @@ async function parseYarnLockfile(lockfilePath) {
|
|
|
758
853
|
"LOCKFILE_PARSE_ERROR"
|
|
759
854
|
);
|
|
760
855
|
}
|
|
761
|
-
const directNames = await readDirectDepNames(
|
|
856
|
+
const directNames = await readDirectDepNames(import_node_path5.default.dirname(lockfilePath));
|
|
762
857
|
const entries = [];
|
|
763
858
|
const seen = /* @__PURE__ */ new Set();
|
|
764
859
|
for (const block of blocks) {
|
|
@@ -902,7 +997,7 @@ async function readDirectDepNames(cwd) {
|
|
|
902
997
|
const names = /* @__PURE__ */ new Set();
|
|
903
998
|
let raw;
|
|
904
999
|
try {
|
|
905
|
-
raw = await (0, import_promises6.readFile)(
|
|
1000
|
+
raw = await (0, import_promises6.readFile)(import_node_path5.default.join(cwd, "package.json"), "utf8");
|
|
906
1001
|
} catch {
|
|
907
1002
|
return names;
|
|
908
1003
|
}
|
|
@@ -927,12 +1022,12 @@ async function readDirectDepNames(cwd) {
|
|
|
927
1022
|
}
|
|
928
1023
|
return names;
|
|
929
1024
|
}
|
|
930
|
-
var import_promises6,
|
|
1025
|
+
var import_promises6, import_node_path5;
|
|
931
1026
|
var init_yarn = __esm({
|
|
932
1027
|
"src/parsers/yarn.ts"() {
|
|
933
1028
|
"use strict";
|
|
934
1029
|
import_promises6 = require("fs/promises");
|
|
935
|
-
|
|
1030
|
+
import_node_path5 = __toESM(require("path"), 1);
|
|
936
1031
|
init_types();
|
|
937
1032
|
}
|
|
938
1033
|
});
|
|
@@ -941,7 +1036,7 @@ var init_yarn = __esm({
|
|
|
941
1036
|
async function detectLockfile(cwd) {
|
|
942
1037
|
const probed = await Promise.all(
|
|
943
1038
|
LOCKFILE_CANDIDATES.map(async (candidate) => {
|
|
944
|
-
const filePath =
|
|
1039
|
+
const filePath = import_node_path6.default.join(cwd, candidate.filename);
|
|
945
1040
|
return { ...candidate, filePath, present: await exists(filePath) };
|
|
946
1041
|
})
|
|
947
1042
|
);
|
|
@@ -1019,7 +1114,7 @@ function staleWarning(source, missing) {
|
|
|
1019
1114
|
async function presentLockfiles(cwd) {
|
|
1020
1115
|
const probed = await Promise.all(
|
|
1021
1116
|
LOCKFILE_CANDIDATES.map(async (candidate) => {
|
|
1022
|
-
const filePath =
|
|
1117
|
+
const filePath = import_node_path6.default.join(cwd, candidate.filename);
|
|
1023
1118
|
return { ...candidate, filePath, present: await exists(filePath) };
|
|
1024
1119
|
})
|
|
1025
1120
|
);
|
|
@@ -1045,12 +1140,12 @@ async function exists(filePath) {
|
|
|
1045
1140
|
return false;
|
|
1046
1141
|
}
|
|
1047
1142
|
}
|
|
1048
|
-
var import_promises7,
|
|
1143
|
+
var import_promises7, import_node_path6, LOCKFILE_CANDIDATES;
|
|
1049
1144
|
var init_parsers = __esm({
|
|
1050
1145
|
"src/parsers/index.ts"() {
|
|
1051
1146
|
"use strict";
|
|
1052
1147
|
import_promises7 = require("fs/promises");
|
|
1053
|
-
|
|
1148
|
+
import_node_path6 = __toESM(require("path"), 1);
|
|
1054
1149
|
init_types();
|
|
1055
1150
|
init_consistency();
|
|
1056
1151
|
init_npm();
|
|
@@ -1209,6 +1304,29 @@ __export(runtime_exports, {
|
|
|
1209
1304
|
});
|
|
1210
1305
|
module.exports = __toCommonJS(runtime_exports);
|
|
1211
1306
|
|
|
1307
|
+
// src/protect/safe-origin.js
|
|
1308
|
+
function isSafeOrigin(value) {
|
|
1309
|
+
try {
|
|
1310
|
+
const u = new URL(value);
|
|
1311
|
+
if (u.protocol === "https:") return true;
|
|
1312
|
+
return u.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]" || u.hostname === "::1");
|
|
1313
|
+
} catch {
|
|
1314
|
+
return false;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
function safeBaseUrl(candidate, fallback, label) {
|
|
1318
|
+
if (typeof candidate !== "string" || candidate === "") return fallback;
|
|
1319
|
+
if (isSafeOrigin(candidate)) return candidate;
|
|
1320
|
+
warnOnce(label, `[patchstack] ignoring unsafe ${label} override (${candidate}): must be https (or localhost). Using the default.`);
|
|
1321
|
+
return fallback;
|
|
1322
|
+
}
|
|
1323
|
+
var warned = /* @__PURE__ */ new Set();
|
|
1324
|
+
function warnOnce(key, message) {
|
|
1325
|
+
if (warned.has(key)) return;
|
|
1326
|
+
warned.add(key);
|
|
1327
|
+
console.warn(message);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1212
1330
|
// src/protect/engine/client.js
|
|
1213
1331
|
var DEFAULT_BASE_URL = "https://api.patchstack.com";
|
|
1214
1332
|
var DEFAULT_CACHE_TTL = 3e5;
|
|
@@ -1221,9 +1339,11 @@ var PatchstackRuleClient = class {
|
|
|
1221
1339
|
#cacheTime = null;
|
|
1222
1340
|
#ttlEffective = 0;
|
|
1223
1341
|
#etag;
|
|
1224
|
-
|
|
1342
|
+
#timeoutMs;
|
|
1343
|
+
constructor({ token, baseUrl, cacheTtl, etag, timeoutMs } = {}) {
|
|
1344
|
+
this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
|
|
1225
1345
|
this.#token = token ?? process.env.PATCHSTACK_WAF_TOKEN;
|
|
1226
|
-
this.#baseUrl = baseUrl ?? process.env.PATCHSTACK_WAF_API_URL
|
|
1346
|
+
this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_WAF_API_URL, DEFAULT_BASE_URL, "rule endpoint");
|
|
1227
1347
|
this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL;
|
|
1228
1348
|
this.#etag = etag ?? null;
|
|
1229
1349
|
if (!this.#token) {
|
|
@@ -1248,7 +1368,7 @@ var PatchstackRuleClient = class {
|
|
|
1248
1368
|
method: "POST",
|
|
1249
1369
|
headers,
|
|
1250
1370
|
body: JSON.stringify({}),
|
|
1251
|
-
signal: AbortSignal.timeout(
|
|
1371
|
+
signal: AbortSignal.timeout(this.#timeoutMs)
|
|
1252
1372
|
});
|
|
1253
1373
|
if (response.status === 304) {
|
|
1254
1374
|
this.#touch(now);
|
|
@@ -1301,6 +1421,9 @@ var PatchstackRuleClient = class {
|
|
|
1301
1421
|
};
|
|
1302
1422
|
|
|
1303
1423
|
// src/protect/engine/request.js
|
|
1424
|
+
var FILE_ATTRS = /* @__PURE__ */ new Set(["content", "filename", "type"]);
|
|
1425
|
+
var fileFilename = (f) => f && typeof f === "object" ? f.filename : f;
|
|
1426
|
+
var fileAttribute = (f, attr) => f && typeof f === "object" ? f[attr] : attr === "filename" ? f : void 0;
|
|
1304
1427
|
function base64DecodeUtf8(value) {
|
|
1305
1428
|
const str = String(value);
|
|
1306
1429
|
if (typeof Buffer !== "undefined") {
|
|
@@ -1504,7 +1627,9 @@ var RequestResolver = class {
|
|
|
1504
1627
|
default: {
|
|
1505
1628
|
if (key.startsWith("HTTP_")) {
|
|
1506
1629
|
const headerName = key.substring(5).toLowerCase().replace(/_/g, "-");
|
|
1507
|
-
|
|
1630
|
+
const headers = req.headers;
|
|
1631
|
+
if (headers === null || typeof headers !== "object") return [];
|
|
1632
|
+
return Object.prototype.hasOwnProperty.call(headers, headerName) ? [headers[headerName]] : [];
|
|
1508
1633
|
}
|
|
1509
1634
|
return [];
|
|
1510
1635
|
}
|
|
@@ -1512,14 +1637,32 @@ var RequestResolver = class {
|
|
|
1512
1637
|
}
|
|
1513
1638
|
#resolveFiles(key) {
|
|
1514
1639
|
const files = this.#req.files;
|
|
1515
|
-
if (!files) {
|
|
1640
|
+
if (!files || typeof files !== "object") {
|
|
1516
1641
|
return [];
|
|
1517
1642
|
}
|
|
1643
|
+
const dot = key.lastIndexOf(".");
|
|
1644
|
+
if (dot !== -1 && FILE_ATTRS.has(key.slice(dot + 1)) && Object.prototype.hasOwnProperty.call(files, key.slice(0, dot))) {
|
|
1645
|
+
const attr = key.slice(dot + 1);
|
|
1646
|
+
const entry = files[key.slice(0, dot)];
|
|
1647
|
+
const list = Array.isArray(entry) ? entry : [entry];
|
|
1648
|
+
const out = [];
|
|
1649
|
+
for (const f of list) {
|
|
1650
|
+
const v = fileAttribute(f, attr);
|
|
1651
|
+
if (v !== void 0 && v !== "") out.push(v);
|
|
1652
|
+
}
|
|
1653
|
+
return out;
|
|
1654
|
+
}
|
|
1655
|
+
const filenamesOf = (entry) => Array.isArray(entry) ? entry.map(fileFilename) : [fileFilename(entry)];
|
|
1518
1656
|
if (key.endsWith("*")) {
|
|
1519
|
-
|
|
1657
|
+
const prefix = key.slice(0, -1);
|
|
1658
|
+
const out = [];
|
|
1659
|
+
for (const [k, entry] of Object.entries(files)) {
|
|
1660
|
+
if (k.startsWith(prefix)) out.push(...filenamesOf(entry));
|
|
1661
|
+
}
|
|
1662
|
+
return out.filter((v) => v !== void 0);
|
|
1520
1663
|
}
|
|
1521
|
-
|
|
1522
|
-
return
|
|
1664
|
+
if (!Object.prototype.hasOwnProperty.call(files, key)) return [];
|
|
1665
|
+
return filenamesOf(files[key]).filter((v) => v !== void 0);
|
|
1523
1666
|
}
|
|
1524
1667
|
#resolveRaw() {
|
|
1525
1668
|
if (typeof this.#req._rawBody === "string") {
|
|
@@ -1550,6 +1693,9 @@ var RequestResolver = class {
|
|
|
1550
1693
|
if (body) {
|
|
1551
1694
|
parts.push(typeof body === "string" ? body : JSON.stringify(body));
|
|
1552
1695
|
}
|
|
1696
|
+
if (typeof this.#req._rawBody === "string" && this.#req._rawBody) {
|
|
1697
|
+
parts.push(this.#req._rawBody);
|
|
1698
|
+
}
|
|
1553
1699
|
const headers = this.#req.headers ?? {};
|
|
1554
1700
|
const excludedHeaders = /* @__PURE__ */ new Set([
|
|
1555
1701
|
"host",
|
|
@@ -1742,8 +1888,6 @@ function removeSqlComments(value) {
|
|
|
1742
1888
|
let result = value;
|
|
1743
1889
|
result = result.replace(/\/\*[\s\S]*?\*\//g, " ");
|
|
1744
1890
|
result = result.replace(/\/\*![\s\S]*?\*\//g, " ");
|
|
1745
|
-
result = result.replace(/--[^\r\n]*/g, "");
|
|
1746
|
-
result = result.replace(/#[^\r\n]*/g, "");
|
|
1747
1891
|
return result;
|
|
1748
1892
|
}
|
|
1749
1893
|
function removeNullBytes(value) {
|
|
@@ -1798,17 +1942,21 @@ function normalizeRequest(req, options = {}) {
|
|
|
1798
1942
|
_rawBody: rawBody
|
|
1799
1943
|
};
|
|
1800
1944
|
}
|
|
1801
|
-
|
|
1945
|
+
var MAX_NORMALIZE_DEPTH = 200;
|
|
1946
|
+
function normalizeObject(value, options = {}, depth = 0) {
|
|
1802
1947
|
if (typeof value === "string") {
|
|
1803
1948
|
return normalize(value, options);
|
|
1804
1949
|
}
|
|
1950
|
+
if (depth >= MAX_NORMALIZE_DEPTH) {
|
|
1951
|
+
return value;
|
|
1952
|
+
}
|
|
1805
1953
|
if (Array.isArray(value)) {
|
|
1806
|
-
return value.map((item) => normalizeObject(item, options));
|
|
1954
|
+
return value.map((item) => normalizeObject(item, options, depth + 1));
|
|
1807
1955
|
}
|
|
1808
1956
|
if (typeof value === "object" && value !== null) {
|
|
1809
1957
|
const result = {};
|
|
1810
1958
|
for (const [key, val] of Object.entries(value)) {
|
|
1811
|
-
result[key] = normalizeObject(val, options);
|
|
1959
|
+
result[key] = normalizeObject(val, options, depth + 1);
|
|
1812
1960
|
}
|
|
1813
1961
|
return result;
|
|
1814
1962
|
}
|
|
@@ -1816,14 +1964,32 @@ function normalizeObject(value, options = {}) {
|
|
|
1816
1964
|
}
|
|
1817
1965
|
|
|
1818
1966
|
// src/protect/engine/engine.js
|
|
1967
|
+
var GRP = "(?:[^()]|\\([^()]*\\))*";
|
|
1819
1968
|
var REDOS_PATTERNS = [
|
|
1820
1969
|
/\([^)]*[+*}][^)]*\)\s*[+*]/,
|
|
1821
|
-
/\([^)]*\|[^)]*\)\s*[+*]
|
|
1970
|
+
/\([^)]*\|[^)]*\)\s*[+*]/,
|
|
1971
|
+
new RegExp("\\(" + GRP + "[+*}]" + GRP + "\\)\\s*[+*]"),
|
|
1972
|
+
// nested quantified subgroup
|
|
1973
|
+
new RegExp("\\(" + GRP + "\\|" + GRP + "\\)\\s*[+*]")
|
|
1974
|
+
// nested alternation under a quantifier
|
|
1822
1975
|
];
|
|
1976
|
+
var warnedRejectedPatterns = /* @__PURE__ */ new Set();
|
|
1977
|
+
function warnRejectedPatternOnce(pattern) {
|
|
1978
|
+
const key = String(pattern);
|
|
1979
|
+
if (warnedRejectedPatterns.has(key)) return;
|
|
1980
|
+
warnedRejectedPatterns.add(key);
|
|
1981
|
+
console.warn(
|
|
1982
|
+
`[patchstack] rule_v2 regex pattern rejected (unsafe or invalid) \u2014 condition treated as no-match. The rule relying on it is NOT enforced: ${key}`
|
|
1983
|
+
);
|
|
1984
|
+
}
|
|
1985
|
+
var MAX_PATTERN_LENGTH = 1e3;
|
|
1823
1986
|
function safeRegExp(pattern) {
|
|
1824
1987
|
if (!pattern) {
|
|
1825
1988
|
return null;
|
|
1826
1989
|
}
|
|
1990
|
+
if (typeof pattern !== "string" || pattern.length > MAX_PATTERN_LENGTH) {
|
|
1991
|
+
return null;
|
|
1992
|
+
}
|
|
1827
1993
|
for (const dangerous of REDOS_PATTERNS) {
|
|
1828
1994
|
if (dangerous.test(pattern)) {
|
|
1829
1995
|
return null;
|
|
@@ -1880,6 +2046,34 @@ function arrayKeyValue(value, matchObj) {
|
|
|
1880
2046
|
}
|
|
1881
2047
|
return false;
|
|
1882
2048
|
}
|
|
2049
|
+
var WHOLE_VALUE_MATCH_TYPES = /* @__PURE__ */ new Set(["isset", "array_in_array", "array_key_value"]);
|
|
2050
|
+
function collectLeafValues(root, nodeCap = 2e4, maxDepth = 1e3) {
|
|
2051
|
+
const out = [];
|
|
2052
|
+
const stack = [[root, 0]];
|
|
2053
|
+
let visited = 0;
|
|
2054
|
+
while (stack.length) {
|
|
2055
|
+
const [node, depth] = stack.pop();
|
|
2056
|
+
if (node === null || node === void 0) continue;
|
|
2057
|
+
if (typeof node !== "object") {
|
|
2058
|
+
out.push(node);
|
|
2059
|
+
continue;
|
|
2060
|
+
}
|
|
2061
|
+
if (depth >= maxDepth || visited >= nodeCap) continue;
|
|
2062
|
+
visited++;
|
|
2063
|
+
if (Array.isArray(node)) {
|
|
2064
|
+
for (let i = node.length - 1; i >= 0; i--) stack.push([node[i], depth + 1]);
|
|
2065
|
+
} else {
|
|
2066
|
+
for (const k of Object.keys(node)) stack.push([node[k], depth + 1]);
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
return out;
|
|
2070
|
+
}
|
|
2071
|
+
var warnedKeys = /* @__PURE__ */ new Set();
|
|
2072
|
+
function warnOnce2(key, message) {
|
|
2073
|
+
if (warnedKeys.has(key)) return;
|
|
2074
|
+
warnedKeys.add(key);
|
|
2075
|
+
console.warn(message);
|
|
2076
|
+
}
|
|
1883
2077
|
var warnedMatchTypes = /* @__PURE__ */ new Set();
|
|
1884
2078
|
function warnUnsupportedMatchType(type) {
|
|
1885
2079
|
if (type == null || warnedMatchTypes.has(type)) {
|
|
@@ -1892,28 +2086,93 @@ function warnUnsupportedMatchType(type) {
|
|
|
1892
2086
|
}
|
|
1893
2087
|
function isInternalHost(hostname) {
|
|
1894
2088
|
if (!hostname) return false;
|
|
1895
|
-
|
|
2089
|
+
let host = String(hostname).toLowerCase().replace(/^\[|\]$/g, "");
|
|
2090
|
+
host = host.replace(/%[^\]]*$/, "");
|
|
2091
|
+
host = host.replace(/\.$/, "");
|
|
1896
2092
|
if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) return true;
|
|
1897
2093
|
if (host === "metadata.google.internal") return true;
|
|
1898
|
-
if (host
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
2094
|
+
if (host.includes(":")) {
|
|
2095
|
+
const g = expandIPv6(host);
|
|
2096
|
+
if (!g) return false;
|
|
2097
|
+
const allZeroHi = g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0;
|
|
2098
|
+
if (allZeroHi && g[5] === 0 && g[6] === 0 && (g[7] === 0 || g[7] === 1)) return true;
|
|
2099
|
+
if ((g[0] & 65472) === 65152) return true;
|
|
2100
|
+
if ((g[0] & 65024) === 64512) return true;
|
|
2101
|
+
if (allZeroHi && (g[5] === 65535 || g[5] === 0)) {
|
|
2102
|
+
return isPrivateV4Int((g[6] << 16 >>> 0 | g[7]) >>> 0);
|
|
2103
|
+
}
|
|
2104
|
+
return false;
|
|
1907
2105
|
}
|
|
2106
|
+
const v4 = parseIPv4ToInt(host);
|
|
2107
|
+
if (v4 !== null) return isPrivateV4Int(v4);
|
|
1908
2108
|
return false;
|
|
1909
2109
|
}
|
|
1910
|
-
function
|
|
2110
|
+
function isPrivateV4Int(n) {
|
|
2111
|
+
const a = n >>> 24 & 255;
|
|
2112
|
+
const b = n >>> 16 & 255;
|
|
1911
2113
|
if (a === 127 || a === 10 || a === 0) return true;
|
|
1912
2114
|
if (a === 169 && b === 254) return true;
|
|
1913
2115
|
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
1914
2116
|
if (a === 192 && b === 168) return true;
|
|
2117
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
1915
2118
|
return false;
|
|
1916
2119
|
}
|
|
2120
|
+
function parseIPv4ToInt(host) {
|
|
2121
|
+
if (!/^[0-9a-fx]+(\.[0-9a-fx]+)*$/i.test(host)) return null;
|
|
2122
|
+
const parts = host.split(".");
|
|
2123
|
+
if (parts.length > 4) return null;
|
|
2124
|
+
const nums = [];
|
|
2125
|
+
for (const p of parts) {
|
|
2126
|
+
let n;
|
|
2127
|
+
if (/^0x[0-9a-f]+$/i.test(p)) n = parseInt(p, 16);
|
|
2128
|
+
else if (/^0[0-7]+$/.test(p)) n = parseInt(p, 8);
|
|
2129
|
+
else if (/^[0-9]+$/.test(p)) n = parseInt(p, 10);
|
|
2130
|
+
else return null;
|
|
2131
|
+
if (!Number.isInteger(n) || n < 0) return null;
|
|
2132
|
+
nums.push(n);
|
|
2133
|
+
}
|
|
2134
|
+
const last = nums.length - 1;
|
|
2135
|
+
let value = 0;
|
|
2136
|
+
for (let i = 0; i < last; i++) {
|
|
2137
|
+
if (nums[i] > 255) return null;
|
|
2138
|
+
value += nums[i] * 2 ** (8 * (3 - i));
|
|
2139
|
+
}
|
|
2140
|
+
const maxLast = 2 ** (8 * (5 - nums.length)) - 1;
|
|
2141
|
+
if (nums[last] > maxLast) return null;
|
|
2142
|
+
value += nums[last];
|
|
2143
|
+
if (value < 0 || value > 4294967295) return null;
|
|
2144
|
+
return value >>> 0;
|
|
2145
|
+
}
|
|
2146
|
+
function expandIPv6(host) {
|
|
2147
|
+
if (!host.includes(":")) return null;
|
|
2148
|
+
let s = host;
|
|
2149
|
+
const lastColon = s.lastIndexOf(":");
|
|
2150
|
+
const tail = s.slice(lastColon + 1);
|
|
2151
|
+
if (tail.includes(".")) {
|
|
2152
|
+
const v4 = parseIPv4ToInt(tail);
|
|
2153
|
+
if (v4 === null) return null;
|
|
2154
|
+
s = s.slice(0, lastColon + 1) + (v4 >>> 16 & 65535).toString(16) + ":" + (v4 & 65535).toString(16);
|
|
2155
|
+
}
|
|
2156
|
+
const halves = s.split("::");
|
|
2157
|
+
if (halves.length > 2) return null;
|
|
2158
|
+
const head = halves[0] ? halves[0].split(":") : [];
|
|
2159
|
+
const back = halves.length === 2 ? halves[1] ? halves[1].split(":") : [] : null;
|
|
2160
|
+
let groups;
|
|
2161
|
+
if (back === null) {
|
|
2162
|
+
groups = head;
|
|
2163
|
+
} else {
|
|
2164
|
+
const fill = 8 - head.length - back.length;
|
|
2165
|
+
if (fill < 0) return null;
|
|
2166
|
+
groups = [...head, ...Array(fill).fill("0"), ...back];
|
|
2167
|
+
}
|
|
2168
|
+
if (groups.length !== 8) return null;
|
|
2169
|
+
const out = [];
|
|
2170
|
+
for (const g of groups) {
|
|
2171
|
+
if (!/^[0-9a-f]{1,4}$/i.test(g)) return null;
|
|
2172
|
+
out.push(parseInt(g, 16));
|
|
2173
|
+
}
|
|
2174
|
+
return out;
|
|
2175
|
+
}
|
|
1917
2176
|
function ruleAppliesTo(when, resolver) {
|
|
1918
2177
|
try {
|
|
1919
2178
|
if (when.method) {
|
|
@@ -1941,14 +2200,22 @@ function pathMatches(pattern, path7) {
|
|
|
1941
2200
|
}
|
|
1942
2201
|
return path7 === pattern;
|
|
1943
2202
|
}
|
|
2203
|
+
function normalizeDefaultPort(host) {
|
|
2204
|
+
return String(host).toLowerCase().replace(/:(?:80|443)$/, "");
|
|
2205
|
+
}
|
|
1944
2206
|
function isCrossOrigin(resolver) {
|
|
1945
2207
|
try {
|
|
1946
|
-
const host = String(resolver.resolve("server.HTTP_HOST")[0] ?? "")
|
|
2208
|
+
const host = normalizeDefaultPort(String(resolver.resolve("server.HTTP_HOST")[0] ?? ""));
|
|
1947
2209
|
if (!host) return false;
|
|
1948
|
-
const
|
|
1949
|
-
|
|
1950
|
-
const
|
|
1951
|
-
|
|
2210
|
+
const originRaw = resolver.resolve("server.HTTP_ORIGIN")[0];
|
|
2211
|
+
const hasOrigin = originRaw !== void 0 && originRaw !== null;
|
|
2212
|
+
const src = hasOrigin ? originRaw : resolver.resolve("server.HTTP_REFERER")[0];
|
|
2213
|
+
if (src === void 0 || src === null) return false;
|
|
2214
|
+
const s = String(src).trim();
|
|
2215
|
+
if (hasOrigin && (s === "" || s.toLowerCase() === "null")) return true;
|
|
2216
|
+
const srcHost = hostFromUrl(s);
|
|
2217
|
+
if (srcHost === null) return hasOrigin;
|
|
2218
|
+
return normalizeDefaultPort(srcHost) !== host;
|
|
1952
2219
|
} catch {
|
|
1953
2220
|
return false;
|
|
1954
2221
|
}
|
|
@@ -1960,6 +2227,41 @@ function hostFromUrl(value) {
|
|
|
1960
2227
|
return null;
|
|
1961
2228
|
}
|
|
1962
2229
|
}
|
|
2230
|
+
function isOffOriginRedirect(resolver) {
|
|
2231
|
+
try {
|
|
2232
|
+
const status = Number(resolver.resolve("response.status")[0] ?? 0);
|
|
2233
|
+
if (status < 300 || status >= 400) return false;
|
|
2234
|
+
const location = resolver.resolve("response.header.location")[0];
|
|
2235
|
+
if (!location) return false;
|
|
2236
|
+
const host = String(resolver.resolve("server.HTTP_HOST")[0] ?? "").toLowerCase();
|
|
2237
|
+
if (!host) return false;
|
|
2238
|
+
const loc = String(location).replace(/[\t\r\n]/g, "").replace(/\\/g, "/");
|
|
2239
|
+
let target;
|
|
2240
|
+
try {
|
|
2241
|
+
target = new URL(loc, "http://" + host).host.toLowerCase();
|
|
2242
|
+
} catch {
|
|
2243
|
+
return false;
|
|
2244
|
+
}
|
|
2245
|
+
return normalizeDefaultPort(target) !== normalizeDefaultPort(host);
|
|
2246
|
+
} catch {
|
|
2247
|
+
return false;
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
function isReflectedCorsWithCredentials(resolver) {
|
|
2251
|
+
try {
|
|
2252
|
+
const acac = String(resolver.resolve("response.header.access-control-allow-credentials")[0] ?? "").toLowerCase();
|
|
2253
|
+
if (acac !== "true") return false;
|
|
2254
|
+
const acao = String(resolver.resolve("response.header.access-control-allow-origin")[0] ?? "");
|
|
2255
|
+
if (!acao) return false;
|
|
2256
|
+
if (acao === "*") return true;
|
|
2257
|
+
if (acao.toLowerCase() === "null") return true;
|
|
2258
|
+
const origin = String(resolver.resolve("server.HTTP_ORIGIN")[0] ?? "");
|
|
2259
|
+
if (!origin) return false;
|
|
2260
|
+
return acao === origin;
|
|
2261
|
+
} catch {
|
|
2262
|
+
return false;
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
1963
2265
|
function matchValue(type, value, matchVal, matchObj) {
|
|
1964
2266
|
if (value === null || value === void 0) {
|
|
1965
2267
|
if (type === "isset") {
|
|
@@ -1967,7 +2269,16 @@ function matchValue(type, value, matchVal, matchObj) {
|
|
|
1967
2269
|
}
|
|
1968
2270
|
return false;
|
|
1969
2271
|
}
|
|
1970
|
-
|
|
2272
|
+
let strValue;
|
|
2273
|
+
if (typeof value === "string") {
|
|
2274
|
+
strValue = value;
|
|
2275
|
+
} else {
|
|
2276
|
+
try {
|
|
2277
|
+
strValue = String(value);
|
|
2278
|
+
} catch {
|
|
2279
|
+
strValue = "";
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
1971
2282
|
switch (type) {
|
|
1972
2283
|
case "equals":
|
|
1973
2284
|
return strValue == matchVal;
|
|
@@ -1988,6 +2299,7 @@ function matchValue(type, value, matchVal, matchObj) {
|
|
|
1988
2299
|
case "regex": {
|
|
1989
2300
|
const regex = safeRegExp(matchVal);
|
|
1990
2301
|
if (!regex) {
|
|
2302
|
+
warnRejectedPatternOnce(matchVal);
|
|
1991
2303
|
return false;
|
|
1992
2304
|
}
|
|
1993
2305
|
return regex.test(strValue);
|
|
@@ -2066,6 +2378,15 @@ var RuleEngine = class {
|
|
|
2066
2378
|
this.#whitelists = whitelists;
|
|
2067
2379
|
this.#whitelistKeys = whitelist_keys;
|
|
2068
2380
|
this.#onError = onError;
|
|
2381
|
+
if (Array.isArray(whitelists) && whitelists.some((w) => w && Array.isArray(w.rule_v2) && !w.rule_id)) {
|
|
2382
|
+
warnOnce2(
|
|
2383
|
+
"whitelist-no-rule-id",
|
|
2384
|
+
"[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
|
+
);
|
|
2386
|
+
}
|
|
2387
|
+
if (whitelist_keys && typeof whitelist_keys === "object" && Object.keys(whitelist_keys).length > 0) {
|
|
2388
|
+
warnOnce2("whitelist-keys-unimplemented", "[patchstack] `whitelist_keys` is not implemented and has no effect.");
|
|
2389
|
+
}
|
|
2069
2390
|
}
|
|
2070
2391
|
// A mitigation engine must never take down the app it protects: any error while
|
|
2071
2392
|
// evaluating a rule is reported and the request is allowed through (fail open). A
|
|
@@ -2146,6 +2467,12 @@ var RuleEngine = class {
|
|
|
2146
2467
|
if (match && match.type === "cross_origin") {
|
|
2147
2468
|
return isCrossOrigin(resolver);
|
|
2148
2469
|
}
|
|
2470
|
+
if (match && match.type === "off_origin") {
|
|
2471
|
+
return isOffOriginRedirect(resolver);
|
|
2472
|
+
}
|
|
2473
|
+
if (match && match.type === "cors_reflected") {
|
|
2474
|
+
return isReflectedCorsWithCredentials(resolver);
|
|
2475
|
+
}
|
|
2149
2476
|
const params = Array.isArray(parameter) ? parameter : [parameter];
|
|
2150
2477
|
const values = params.flatMap((p) => resolver.resolve(p));
|
|
2151
2478
|
if (values.length === 0) {
|
|
@@ -2161,10 +2488,12 @@ var RuleEngine = class {
|
|
|
2161
2488
|
}
|
|
2162
2489
|
continue;
|
|
2163
2490
|
}
|
|
2164
|
-
if (typeof value === "object" && value !== null
|
|
2165
|
-
|
|
2166
|
-
if (matchValue(match.type,
|
|
2167
|
-
|
|
2491
|
+
if (typeof value === "object" && value !== null) {
|
|
2492
|
+
if (WHOLE_VALUE_MATCH_TYPES.has(match.type)) {
|
|
2493
|
+
if (matchValue(match.type, value, match.value, match)) return true;
|
|
2494
|
+
} else {
|
|
2495
|
+
for (const leaf of collectLeafValues(value)) {
|
|
2496
|
+
if (matchValue(match.type, leaf, match.value, match)) return true;
|
|
2168
2497
|
}
|
|
2169
2498
|
}
|
|
2170
2499
|
continue;
|
|
@@ -2196,22 +2525,27 @@ var RuleEngine = class {
|
|
|
2196
2525
|
};
|
|
2197
2526
|
|
|
2198
2527
|
// src/protect/engine/pulse-client.js
|
|
2528
|
+
init_pulse_token();
|
|
2199
2529
|
var DEFAULT_BASE_URL2 = "https://api.patchstack.com/monitor/pulse";
|
|
2200
2530
|
var DEFAULT_CACHE_TTL2 = 3e5;
|
|
2201
2531
|
var JITTER_FRACTION2 = 0.1;
|
|
2202
2532
|
var PulseRuleClient = class {
|
|
2203
2533
|
#siteUuid;
|
|
2534
|
+
#timeoutMs;
|
|
2204
2535
|
#baseUrl;
|
|
2205
2536
|
#cacheTtl;
|
|
2206
2537
|
#cache = null;
|
|
2207
2538
|
#cacheTime = null;
|
|
2208
2539
|
#ttlEffective = 0;
|
|
2209
2540
|
#etag;
|
|
2210
|
-
|
|
2541
|
+
#pulseAuth;
|
|
2542
|
+
constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth } = {}) {
|
|
2543
|
+
this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
|
|
2211
2544
|
this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
|
|
2212
|
-
this.#baseUrl = baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL
|
|
2545
|
+
this.#baseUrl = safeBaseUrl(baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL, DEFAULT_BASE_URL2, "rule endpoint");
|
|
2213
2546
|
this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL2;
|
|
2214
2547
|
this.#etag = etag ?? null;
|
|
2548
|
+
this.#pulseAuth = pulseAuth ?? null;
|
|
2215
2549
|
if (!this.#siteUuid) {
|
|
2216
2550
|
throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
|
|
2217
2551
|
}
|
|
@@ -2223,9 +2557,15 @@ var PulseRuleClient = class {
|
|
|
2223
2557
|
}
|
|
2224
2558
|
const url = `${this.#baseUrl}/rules/${encodeURIComponent(this.#siteUuid)}`;
|
|
2225
2559
|
try {
|
|
2226
|
-
const headers = {
|
|
2560
|
+
const headers = {
|
|
2561
|
+
Accept: "application/json",
|
|
2562
|
+
...await pulseAuthHeader(
|
|
2563
|
+
{ pulseAuth: this.#pulseAuth, endpoint: this.#baseUrl, timeoutMs: this.#timeoutMs },
|
|
2564
|
+
fetch
|
|
2565
|
+
)
|
|
2566
|
+
};
|
|
2227
2567
|
if (this.#etag) headers["If-None-Match"] = this.#etag;
|
|
2228
|
-
const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(
|
|
2568
|
+
const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(this.#timeoutMs) });
|
|
2229
2569
|
if (response.status === 304) {
|
|
2230
2570
|
this.#touch(now);
|
|
2231
2571
|
return this.#cache ?? { success: true, notModified: true, etag: this.#etag, firewall: [], whitelists: [], whitelist_keys: {} };
|
|
@@ -2302,25 +2642,9 @@ async function fromFetchRequest(request, options = {}) {
|
|
|
2302
2642
|
let body = {};
|
|
2303
2643
|
let files;
|
|
2304
2644
|
if (rawBody) {
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
} catch {
|
|
2309
|
-
body = {};
|
|
2310
|
-
}
|
|
2311
|
-
} else if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
2312
|
-
body = {};
|
|
2313
|
-
for (const [k, v] of new URLSearchParams(rawBody)) {
|
|
2314
|
-
body[k] = k in body ? [].concat(body[k], v) : v;
|
|
2315
|
-
}
|
|
2316
|
-
} else if (contentType.includes("multipart/form-data")) {
|
|
2317
|
-
const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
|
|
2318
|
-
if (boundary) {
|
|
2319
|
-
const parsed = parseMultipart(rawBody, boundary);
|
|
2320
|
-
body = parsed.body;
|
|
2321
|
-
files = parsed.files;
|
|
2322
|
-
}
|
|
2323
|
-
}
|
|
2645
|
+
const parsed = parseBody(rawBody, contentType);
|
|
2646
|
+
body = parsed.body;
|
|
2647
|
+
files = parsed.files;
|
|
2324
2648
|
}
|
|
2325
2649
|
const uri = url.pathname + url.search;
|
|
2326
2650
|
const forwarded = headers["cf-connecting-ip"] || headers["x-forwarded-for"] || headers["x-real-ip"] || "";
|
|
@@ -2340,9 +2664,6 @@ async function fromFetchRequest(request, options = {}) {
|
|
|
2340
2664
|
};
|
|
2341
2665
|
}
|
|
2342
2666
|
async function readCappedText(request, max) {
|
|
2343
|
-
const ceiling = Math.max(max, max * 4);
|
|
2344
|
-
const declared = Number(request.headers?.get?.("content-length") || 0);
|
|
2345
|
-
if (declared && declared > ceiling) return "";
|
|
2346
2667
|
let clone;
|
|
2347
2668
|
try {
|
|
2348
2669
|
clone = request.clone();
|
|
@@ -2388,6 +2709,31 @@ function concatChunks(chunks, total) {
|
|
|
2388
2709
|
}
|
|
2389
2710
|
return out;
|
|
2390
2711
|
}
|
|
2712
|
+
function parseBody(rawBody, contentType) {
|
|
2713
|
+
const ct = String(contentType || "").toLowerCase();
|
|
2714
|
+
const isJson = ct.includes("application/json") || /\+json\b/.test(ct);
|
|
2715
|
+
const isForm = ct.includes("application/x-www-form-urlencoded");
|
|
2716
|
+
const isMultipart = ct.includes("multipart/form-data");
|
|
2717
|
+
const isAmbiguous = ct === "" || ct.startsWith("text/plain") || ct.includes("csp-report") || ct.includes("/json");
|
|
2718
|
+
if (isMultipart) {
|
|
2719
|
+
const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
|
|
2720
|
+
if (boundary) return parseMultipart(rawBody, boundary);
|
|
2721
|
+
return { body: {}, files: void 0 };
|
|
2722
|
+
}
|
|
2723
|
+
if (isForm) {
|
|
2724
|
+
const body = {};
|
|
2725
|
+
for (const [k, v] of new URLSearchParams(rawBody)) body[k] = k in body ? [].concat(body[k], v) : v;
|
|
2726
|
+
return { body, files: void 0 };
|
|
2727
|
+
}
|
|
2728
|
+
if (isJson || isAmbiguous) {
|
|
2729
|
+
try {
|
|
2730
|
+
const parsed = JSON.parse(rawBody);
|
|
2731
|
+
if (parsed && typeof parsed === "object") return { body: parsed, files: void 0 };
|
|
2732
|
+
} catch {
|
|
2733
|
+
}
|
|
2734
|
+
}
|
|
2735
|
+
return { body: {}, files: void 0 };
|
|
2736
|
+
}
|
|
2391
2737
|
function parseMultipart(rawBody, boundary) {
|
|
2392
2738
|
const body = {};
|
|
2393
2739
|
const files = {};
|
|
@@ -2402,7 +2748,9 @@ function parseMultipart(rawBody, boundary) {
|
|
|
2402
2748
|
const content = part.slice(sep.index + sep[0].length).replace(/\r?\n$/, "");
|
|
2403
2749
|
const filename = /filename="([^"]*)"/i.exec(disposition)?.[1];
|
|
2404
2750
|
if (filename !== void 0) {
|
|
2405
|
-
|
|
2751
|
+
const partType = /content-type:\s*([^\r\n;]+)/i.exec(rawHeaders)?.[1]?.trim() || "";
|
|
2752
|
+
const file = { filename, type: partType, content };
|
|
2753
|
+
files[name] = name in files ? [].concat(files[name], file) : file;
|
|
2406
2754
|
} else {
|
|
2407
2755
|
body[name] = name in body ? [].concat(body[name], content) : content;
|
|
2408
2756
|
}
|
|
@@ -2454,25 +2802,9 @@ function fromNodeRequest(req, rawBody = "") {
|
|
|
2454
2802
|
let body = {};
|
|
2455
2803
|
let files;
|
|
2456
2804
|
if (rawBody) {
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
} catch {
|
|
2461
|
-
body = {};
|
|
2462
|
-
}
|
|
2463
|
-
} else if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
2464
|
-
body = {};
|
|
2465
|
-
for (const [k, v] of new URLSearchParams(rawBody)) {
|
|
2466
|
-
body[k] = k in body ? [].concat(body[k], v) : v;
|
|
2467
|
-
}
|
|
2468
|
-
} else if (contentType.includes("multipart/form-data")) {
|
|
2469
|
-
const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
|
|
2470
|
-
if (boundary) {
|
|
2471
|
-
const parsed = parseMultipart(rawBody, boundary);
|
|
2472
|
-
body = parsed.body;
|
|
2473
|
-
files = parsed.files;
|
|
2474
|
-
}
|
|
2475
|
-
}
|
|
2805
|
+
const parsed = parseBody(rawBody, contentType);
|
|
2806
|
+
body = parsed.body;
|
|
2807
|
+
files = parsed.files;
|
|
2476
2808
|
}
|
|
2477
2809
|
const uri = url.pathname + url.search;
|
|
2478
2810
|
const forwarded = headers["cf-connecting-ip"] || headers["x-forwarded-for"] || headers["x-real-ip"] || "";
|
|
@@ -2506,7 +2838,7 @@ function parseCookies2(header) {
|
|
|
2506
2838
|
}
|
|
2507
2839
|
|
|
2508
2840
|
// src/protect/egress.js
|
|
2509
|
-
async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, lookup, allowHosts } = {}) {
|
|
2841
|
+
async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = true, lookup, allowHosts } = {}) {
|
|
2510
2842
|
const restores = [];
|
|
2511
2843
|
if (typeof shouldBlock !== "function") return () => {
|
|
2512
2844
|
};
|
|
@@ -2528,11 +2860,24 @@ async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, look
|
|
|
2528
2860
|
screen = null;
|
|
2529
2861
|
}
|
|
2530
2862
|
}
|
|
2863
|
+
const skip = (reason, detail) => {
|
|
2864
|
+
try {
|
|
2865
|
+
onSkip?.({ phase: "egress", reason, detail });
|
|
2866
|
+
} catch {
|
|
2867
|
+
}
|
|
2868
|
+
};
|
|
2531
2869
|
const resolvesToDisallowed = (url, host, method) => new Promise((resolve) => {
|
|
2532
|
-
if (!screen
|
|
2870
|
+
if (!screen) {
|
|
2871
|
+
if (host && dnsScreen) skip("resolver-unavailable", { host });
|
|
2872
|
+
return resolve(false);
|
|
2873
|
+
}
|
|
2874
|
+
if (!host || screen.isIP(host) !== 0 || screen.isExempt(host)) return resolve(false);
|
|
2533
2875
|
try {
|
|
2534
2876
|
screen.lookup(host, { all: true }, (err, addresses) => {
|
|
2535
|
-
if (err || !Array.isArray(addresses))
|
|
2877
|
+
if (err || !Array.isArray(addresses)) {
|
|
2878
|
+
skip("resolver-failed", { host });
|
|
2879
|
+
return resolve(false);
|
|
2880
|
+
}
|
|
2536
2881
|
for (const a of addresses) {
|
|
2537
2882
|
const ip = a && typeof a === "object" ? a.address : a;
|
|
2538
2883
|
if (ip && block(url, ip, method)) return resolve(true);
|
|
@@ -2540,24 +2885,61 @@ async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, look
|
|
|
2540
2885
|
resolve(false);
|
|
2541
2886
|
});
|
|
2542
2887
|
} catch {
|
|
2888
|
+
skip("resolver-failed", { host });
|
|
2543
2889
|
resolve(false);
|
|
2544
2890
|
}
|
|
2545
2891
|
});
|
|
2546
2892
|
const originalFetch = globalThis.fetch;
|
|
2547
2893
|
if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
|
|
2548
|
-
const
|
|
2549
|
-
|
|
2894
|
+
const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
|
|
2895
|
+
const MAX_REDIRECTS = 20;
|
|
2896
|
+
const screenUrl = async (u, method) => {
|
|
2550
2897
|
let host = null;
|
|
2551
2898
|
try {
|
|
2552
|
-
host = new URL(
|
|
2899
|
+
host = new URL(u).hostname;
|
|
2553
2900
|
} catch {
|
|
2554
2901
|
host = null;
|
|
2555
2902
|
}
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2903
|
+
if (block(u, host, method) || await resolvesToDisallowed(u, host, method)) {
|
|
2904
|
+
throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? u}`);
|
|
2905
|
+
}
|
|
2906
|
+
};
|
|
2907
|
+
const guarded = async (input, init) => {
|
|
2908
|
+
let cur;
|
|
2909
|
+
try {
|
|
2910
|
+
cur = new Request(input, { ...init || {}, redirect: "manual" });
|
|
2911
|
+
} catch {
|
|
2912
|
+
return originalFetch(input, init);
|
|
2913
|
+
}
|
|
2914
|
+
const callerRedirect = init && init.redirect || input && input.redirect || "follow";
|
|
2915
|
+
let url = cur.url;
|
|
2916
|
+
let method = cur.method;
|
|
2917
|
+
await screenUrl(url, method);
|
|
2918
|
+
if (callerRedirect !== "follow") return originalFetch(input, init);
|
|
2919
|
+
const headers = new Headers(cur.headers);
|
|
2920
|
+
const signal = cur.signal;
|
|
2921
|
+
let body = method === "GET" || method === "HEAD" ? void 0 : await cur.clone().arrayBuffer();
|
|
2922
|
+
for (let hop = 0; ; hop++) {
|
|
2923
|
+
const resp = await originalFetch(
|
|
2924
|
+
hop === 0 ? cur : new Request(url, { method, headers, body, redirect: "manual", signal })
|
|
2925
|
+
);
|
|
2926
|
+
const location = REDIRECT_STATUSES.has(resp.status) ? resp.headers.get("location") : null;
|
|
2927
|
+
if (!location) return resp;
|
|
2928
|
+
if (hop >= MAX_REDIRECTS) throw new Error("Patchstack blocked an outbound request: too many redirects");
|
|
2929
|
+
const next = new URL(location, url).href;
|
|
2930
|
+
if (resp.status === 303 || (resp.status === 301 || resp.status === 302) && method === "POST") {
|
|
2931
|
+
method = "GET";
|
|
2932
|
+
body = void 0;
|
|
2933
|
+
headers.delete("content-type");
|
|
2934
|
+
headers.delete("content-length");
|
|
2935
|
+
}
|
|
2936
|
+
if (new URL(next).origin !== new URL(url).origin) {
|
|
2937
|
+
headers.delete("authorization");
|
|
2938
|
+
headers.delete("cookie");
|
|
2939
|
+
}
|
|
2940
|
+
await screenUrl(next, method);
|
|
2941
|
+
url = next;
|
|
2559
2942
|
}
|
|
2560
|
-
return originalFetch(input, init);
|
|
2561
2943
|
};
|
|
2562
2944
|
guarded.__patchstackGuarded = true;
|
|
2563
2945
|
globalThis.fetch = guarded;
|
|
@@ -2573,30 +2955,6 @@ async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, look
|
|
|
2573
2955
|
} catch {
|
|
2574
2956
|
}
|
|
2575
2957
|
}
|
|
2576
|
-
const OriginalWS = globalThis.WebSocket;
|
|
2577
|
-
if (typeof OriginalWS === "function" && !OriginalWS.__patchstackGuarded) {
|
|
2578
|
-
const GuardedWS = new Proxy(OriginalWS, {
|
|
2579
|
-
construct(target, args, newTarget) {
|
|
2580
|
-
const url = String(args?.[0] ?? "");
|
|
2581
|
-
let host = null;
|
|
2582
|
-
try {
|
|
2583
|
-
host = new URL(url).hostname;
|
|
2584
|
-
} catch {
|
|
2585
|
-
host = null;
|
|
2586
|
-
}
|
|
2587
|
-
if (block(url, host, "WEBSOCKET")) {
|
|
2588
|
-
throw new Error(`Patchstack blocked an outbound WebSocket to a disallowed address: ${host ?? url}`);
|
|
2589
|
-
}
|
|
2590
|
-
return Reflect.construct(target, args, newTarget);
|
|
2591
|
-
}
|
|
2592
|
-
});
|
|
2593
|
-
OriginalWS.__patchstackGuarded = true;
|
|
2594
|
-
globalThis.WebSocket = GuardedWS;
|
|
2595
|
-
restores.push(() => {
|
|
2596
|
-
if (globalThis.WebSocket === GuardedWS) globalThis.WebSocket = OriginalWS;
|
|
2597
|
-
delete OriginalWS.__patchstackGuarded;
|
|
2598
|
-
});
|
|
2599
|
-
}
|
|
2600
2958
|
return () => {
|
|
2601
2959
|
for (const restore of restores) {
|
|
2602
2960
|
try {
|
|
@@ -2727,6 +3085,7 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
2727
3085
|
phase: "response",
|
|
2728
3086
|
category: "secret-exposure",
|
|
2729
3087
|
action: "redact",
|
|
3088
|
+
prefilter: ["PRIVATE KEY"],
|
|
2730
3089
|
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/" } }]
|
|
2731
3090
|
},
|
|
2732
3091
|
{
|
|
@@ -2735,6 +3094,7 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
2735
3094
|
phase: "response",
|
|
2736
3095
|
category: "secret-exposure",
|
|
2737
3096
|
action: "redact",
|
|
3097
|
+
prefilter: ["AKIA", "ASIA"],
|
|
2738
3098
|
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b/" } }]
|
|
2739
3099
|
},
|
|
2740
3100
|
{
|
|
@@ -2743,14 +3103,29 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
2743
3103
|
phase: "response",
|
|
2744
3104
|
category: "secret-exposure",
|
|
2745
3105
|
action: "redact",
|
|
3106
|
+
prefilter: ["AIza"],
|
|
2746
3107
|
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\bAIza[0-9A-Za-z_-]{35}\\b/" } }]
|
|
2747
3108
|
},
|
|
3109
|
+
{
|
|
3110
|
+
id: "resp-vendor-api-key",
|
|
3111
|
+
title: "Vendor API key / token in response body",
|
|
3112
|
+
phase: "response",
|
|
3113
|
+
category: "secret-exposure",
|
|
3114
|
+
action: "redact",
|
|
3115
|
+
// High-signal, prefix-anchored provider tokens that never legitimately appear in a response
|
|
3116
|
+
// body: Stripe (sk_live_/rk_live_), GitHub (gh[opsu]_ / github_pat_), GitLab (glpat-),
|
|
3117
|
+
// Slack (xox[baprs]-), Anthropic (sk-ant-), Google OAuth (ya29.), npm (npm_). Trailing
|
|
3118
|
+
// (?![0-9A-Za-z]) instead of \\b since some tokens end in - / _ .
|
|
3119
|
+
prefilter: ["sk_live_", "rk_live_", "ghp_", "gho_", "ghs_", "ghu_", "github_pat_", "glpat-", "xox", "sk-ant-", "ya29.", "npm_"],
|
|
3120
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:sk_live_[0-9A-Za-z]{16,}|rk_live_[0-9A-Za-z]{16,}|gh[opsu]_[0-9A-Za-z]{36}|github_pat_[0-9A-Za-z_]{60,}|glpat-[0-9A-Za-z_-]{20,}|xox[baprs]-[0-9A-Za-z-]{10,}|sk-ant-[0-9A-Za-z_-]{20,}|ya29\\.[0-9A-Za-z_-]{20,}|npm_[0-9A-Za-z]{36})(?![0-9A-Za-z])/" } }]
|
|
3121
|
+
},
|
|
2748
3122
|
{
|
|
2749
3123
|
id: "resp-jwt",
|
|
2750
3124
|
title: "JWT in response body",
|
|
2751
3125
|
phase: "response",
|
|
2752
3126
|
category: "secret-exposure",
|
|
2753
3127
|
action: "redact",
|
|
3128
|
+
prefilter: ["eyJ"],
|
|
2754
3129
|
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\b/" } }]
|
|
2755
3130
|
},
|
|
2756
3131
|
{
|
|
@@ -2759,6 +3134,7 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
2759
3134
|
phase: "response",
|
|
2760
3135
|
category: "secret-exposure",
|
|
2761
3136
|
action: "redact",
|
|
3137
|
+
prefilter: ["mongodb", "postgres", "mysql", "redis", "amqp"],
|
|
2762
3138
|
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[^\\s:@\\/]+:[^\\s:@\\/]+@/i" } }]
|
|
2763
3139
|
},
|
|
2764
3140
|
{
|
|
@@ -2767,6 +3143,8 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
2767
3143
|
phase: "response",
|
|
2768
3144
|
category: "info-exposure",
|
|
2769
3145
|
action: "redact",
|
|
3146
|
+
// No prefilter: a Node stack frame has no single distinctive literal (` at ` is too common to
|
|
3147
|
+
// gate on). The pattern is linearly bounded per line, so it runs on every screened body.
|
|
2770
3148
|
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\n\\s+at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
|
|
2771
3149
|
},
|
|
2772
3150
|
{
|
|
@@ -2775,6 +3153,7 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
2775
3153
|
phase: "response",
|
|
2776
3154
|
category: "info-exposure",
|
|
2777
3155
|
action: "redact",
|
|
3156
|
+
prefilter: ["SQLSTATE", "Sequelize", "ER_", "ORA-", "PG::", "SQLITE_ERROR", "SQL syntax"],
|
|
2778
3157
|
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/(SQLSTATE\\[[0-9A-Z]+\\]|SequelizeDatabaseError|ER_[A-Z_]+|ORA-\\d{5}|PG::[A-Za-z]+Error|SQLITE_ERROR|You have an error in your SQL syntax)/i" } }]
|
|
2779
3158
|
},
|
|
2780
3159
|
{
|
|
@@ -2786,6 +3165,7 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
2786
3165
|
// Multi-language exception/traceback signatures a normal API response never carries:
|
|
2787
3166
|
// Python traceback, Java "Exception in thread", .NET System.*Exception, JVM stack frames,
|
|
2788
3167
|
// Go goroutine dumps. (Node `at fn (file:line:col)` frames are handled by resp-stack-trace.)
|
|
3168
|
+
prefilter: ["Traceback", "Exception in thread", "System.", "goroutine ", ".java:", ".kt:", ".scala:", ".rb:", ".py:", ".cs:"],
|
|
2789
3169
|
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: '/(Traceback \\(most recent call last\\)|Exception in thread "|System\\.[A-Za-z.]+Exception|\\bat [\\w.$]+\\([\\w]+\\.(?:java|kt|scala|rb|py|cs):\\d+\\)|goroutine \\d+ \\[)/' } }]
|
|
2790
3170
|
}
|
|
2791
3171
|
];
|
|
@@ -2883,8 +3263,22 @@ function renderBlockPage({ url = "", code = "patchstack" } = {}) {
|
|
|
2883
3263
|
}
|
|
2884
3264
|
|
|
2885
3265
|
// src/protect/rules/store.js
|
|
2886
|
-
var
|
|
2887
|
-
|
|
3266
|
+
var fsMod;
|
|
3267
|
+
async function loadFs() {
|
|
3268
|
+
if (fsMod !== void 0) return fsMod;
|
|
3269
|
+
try {
|
|
3270
|
+
const [fs, path7] = await Promise.all([import("fs"), import("path")]);
|
|
3271
|
+
fsMod = {
|
|
3272
|
+
readFileSync: fs.readFileSync,
|
|
3273
|
+
writeFileSync: fs.writeFileSync,
|
|
3274
|
+
mkdirSync: fs.mkdirSync,
|
|
3275
|
+
join: path7.join
|
|
3276
|
+
};
|
|
3277
|
+
} catch {
|
|
3278
|
+
fsMod = null;
|
|
3279
|
+
}
|
|
3280
|
+
return fsMod;
|
|
3281
|
+
}
|
|
2888
3282
|
function makeStore(options = {}) {
|
|
2889
3283
|
let mem = null;
|
|
2890
3284
|
const durable = durableTier(options);
|
|
@@ -2926,21 +3320,22 @@ function durableTier(options) {
|
|
|
2926
3320
|
write: async (env) => cacheWrite(dir, env)
|
|
2927
3321
|
};
|
|
2928
3322
|
}
|
|
2929
|
-
function
|
|
2930
|
-
return (0, import_node_path.join)(dir, "patchstack-rules.json");
|
|
2931
|
-
}
|
|
2932
|
-
function cacheWrite(dir, env) {
|
|
3323
|
+
async function cacheWrite(dir, env) {
|
|
2933
3324
|
if (!dir) return;
|
|
3325
|
+
const fs = await loadFs();
|
|
3326
|
+
if (!fs) return;
|
|
2934
3327
|
try {
|
|
2935
|
-
|
|
2936
|
-
|
|
3328
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
3329
|
+
fs.writeFileSync(fs.join(dir, "patchstack-rules.json"), JSON.stringify(env));
|
|
2937
3330
|
} catch {
|
|
2938
3331
|
}
|
|
2939
3332
|
}
|
|
2940
|
-
function cacheRead(dir) {
|
|
3333
|
+
async function cacheRead(dir) {
|
|
2941
3334
|
if (!dir) return null;
|
|
3335
|
+
const fs = await loadFs();
|
|
3336
|
+
if (!fs) return null;
|
|
2942
3337
|
try {
|
|
2943
|
-
return toEnvelope(JSON.parse(
|
|
3338
|
+
return toEnvelope(JSON.parse(fs.readFileSync(fs.join(dir, "patchstack-rules.json"), "utf8")));
|
|
2944
3339
|
} catch {
|
|
2945
3340
|
return null;
|
|
2946
3341
|
}
|
|
@@ -2956,56 +3351,204 @@ function toEnvelope(value) {
|
|
|
2956
3351
|
return null;
|
|
2957
3352
|
}
|
|
2958
3353
|
|
|
3354
|
+
// src/protect/rules/validate.js
|
|
3355
|
+
var LIMITS = {
|
|
3356
|
+
maxRules: 5e3,
|
|
3357
|
+
maxWhitelists: 2e3,
|
|
3358
|
+
maxConditionsPerRule: 250,
|
|
3359
|
+
maxNestingDepth: 12,
|
|
3360
|
+
maxRegexLength: 1e3,
|
|
3361
|
+
maxValueLength: 8192
|
|
3362
|
+
};
|
|
3363
|
+
var PHASES = /* @__PURE__ */ new Set(["request", "response", "egress"]);
|
|
3364
|
+
var ACTIONS = /* @__PURE__ */ new Set(["block", "redact", "encode", "set-header", "remove-header", "harden-cookie"]);
|
|
3365
|
+
function validateBundle(bundle, opts = {}) {
|
|
3366
|
+
const rejected = [];
|
|
3367
|
+
const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
|
|
3368
|
+
const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
|
|
3369
|
+
const firewall = [];
|
|
3370
|
+
for (const rule of inFirewall) {
|
|
3371
|
+
if (firewall.length >= LIMITS.maxRules) {
|
|
3372
|
+
rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS.maxRules})` });
|
|
3373
|
+
continue;
|
|
3374
|
+
}
|
|
3375
|
+
const reason = ruleProblem(rule);
|
|
3376
|
+
if (reason) rejected.push({ id: idOf(rule), reason });
|
|
3377
|
+
else firewall.push(rule);
|
|
3378
|
+
}
|
|
3379
|
+
const whitelists = [];
|
|
3380
|
+
for (const wl of inWhitelists) {
|
|
3381
|
+
if (whitelists.length >= LIMITS.maxWhitelists) {
|
|
3382
|
+
rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS.maxWhitelists})` });
|
|
3383
|
+
continue;
|
|
3384
|
+
}
|
|
3385
|
+
if (!opts.allowGlobalWhitelists && wl && Array.isArray(wl.rule_v2) && !wl.rule_id) {
|
|
3386
|
+
rejected.push({ id: idOf(wl), reason: "whitelist has no rule_id (would suppress every rule); set allowGlobalWhitelists to permit" });
|
|
3387
|
+
continue;
|
|
3388
|
+
}
|
|
3389
|
+
const reason = conditionsProblem(wl?.rule_v2);
|
|
3390
|
+
if (reason) rejected.push({ id: idOf(wl), reason: `whitelist: ${reason}` });
|
|
3391
|
+
else whitelists.push(wl);
|
|
3392
|
+
}
|
|
3393
|
+
return {
|
|
3394
|
+
bundle: { ...bundle, firewall, whitelists },
|
|
3395
|
+
rejected
|
|
3396
|
+
};
|
|
3397
|
+
}
|
|
3398
|
+
function idOf(rule) {
|
|
3399
|
+
const id = rule?.id ?? rule?.rule_id;
|
|
3400
|
+
return id === void 0 || id === null ? "(unidentified)" : String(id);
|
|
3401
|
+
}
|
|
3402
|
+
function ruleProblem(rule) {
|
|
3403
|
+
if (!rule || typeof rule !== "object") return "not an object";
|
|
3404
|
+
if (rule.phase !== void 0 && !PHASES.has(rule.phase)) return `unknown phase "${rule.phase}"`;
|
|
3405
|
+
if (rule.action !== void 0 && !ACTIONS.has(rule.action)) return `unknown action "${rule.action}"`;
|
|
3406
|
+
const capOverride = rule.max_bytes;
|
|
3407
|
+
if (capOverride !== void 0 && !(Number(capOverride) > 0)) return "max_bytes must be a positive number";
|
|
3408
|
+
return conditionsProblem(rule.rule_v2);
|
|
3409
|
+
}
|
|
3410
|
+
function conditionsProblem(conditions, depth = 0) {
|
|
3411
|
+
if (!Array.isArray(conditions)) return "rule_v2 must be an array of conditions";
|
|
3412
|
+
if (conditions.length === 0) return "rule_v2 is empty (would never match)";
|
|
3413
|
+
if (depth > LIMITS.maxNestingDepth) return `nesting deeper than ${LIMITS.maxNestingDepth}`;
|
|
3414
|
+
if (conditions.length > LIMITS.maxConditionsPerRule) {
|
|
3415
|
+
return `more than ${LIMITS.maxConditionsPerRule} conditions`;
|
|
3416
|
+
}
|
|
3417
|
+
for (const c of conditions) {
|
|
3418
|
+
if (!c || typeof c !== "object") return "condition is not an object";
|
|
3419
|
+
if (Array.isArray(c.rules)) {
|
|
3420
|
+
const nested = conditionsProblem(c.rules, depth + 1);
|
|
3421
|
+
if (nested) return nested;
|
|
3422
|
+
continue;
|
|
3423
|
+
}
|
|
3424
|
+
const m = c.match;
|
|
3425
|
+
if (!m || typeof m !== "object") return "condition has no match object";
|
|
3426
|
+
if (typeof m.type !== "string" || m.type === "") return "match.type must be a non-empty string";
|
|
3427
|
+
if (m.type === "regex") {
|
|
3428
|
+
if (typeof m.value !== "string") return "regex match.value must be a string";
|
|
3429
|
+
if (m.value.length > LIMITS.maxRegexLength) return `regex longer than ${LIMITS.maxRegexLength} chars`;
|
|
3430
|
+
} else if (typeof m.value === "string" && m.value.length > LIMITS.maxValueLength) {
|
|
3431
|
+
return `match.value longer than ${LIMITS.maxValueLength} chars`;
|
|
3432
|
+
}
|
|
3433
|
+
if (m.match) {
|
|
3434
|
+
const nested = conditionsProblem([{ match: m.match }], depth + 1);
|
|
3435
|
+
if (nested) return nested;
|
|
3436
|
+
}
|
|
3437
|
+
}
|
|
3438
|
+
return null;
|
|
3439
|
+
}
|
|
3440
|
+
|
|
2959
3441
|
// src/protect/rules/source.js
|
|
2960
|
-
|
|
3442
|
+
function liveUpdateRejections(res, options) {
|
|
3443
|
+
if (options.acceptPartialBundle) return [];
|
|
3444
|
+
const { rejected } = validateBundle(
|
|
3445
|
+
{ firewall: Array.isArray(res.firewall) ? res.firewall : [], whitelists: Array.isArray(res.whitelists) ? res.whitelists : [] },
|
|
3446
|
+
{ allowGlobalWhitelists: options.allowGlobalWhitelists }
|
|
3447
|
+
);
|
|
3448
|
+
return rejected;
|
|
3449
|
+
}
|
|
3450
|
+
function reportRejections(rejected, options, label) {
|
|
3451
|
+
const report = options.onRuleRejected;
|
|
3452
|
+
for (const r of rejected) {
|
|
3453
|
+
if (typeof report === "function") {
|
|
3454
|
+
try {
|
|
3455
|
+
report({ ...r, accepted: false });
|
|
3456
|
+
} catch {
|
|
3457
|
+
}
|
|
3458
|
+
}
|
|
3459
|
+
}
|
|
3460
|
+
const sample = rejected.slice(0, 3).map((r) => `${r.id} (${r.reason})`).join("; ");
|
|
3461
|
+
options.onError?.(new Error(
|
|
3462
|
+
`${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
|
+
));
|
|
3464
|
+
}
|
|
3465
|
+
async function resolveRules(options, store, ctx = {}) {
|
|
3466
|
+
const timeoutMs = ctx.timeoutMs;
|
|
2961
3467
|
if (options.siteUuid) {
|
|
2962
3468
|
const prior = await store.read();
|
|
2963
|
-
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag });
|
|
3469
|
+
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth });
|
|
2964
3470
|
const res = await client.getRules();
|
|
2965
|
-
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle);
|
|
3471
|
+
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
2966
3472
|
if (res.success && !res.notModified) {
|
|
2967
|
-
const
|
|
3473
|
+
const rejected = liveUpdateRejections(res, options);
|
|
3474
|
+
if (rejected.length > 0) {
|
|
3475
|
+
reportRejections(rejected, options, "rule update rejected");
|
|
3476
|
+
if (prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
3477
|
+
if (options.rules) return normalizeBundle(options.rules, options);
|
|
3478
|
+
return emptyBundle();
|
|
3479
|
+
}
|
|
3480
|
+
const bundle = normalizeBundle(res, options);
|
|
2968
3481
|
await store.write({ bundle, etag: res.etag ?? null });
|
|
2969
3482
|
return bundle;
|
|
2970
3483
|
}
|
|
2971
3484
|
if (prior?.bundle) {
|
|
2972
3485
|
options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
|
|
2973
|
-
return normalizeBundle(prior.bundle);
|
|
3486
|
+
return normalizeBundle(prior.bundle, options);
|
|
2974
3487
|
}
|
|
2975
3488
|
if (options.rules) {
|
|
2976
3489
|
options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`));
|
|
2977
|
-
return normalizeBundle(options.rules);
|
|
3490
|
+
return normalizeBundle(options.rules, options);
|
|
2978
3491
|
}
|
|
2979
3492
|
options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
|
|
2980
3493
|
return emptyBundle();
|
|
2981
3494
|
}
|
|
2982
3495
|
if (options.token) {
|
|
2983
3496
|
const prior = await store.read();
|
|
2984
|
-
const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag });
|
|
3497
|
+
const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs });
|
|
2985
3498
|
const res = await client.getRules();
|
|
2986
|
-
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle);
|
|
3499
|
+
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
2987
3500
|
if (res.success && !res.notModified) {
|
|
2988
|
-
const
|
|
3501
|
+
const rejected = liveUpdateRejections(res, options);
|
|
3502
|
+
if (rejected.length > 0) {
|
|
3503
|
+
reportRejections(rejected, options, "rule update rejected");
|
|
3504
|
+
if (prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
3505
|
+
if (options.rules) return normalizeBundle(options.rules, options);
|
|
3506
|
+
return emptyBundle();
|
|
3507
|
+
}
|
|
3508
|
+
const bundle = normalizeBundle(res, options);
|
|
2989
3509
|
await store.write({ bundle, etag: res.etag ?? null });
|
|
2990
3510
|
return bundle;
|
|
2991
3511
|
}
|
|
2992
3512
|
if (prior?.bundle) {
|
|
2993
3513
|
options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
|
|
2994
|
-
return normalizeBundle(prior.bundle);
|
|
3514
|
+
return normalizeBundle(prior.bundle, options);
|
|
2995
3515
|
}
|
|
2996
3516
|
options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
|
|
2997
3517
|
return emptyBundle();
|
|
2998
3518
|
}
|
|
2999
3519
|
if (options.rules) {
|
|
3000
|
-
return normalizeBundle(options.rules);
|
|
3520
|
+
return normalizeBundle(options.rules, options);
|
|
3001
3521
|
}
|
|
3002
3522
|
return emptyBundle();
|
|
3003
3523
|
}
|
|
3004
|
-
function normalizeBundle(b) {
|
|
3524
|
+
function normalizeBundle(b, options = {}) {
|
|
3005
3525
|
const enforcement = b?.enforcement ?? b?.mode;
|
|
3526
|
+
const { bundle: checked, rejected } = validateBundle(
|
|
3527
|
+
{
|
|
3528
|
+
firewall: Array.isArray(b.firewall) ? b.firewall : [],
|
|
3529
|
+
whitelists: Array.isArray(b.whitelists) ? b.whitelists : []
|
|
3530
|
+
},
|
|
3531
|
+
{ allowGlobalWhitelists: options.allowGlobalWhitelists }
|
|
3532
|
+
);
|
|
3533
|
+
if (rejected.length > 0) {
|
|
3534
|
+
const report = options.onRuleRejected;
|
|
3535
|
+
if (typeof report === "function") {
|
|
3536
|
+
for (const r of rejected) {
|
|
3537
|
+
try {
|
|
3538
|
+
report(r);
|
|
3539
|
+
} catch {
|
|
3540
|
+
}
|
|
3541
|
+
}
|
|
3542
|
+
} else {
|
|
3543
|
+
const sample = rejected.slice(0, 3).map((r) => `${r.id} (${r.reason})`).join("; ");
|
|
3544
|
+
console.warn(
|
|
3545
|
+
`[patchstack] ${rejected.length} delivered rule(s) rejected as invalid/oversized and are NOT enforced: ${sample}` + (rejected.length > 3 ? ", \u2026" : "")
|
|
3546
|
+
);
|
|
3547
|
+
}
|
|
3548
|
+
}
|
|
3006
3549
|
return {
|
|
3007
|
-
firewall:
|
|
3008
|
-
whitelists:
|
|
3550
|
+
firewall: checked.firewall,
|
|
3551
|
+
whitelists: checked.whitelists,
|
|
3009
3552
|
whitelist_keys: b.whitelist_keys ?? {},
|
|
3010
3553
|
...enforcement === "block" || enforcement === "dry-run" ? { enforcement } : {}
|
|
3011
3554
|
};
|
|
@@ -3068,6 +3611,169 @@ function makeRefreshHandler(tick, secret) {
|
|
|
3068
3611
|
};
|
|
3069
3612
|
}
|
|
3070
3613
|
|
|
3614
|
+
// src/protect/firewall-log.js
|
|
3615
|
+
var DEFAULT_API_BASE = "https://api.patchstack.com";
|
|
3616
|
+
var DEFAULT_FLUSH_MS = 1e3;
|
|
3617
|
+
var MAX_BATCH = 50;
|
|
3618
|
+
var TOKEN_SKEW_MS2 = 6e4;
|
|
3619
|
+
function parseApiKey(apiKey) {
|
|
3620
|
+
if (typeof apiKey !== "string" || apiKey.length === 0) return null;
|
|
3621
|
+
const idx = apiKey.lastIndexOf("-");
|
|
3622
|
+
if (idx <= 0 || idx === apiKey.length - 1) return null;
|
|
3623
|
+
const clientSecret = apiKey.slice(0, idx);
|
|
3624
|
+
const clientId = apiKey.slice(idx + 1);
|
|
3625
|
+
if (!/^\d+$/.test(clientId) || clientSecret.length === 0) return null;
|
|
3626
|
+
return { clientId, clientSecret };
|
|
3627
|
+
}
|
|
3628
|
+
function resolveApiBase(pulseOrManifestUrl) {
|
|
3629
|
+
const fromEnv = typeof process !== "undefined" ? process.env?.PATCHSTACK_API_BASE : void 0;
|
|
3630
|
+
if (typeof fromEnv === "string" && fromEnv.length > 0) {
|
|
3631
|
+
const candidate = fromEnv.replace(/\/$/, "");
|
|
3632
|
+
if (isSafeOrigin(candidate)) return candidate;
|
|
3633
|
+
console.warn(
|
|
3634
|
+
"[patchstack] ignoring PATCHSTACK_API_BASE: block-log reporting requires an https origin (or localhost). Falling back to the default API origin."
|
|
3635
|
+
);
|
|
3636
|
+
}
|
|
3637
|
+
if (typeof pulseOrManifestUrl === "string" && pulseOrManifestUrl.length > 0) {
|
|
3638
|
+
try {
|
|
3639
|
+
return new URL(pulseOrManifestUrl).origin;
|
|
3640
|
+
} catch {
|
|
3641
|
+
}
|
|
3642
|
+
}
|
|
3643
|
+
const endpoint = typeof process !== "undefined" ? process.env?.PATCHSTACK_ENDPOINT : void 0;
|
|
3644
|
+
if (typeof endpoint === "string" && endpoint.length > 0) {
|
|
3645
|
+
try {
|
|
3646
|
+
return new URL(endpoint).origin;
|
|
3647
|
+
} catch {
|
|
3648
|
+
}
|
|
3649
|
+
}
|
|
3650
|
+
return DEFAULT_API_BASE;
|
|
3651
|
+
}
|
|
3652
|
+
function createFirewallLogReporter(opts) {
|
|
3653
|
+
const creds = parseApiKey(opts.apiKey);
|
|
3654
|
+
if (!creds) {
|
|
3655
|
+
return { record() {
|
|
3656
|
+
}, flush() {
|
|
3657
|
+
}, stop() {
|
|
3658
|
+
} };
|
|
3659
|
+
}
|
|
3660
|
+
const apiBase = (opts.apiBase ?? DEFAULT_API_BASE).replace(/\/$/, "");
|
|
3661
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
3662
|
+
const flushMs = Number.isFinite(opts.flushMs) ? opts.flushMs : DEFAULT_FLUSH_MS;
|
|
3663
|
+
const sourceHost = typeof opts.sourceHost === "string" ? opts.sourceHost : "";
|
|
3664
|
+
let queue = [];
|
|
3665
|
+
let timer = null;
|
|
3666
|
+
let stopped = false;
|
|
3667
|
+
let cachedToken = null;
|
|
3668
|
+
let tokenInflight = null;
|
|
3669
|
+
const fetchAccessToken = async () => {
|
|
3670
|
+
if (cachedToken && Date.now() < cachedToken.expiresAt - TOKEN_SKEW_MS2) {
|
|
3671
|
+
return cachedToken.token;
|
|
3672
|
+
}
|
|
3673
|
+
if (tokenInflight) return tokenInflight;
|
|
3674
|
+
tokenInflight = (async () => {
|
|
3675
|
+
try {
|
|
3676
|
+
const res = await fetchImpl(`${apiBase}/oauth/token`, {
|
|
3677
|
+
method: "POST",
|
|
3678
|
+
headers: {
|
|
3679
|
+
"Content-Type": "application/json",
|
|
3680
|
+
Accept: "application/json",
|
|
3681
|
+
"User-Agent": "@patchstack/connect"
|
|
3682
|
+
},
|
|
3683
|
+
body: JSON.stringify({
|
|
3684
|
+
grant_type: "client_credentials",
|
|
3685
|
+
client_id: creds.clientId,
|
|
3686
|
+
client_secret: creds.clientSecret
|
|
3687
|
+
})
|
|
3688
|
+
});
|
|
3689
|
+
if (!res || !res.ok) return null;
|
|
3690
|
+
const body = await res.json();
|
|
3691
|
+
const token = body?.access_token;
|
|
3692
|
+
if (typeof token !== "string" || token.length === 0) return null;
|
|
3693
|
+
const expiresIn = Number(body?.expires_in);
|
|
3694
|
+
const ttlMs = Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1e3 : 36e5;
|
|
3695
|
+
cachedToken = { token, expiresAt: Date.now() + ttlMs };
|
|
3696
|
+
return token;
|
|
3697
|
+
} catch {
|
|
3698
|
+
return null;
|
|
3699
|
+
} finally {
|
|
3700
|
+
tokenInflight = null;
|
|
3701
|
+
}
|
|
3702
|
+
})();
|
|
3703
|
+
return tokenInflight;
|
|
3704
|
+
};
|
|
3705
|
+
const flush = () => {
|
|
3706
|
+
if (timer) {
|
|
3707
|
+
clearTimeout(timer);
|
|
3708
|
+
timer = null;
|
|
3709
|
+
}
|
|
3710
|
+
if (queue.length === 0 || typeof fetchImpl !== "function") return;
|
|
3711
|
+
const batch = queue.splice(0, MAX_BATCH);
|
|
3712
|
+
void (async () => {
|
|
3713
|
+
const token = await fetchAccessToken();
|
|
3714
|
+
if (!token) return;
|
|
3715
|
+
const body = new URLSearchParams();
|
|
3716
|
+
body.set("type", "firewall");
|
|
3717
|
+
body.set("logs", JSON.stringify(batch));
|
|
3718
|
+
try {
|
|
3719
|
+
const p = fetchImpl(`${apiBase}/api/logs/log`, {
|
|
3720
|
+
method: "POST",
|
|
3721
|
+
headers: {
|
|
3722
|
+
Authorization: `Bearer ${token}`,
|
|
3723
|
+
Accept: "application/json",
|
|
3724
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
3725
|
+
"User-Agent": "@patchstack/connect",
|
|
3726
|
+
...sourceHost ? { "Source-Host": sourceHost } : {}
|
|
3727
|
+
},
|
|
3728
|
+
body
|
|
3729
|
+
});
|
|
3730
|
+
if (p && typeof p.then === "function") p.catch(() => {
|
|
3731
|
+
});
|
|
3732
|
+
} catch {
|
|
3733
|
+
}
|
|
3734
|
+
})();
|
|
3735
|
+
};
|
|
3736
|
+
return {
|
|
3737
|
+
/**
|
|
3738
|
+
* @param {{
|
|
3739
|
+
* rule?: { id?: string | number },
|
|
3740
|
+
* method?: string | null,
|
|
3741
|
+
* path?: string | null,
|
|
3742
|
+
* ip?: string | null,
|
|
3743
|
+
* userAgent?: string | null,
|
|
3744
|
+
* }} event
|
|
3745
|
+
*/
|
|
3746
|
+
record(event) {
|
|
3747
|
+
if (stopped) return;
|
|
3748
|
+
const fid = event?.rule?.id;
|
|
3749
|
+
if (fid === void 0 || fid === null || fid === "") return;
|
|
3750
|
+
queue.push({
|
|
3751
|
+
fid,
|
|
3752
|
+
method: event.method ?? null,
|
|
3753
|
+
request_uri: event.path ?? null,
|
|
3754
|
+
ip: event.ip ?? null,
|
|
3755
|
+
user_agent: event.userAgent ?? null,
|
|
3756
|
+
log_date: (/* @__PURE__ */ new Date()).toISOString()
|
|
3757
|
+
});
|
|
3758
|
+
if (queue.length >= MAX_BATCH) {
|
|
3759
|
+
flush();
|
|
3760
|
+
return;
|
|
3761
|
+
}
|
|
3762
|
+
if (!timer) timer = setTimeout(flush, flushMs);
|
|
3763
|
+
},
|
|
3764
|
+
flush,
|
|
3765
|
+
stop() {
|
|
3766
|
+
stopped = true;
|
|
3767
|
+
flush();
|
|
3768
|
+
}
|
|
3769
|
+
};
|
|
3770
|
+
}
|
|
3771
|
+
function telemetryEnabled() {
|
|
3772
|
+
const v = typeof process !== "undefined" ? process.env?.PATCHSTACK_TELEMETRY : void 0;
|
|
3773
|
+
if (v === void 0 || v === "") return true;
|
|
3774
|
+
return !/^(0|false|off|no)$/i.test(String(v));
|
|
3775
|
+
}
|
|
3776
|
+
|
|
3071
3777
|
// src/protect/supabase-guard.js
|
|
3072
3778
|
var GUARD_PATH = "/_patchstack/guard";
|
|
3073
3779
|
var HOP_BY_HOP = /* @__PURE__ */ new Set(["content-encoding", "content-length", "transfer-encoding", "connection"]);
|
|
@@ -3140,9 +3846,30 @@ function createServerFnGuard({ protection }) {
|
|
|
3140
3846
|
}
|
|
3141
3847
|
async function createProtection(options = {}) {
|
|
3142
3848
|
const onError = options.onError;
|
|
3143
|
-
const
|
|
3849
|
+
const userOnDetect = options.onDetect ?? defaultOnDetect;
|
|
3850
|
+
const apiKey = await resolveApiKey(options);
|
|
3851
|
+
const firewallLog = apiKey && telemetryEnabled() && options.reportFirewallLog !== false ? createFirewallLogReporter({
|
|
3852
|
+
apiKey,
|
|
3853
|
+
apiBase: resolveApiBase(options.pulseRulesUrl ?? options.baseUrl),
|
|
3854
|
+
sourceHost: options.sourceHost,
|
|
3855
|
+
fetchImpl: options.fetchImpl
|
|
3856
|
+
}) : null;
|
|
3857
|
+
const onDetect = (detection) => {
|
|
3858
|
+
userOnDetect(detection);
|
|
3859
|
+
if (firewallLog && detection?.mode === "block") {
|
|
3860
|
+
firewallLog.record({
|
|
3861
|
+
rule: detection.rule,
|
|
3862
|
+
method: detection.method,
|
|
3863
|
+
path: detection.path,
|
|
3864
|
+
ip: detection.ip,
|
|
3865
|
+
userAgent: detection.userAgent
|
|
3866
|
+
});
|
|
3867
|
+
}
|
|
3868
|
+
};
|
|
3144
3869
|
const store = makeStore(options);
|
|
3145
|
-
const
|
|
3870
|
+
const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
|
|
3871
|
+
const pulseAuth = await resolvePulseAuth(options);
|
|
3872
|
+
const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth });
|
|
3146
3873
|
let mode = resolveMode(options, bundle);
|
|
3147
3874
|
let requestRules;
|
|
3148
3875
|
let responseRules;
|
|
@@ -3166,39 +3893,84 @@ async function createProtection(options = {}) {
|
|
|
3166
3893
|
responseRuleSet = responseRules.map((rule) => ({
|
|
3167
3894
|
rule,
|
|
3168
3895
|
engine: new RuleEngine({ firewall: [rule], onError }),
|
|
3169
|
-
redactors: rule.action === "redact" || rule.action === "encode" ? extractRedactors(rule) : null
|
|
3896
|
+
redactors: rule.action === "redact" || rule.action === "encode" ? extractRedactors(rule) : null,
|
|
3897
|
+
// A redact/encode condition that carries body-transforming mutations (base64_decode, urldecode,
|
|
3898
|
+
// json_decode, …) detects on the DECODED body but the span redactors run on the RAW body — so
|
|
3899
|
+
// they mask nothing and the secret is served while the log says "redacted". Flag it so screenText
|
|
3900
|
+
// fails such a rule CLOSED (block) instead of serving a no-op redaction.
|
|
3901
|
+
mutatedSpan: (rule.action === "redact" || rule.action === "encode") && hasSpanMutations(rule),
|
|
3902
|
+
// Optional cheap pre-filter: literal anchor(s) that MUST appear for the (expensive) regex to
|
|
3903
|
+
// have any chance of matching. Lets screenText skip the full scan on bodies with no candidate —
|
|
3904
|
+
// the common case — cutting CPU/latency and shrinking the regex/ReDoS surface. Case-insensitive.
|
|
3905
|
+
prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length ? rule.prefilter.map((s) => String(s).toLowerCase()) : null
|
|
3170
3906
|
}));
|
|
3171
3907
|
egressEngine = new RuleEngine({ firewall: egressRules, onError });
|
|
3172
3908
|
};
|
|
3173
3909
|
applyBundle(bundle);
|
|
3910
|
+
const skipCounts = /* @__PURE__ */ Object.create(null);
|
|
3911
|
+
const onSkip = typeof options.onSkip === "function" ? options.onSkip : null;
|
|
3912
|
+
const recordSkip = (phase, reason, detail) => {
|
|
3913
|
+
const key = `${phase}:${reason}`;
|
|
3914
|
+
skipCounts[key] = (skipCounts[key] ?? 0) + 1;
|
|
3915
|
+
if (onSkip) {
|
|
3916
|
+
try {
|
|
3917
|
+
onSkip({ phase, reason, detail, count: skipCounts[key] });
|
|
3918
|
+
} catch {
|
|
3919
|
+
}
|
|
3920
|
+
}
|
|
3921
|
+
};
|
|
3174
3922
|
const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
|
|
3175
|
-
const
|
|
3923
|
+
const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
|
|
3924
|
+
const decide = (phase, result, block, allow2, ctx = {}) => {
|
|
3176
3925
|
if (!result || !result.blocked) return allow2();
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3926
|
+
const effectiveMode = ruleMode(result.rule);
|
|
3927
|
+
onDetect({
|
|
3928
|
+
phase,
|
|
3929
|
+
// The mode this detection was actually handled under, not the site's: a consumer counting blocks
|
|
3930
|
+
// would otherwise over-report, and the whole point of a dry-run rule is that it did not block.
|
|
3931
|
+
mode: effectiveMode,
|
|
3932
|
+
category: result.rule?.category,
|
|
3933
|
+
rule: result.rule,
|
|
3934
|
+
message: result.message,
|
|
3935
|
+
method: ctx.method,
|
|
3936
|
+
path: ctx.path,
|
|
3937
|
+
ip: ctx.ip,
|
|
3938
|
+
userAgent: ctx.userAgent
|
|
3939
|
+
});
|
|
3940
|
+
return effectiveMode === "block" ? block() : allow2();
|
|
3183
3941
|
};
|
|
3184
|
-
const screenText = (text, meta) => {
|
|
3942
|
+
const screenText = (text, meta, reqCtx) => {
|
|
3185
3943
|
let blockRule = null;
|
|
3186
3944
|
const redactions = [];
|
|
3187
|
-
|
|
3945
|
+
const headerMutations = [];
|
|
3946
|
+
let lowerText = null;
|
|
3947
|
+
for (const { rule, engine: re, redactors, prefilter, mutatedSpan } of responseRuleSet) {
|
|
3948
|
+
if (prefilter) {
|
|
3949
|
+
if (lowerText === null) lowerText = text.toLowerCase();
|
|
3950
|
+
if (!prefilter.some((p) => lowerText.includes(p))) continue;
|
|
3951
|
+
}
|
|
3188
3952
|
let result;
|
|
3189
3953
|
try {
|
|
3190
|
-
result = re.evaluate({ _response: { ...meta, body: text } });
|
|
3954
|
+
result = re.evaluate({ ...reqCtx || {}, _response: { ...meta, body: text } });
|
|
3191
3955
|
} catch (err) {
|
|
3192
3956
|
onError?.(err);
|
|
3193
3957
|
continue;
|
|
3194
3958
|
}
|
|
3195
3959
|
if (!result.blocked) continue;
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
if (
|
|
3960
|
+
const responseMode = ruleMode(rule);
|
|
3961
|
+
onDetect({ phase: "response", mode: responseMode, category: rule.category, rule, message: result.message });
|
|
3962
|
+
if (responseMode !== "block") continue;
|
|
3963
|
+
if (redactors && redactors.length) {
|
|
3964
|
+
const spanRedactors = redactors.filter((r) => !r.jsonPath);
|
|
3965
|
+
if (mutatedSpan && spanRedactors.length) {
|
|
3966
|
+
if (!blockRule) blockRule = rule;
|
|
3967
|
+
} else {
|
|
3968
|
+
redactions.push({ rule, redactors });
|
|
3969
|
+
}
|
|
3970
|
+
} else if (isHeaderMutation(rule.action)) headerMutations.push(rule);
|
|
3199
3971
|
else if (!blockRule) blockRule = rule;
|
|
3200
3972
|
}
|
|
3201
|
-
if (mode !== "block" || !blockRule && !redactions.length) return { verdict: "pass" };
|
|
3973
|
+
if (mode !== "block" || !blockRule && !redactions.length && !headerMutations.length) return { verdict: "pass" };
|
|
3202
3974
|
if (blockRule) return { verdict: "block" };
|
|
3203
3975
|
let body = text;
|
|
3204
3976
|
const headers = { ...meta.headers || {} };
|
|
@@ -3220,17 +3992,33 @@ async function createProtection(options = {}) {
|
|
|
3220
3992
|
}
|
|
3221
3993
|
}
|
|
3222
3994
|
}
|
|
3995
|
+
for (const rule of headerMutations) applyHeaderMutation(headers, rule);
|
|
3223
3996
|
return { verdict: "redact", body, headers };
|
|
3224
3997
|
};
|
|
3225
|
-
const
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3998
|
+
const reqContextFromFetch = (request) => {
|
|
3999
|
+
try {
|
|
4000
|
+
const u = new URL(request.url);
|
|
4001
|
+
const headers = headerObject(request.headers);
|
|
4002
|
+
if (!headers.host) headers.host = u.host;
|
|
4003
|
+
return { method: request.method, originalUrl: u.pathname + u.search, headers };
|
|
4004
|
+
} catch {
|
|
4005
|
+
return void 0;
|
|
4006
|
+
}
|
|
4007
|
+
};
|
|
4008
|
+
const reqContextFromNode = (req) => req ? { method: req.method, originalUrl: req.url, headers: req.headers || {} } : void 0;
|
|
4009
|
+
const screenResp = async (response, reqCtx) => {
|
|
4010
|
+
const read = await readTextResponse(response, screenCap);
|
|
4011
|
+
if (read.skip) {
|
|
4012
|
+
if (read.skip !== "not-a-response") recordSkip("response", read.skip, { status: response?.status });
|
|
4013
|
+
return response;
|
|
4014
|
+
}
|
|
4015
|
+
const text = read.text;
|
|
4016
|
+
const r = screenText(text, { status: response.status, headers: headerObject(response.headers) }, reqCtx);
|
|
3229
4017
|
if (r.verdict === "block") return leakResponse();
|
|
3230
4018
|
if (r.verdict === "redact") return rebuildResponse(response, r.body, r.headers);
|
|
3231
4019
|
return response;
|
|
3232
4020
|
};
|
|
3233
|
-
const wrapNodeResponse = (res) => {
|
|
4021
|
+
const wrapNodeResponse = (res, reqCtx) => {
|
|
3234
4022
|
const origWrite = res.write.bind(res);
|
|
3235
4023
|
const origEnd = res.end.bind(res);
|
|
3236
4024
|
const chunks = [];
|
|
@@ -3246,6 +4034,7 @@ async function createProtection(options = {}) {
|
|
|
3246
4034
|
chunks.length = 0;
|
|
3247
4035
|
origWrite(buf);
|
|
3248
4036
|
overflow = true;
|
|
4037
|
+
recordSkip("response", "body-cap", { bytes: size });
|
|
3249
4038
|
return;
|
|
3250
4039
|
}
|
|
3251
4040
|
chunks.push(buf);
|
|
@@ -3272,16 +4061,19 @@ async function createProtection(options = {}) {
|
|
|
3272
4061
|
}
|
|
3273
4062
|
collect(chunk, enc);
|
|
3274
4063
|
if (overflow) return origEnd(cb);
|
|
3275
|
-
const
|
|
4064
|
+
const buffer = Buffer.concat(chunks);
|
|
3276
4065
|
let ct = res.getHeader ? res.getHeader("content-type") : void 0;
|
|
3277
4066
|
if (Array.isArray(ct)) ct = ct[0];
|
|
3278
|
-
|
|
4067
|
+
const kind = screenableContentType(ct);
|
|
4068
|
+
if (kind === "skip" || kind === "sniff" && looksBinary(buffer)) {
|
|
4069
|
+
recordSkip("response", kind === "skip" ? baseContentType(ct) === "text/event-stream" ? "live-stream" : "non-text-content-type" : "binary-body");
|
|
3279
4070
|
for (const c of chunks) origWrite(c);
|
|
3280
4071
|
return origEnd(cb);
|
|
3281
4072
|
}
|
|
4073
|
+
const text = buffer.toString("utf8");
|
|
3282
4074
|
let r;
|
|
3283
4075
|
try {
|
|
3284
|
-
r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} });
|
|
4076
|
+
r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} }, reqCtx);
|
|
3285
4077
|
} catch (err) {
|
|
3286
4078
|
onError?.(err);
|
|
3287
4079
|
for (const c of chunks) origWrite(c);
|
|
@@ -3304,7 +4096,12 @@ async function createProtection(options = {}) {
|
|
|
3304
4096
|
const current = res.getHeaders ? res.getHeaders() : {};
|
|
3305
4097
|
for (const [name, value] of Object.entries(r.headers)) {
|
|
3306
4098
|
if (name.toLowerCase() === "content-length") continue;
|
|
3307
|
-
if (
|
|
4099
|
+
if (value === null || value === void 0) {
|
|
4100
|
+
try {
|
|
4101
|
+
res.removeHeader && res.removeHeader(name);
|
|
4102
|
+
} catch {
|
|
4103
|
+
}
|
|
4104
|
+
} else if (Array.isArray(value)) {
|
|
3308
4105
|
try {
|
|
3309
4106
|
res.setHeader(name, value);
|
|
3310
4107
|
} catch {
|
|
@@ -3334,8 +4131,9 @@ async function createProtection(options = {}) {
|
|
|
3334
4131
|
return false;
|
|
3335
4132
|
}
|
|
3336
4133
|
if (!result.blocked) return false;
|
|
3337
|
-
|
|
3338
|
-
|
|
4134
|
+
const egressMode = ruleMode(result.rule);
|
|
4135
|
+
onDetect({ phase: "egress", mode: egressMode, category: result.rule?.category, rule: result.rule, message: result.message });
|
|
4136
|
+
return egressMode === "block";
|
|
3339
4137
|
};
|
|
3340
4138
|
const protection = {
|
|
3341
4139
|
get mode() {
|
|
@@ -3344,9 +4142,18 @@ async function createProtection(options = {}) {
|
|
|
3344
4142
|
get rules() {
|
|
3345
4143
|
return { request: requestRules, response: responseRules, egress: egressRules };
|
|
3346
4144
|
},
|
|
4145
|
+
/**
|
|
4146
|
+
* Enforcement coverage: how often the guard FAILED OPEN rather than inspecting, keyed
|
|
4147
|
+
* `<phase>:<reason>` (e.g. `response:body-cap`, `request:body-cap`, `response:live-stream`,
|
|
4148
|
+
* `egress:resolver-failed`). "Always-on" is not "always inspected" — surface this (or pass
|
|
4149
|
+
* `onSkip`) so an unscreened path is visible and alertable rather than silent.
|
|
4150
|
+
*/
|
|
4151
|
+
coverage() {
|
|
4152
|
+
return { skipped: { ...skipCounts } };
|
|
4153
|
+
},
|
|
3347
4154
|
// Screen a fetch Response through the response-phase rules (redact/block). Used by
|
|
3348
4155
|
// .fetch(), and by the Supabase guard on its forwarded upstream response.
|
|
3349
|
-
screenResponse: (response) => screenResp(response),
|
|
4156
|
+
screenResponse: (response, request) => screenResp(response, request ? reqContextFromFetch(request) : void 0),
|
|
3350
4157
|
// (request) => Response | null (null = allow, caller proceeds). Request phase only.
|
|
3351
4158
|
fetchGuard() {
|
|
3352
4159
|
return async (request) => {
|
|
@@ -3357,7 +4164,7 @@ async function createProtection(options = {}) {
|
|
|
3357
4164
|
onError?.(err);
|
|
3358
4165
|
return null;
|
|
3359
4166
|
}
|
|
3360
|
-
return decide("request", result, () => blockResponse(result, request), () => null);
|
|
4167
|
+
return decide("request", result, () => blockResponse(result, request), () => null, fetchRequestMeta(request));
|
|
3361
4168
|
};
|
|
3362
4169
|
},
|
|
3363
4170
|
// Wrap a fetch handler: screens the request, then the response (redact/block).
|
|
@@ -3367,7 +4174,7 @@ async function createProtection(options = {}) {
|
|
|
3367
4174
|
const blocked = await guard(request);
|
|
3368
4175
|
if (blocked) return blocked;
|
|
3369
4176
|
const response = await handler(request, ...rest);
|
|
3370
|
-
return screenResp(response);
|
|
4177
|
+
return screenResp(response, reqContextFromFetch(request));
|
|
3371
4178
|
};
|
|
3372
4179
|
},
|
|
3373
4180
|
// Express middleware (request phase; expects express-parsed req.query/req.body).
|
|
@@ -3379,7 +4186,7 @@ async function createProtection(options = {}) {
|
|
|
3379
4186
|
result = engine.evaluate(req);
|
|
3380
4187
|
} catch (err) {
|
|
3381
4188
|
onError?.(err);
|
|
3382
|
-
if (exprOptions.screenResponses) wrapNodeResponse(res);
|
|
4189
|
+
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
3383
4190
|
return next();
|
|
3384
4191
|
}
|
|
3385
4192
|
decide(
|
|
@@ -3393,9 +4200,10 @@ async function createProtection(options = {}) {
|
|
|
3393
4200
|
}
|
|
3394
4201
|
},
|
|
3395
4202
|
() => {
|
|
3396
|
-
if (exprOptions.screenResponses) wrapNodeResponse(res);
|
|
4203
|
+
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
3397
4204
|
next();
|
|
3398
|
-
}
|
|
4205
|
+
},
|
|
4206
|
+
nodeRequestMeta(req)
|
|
3399
4207
|
);
|
|
3400
4208
|
};
|
|
3401
4209
|
},
|
|
@@ -3420,6 +4228,7 @@ async function createProtection(options = {}) {
|
|
|
3420
4228
|
next();
|
|
3421
4229
|
});
|
|
3422
4230
|
req.on("end", () => {
|
|
4231
|
+
if (overflow) recordSkip("request", "body-cap", { bytes: size, limit: maxBytes });
|
|
3423
4232
|
const rawBody = overflow ? "" : Buffer.concat(chunks).toString("utf8");
|
|
3424
4233
|
let shaped;
|
|
3425
4234
|
let result;
|
|
@@ -3445,9 +4254,10 @@ async function createProtection(options = {}) {
|
|
|
3445
4254
|
},
|
|
3446
4255
|
() => {
|
|
3447
4256
|
if (req.body === void 0) req.body = shaped.body;
|
|
3448
|
-
if (nodeOptions.screenResponses) wrapNodeResponse(res);
|
|
4257
|
+
if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
3449
4258
|
next();
|
|
3450
|
-
}
|
|
4259
|
+
},
|
|
4260
|
+
nodeRequestMeta(req)
|
|
3451
4261
|
);
|
|
3452
4262
|
});
|
|
3453
4263
|
};
|
|
@@ -3457,6 +4267,9 @@ async function createProtection(options = {}) {
|
|
|
3457
4267
|
protection.uninstallEgress = await installEgressGuard({
|
|
3458
4268
|
shouldBlock: egressShouldBlock,
|
|
3459
4269
|
onBlock: options.onEgressBlock,
|
|
4270
|
+
// Route egress coverage gaps (a DNS resolver failure / no resolver on this runtime) into the
|
|
4271
|
+
// same skip accounting as the request/response phases.
|
|
4272
|
+
onSkip: ({ reason, detail }) => recordSkip("egress", reason, detail),
|
|
3460
4273
|
dnsScreen: options.screenDns !== false,
|
|
3461
4274
|
allowHosts: options.allowHosts
|
|
3462
4275
|
});
|
|
@@ -3481,7 +4294,7 @@ async function createProtection(options = {}) {
|
|
|
3481
4294
|
onError?.(err);
|
|
3482
4295
|
}
|
|
3483
4296
|
}
|
|
3484
|
-
const next = await resolveRules(options, store);
|
|
4297
|
+
const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
|
|
3485
4298
|
mode = resolveMode(options, next);
|
|
3486
4299
|
applyBundle(next);
|
|
3487
4300
|
};
|
|
@@ -3491,7 +4304,12 @@ async function createProtection(options = {}) {
|
|
|
3491
4304
|
}
|
|
3492
4305
|
if (options.refreshMs > 0 && live) {
|
|
3493
4306
|
const loop = startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError });
|
|
3494
|
-
protection.stopRefresh =
|
|
4307
|
+
protection.stopRefresh = () => {
|
|
4308
|
+
loop.stop();
|
|
4309
|
+
firewallLog?.stop();
|
|
4310
|
+
};
|
|
4311
|
+
} else if (firewallLog) {
|
|
4312
|
+
protection.stopRefresh = () => firewallLog.stop();
|
|
3495
4313
|
}
|
|
3496
4314
|
return protection;
|
|
3497
4315
|
}
|
|
@@ -3503,21 +4321,77 @@ function resolveMode(options, bundle) {
|
|
|
3503
4321
|
if (options?.mode === "dry-run") return "dry-run";
|
|
3504
4322
|
return "dry-run";
|
|
3505
4323
|
}
|
|
4324
|
+
async function resolveApiKey(options) {
|
|
4325
|
+
if (typeof options?.apiKey === "string" && options.apiKey.length > 0) return options.apiKey;
|
|
4326
|
+
if (typeof process !== "undefined") {
|
|
4327
|
+
const fromEnv = process.env?.PATCHSTACK_API_KEY;
|
|
4328
|
+
if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
|
|
4329
|
+
}
|
|
4330
|
+
try {
|
|
4331
|
+
if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
|
|
4332
|
+
const [{ readFileSync }, { join }] = await Promise.all([import("fs"), import("path")]);
|
|
4333
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
4334
|
+
const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
|
|
4335
|
+
const key = JSON.parse(raw)?.apiKey;
|
|
4336
|
+
if (typeof key === "string" && key.length > 0) return key;
|
|
4337
|
+
} catch {
|
|
4338
|
+
}
|
|
4339
|
+
return void 0;
|
|
4340
|
+
}
|
|
4341
|
+
async function resolvePulseAuth(options) {
|
|
4342
|
+
if (typeof options?.pulseAuth === "string" && options.pulseAuth.length > 0) return options.pulseAuth;
|
|
4343
|
+
if (typeof process !== "undefined") {
|
|
4344
|
+
const fromEnv = process.env?.PATCHSTACK_PULSE_AUTH;
|
|
4345
|
+
if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
|
|
4346
|
+
}
|
|
4347
|
+
try {
|
|
4348
|
+
if (typeof process === "undefined" || typeof process.cwd !== "function") return resolveApiKey(options);
|
|
4349
|
+
const [{ readFileSync }, { join }] = await Promise.all([import("fs"), import("path")]);
|
|
4350
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
4351
|
+
const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
|
|
4352
|
+
const key = JSON.parse(raw)?.pulseAuth;
|
|
4353
|
+
if (typeof key === "string" && key.length > 0) return key;
|
|
4354
|
+
} catch {
|
|
4355
|
+
}
|
|
4356
|
+
return resolveApiKey(options);
|
|
4357
|
+
}
|
|
3506
4358
|
function byPhase(rules, phase) {
|
|
3507
4359
|
return (rules ?? []).filter((r) => (r.phase ?? "request") === phase);
|
|
3508
4360
|
}
|
|
4361
|
+
function baseContentType(ct) {
|
|
4362
|
+
return String(ct || "").toLowerCase().split(";")[0].trim();
|
|
4363
|
+
}
|
|
4364
|
+
function screenableContentType(ct) {
|
|
4365
|
+
const base = baseContentType(ct);
|
|
4366
|
+
if (base === "text/event-stream") return "skip";
|
|
4367
|
+
if (base === "") return "text";
|
|
4368
|
+
if (/(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(base)) return "text";
|
|
4369
|
+
if (base === "application/octet-stream") return "sniff";
|
|
4370
|
+
return "skip";
|
|
4371
|
+
}
|
|
4372
|
+
function looksBinary(bytes) {
|
|
4373
|
+
const n = Math.min(bytes.length, 512);
|
|
4374
|
+
let ctrl = 0;
|
|
4375
|
+
for (let i = 0; i < n; i++) {
|
|
4376
|
+
const b = bytes[i];
|
|
4377
|
+
if (b === 0) return true;
|
|
4378
|
+
if (b < 9 || b > 13 && b < 32) ctrl++;
|
|
4379
|
+
}
|
|
4380
|
+
return n > 0 && ctrl / n > 0.1;
|
|
4381
|
+
}
|
|
3509
4382
|
async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
|
|
3510
|
-
if (!response || typeof response.clone !== "function") return
|
|
3511
|
-
const ct =
|
|
3512
|
-
const
|
|
3513
|
-
if (
|
|
4383
|
+
if (!response || typeof response.clone !== "function") return { skip: "not-a-response" };
|
|
4384
|
+
const ct = response.headers?.get?.("content-type") || "";
|
|
4385
|
+
const kind = screenableContentType(ct);
|
|
4386
|
+
if (kind === "skip") return { skip: baseContentType(ct) === "text/event-stream" ? "live-stream" : "non-text-content-type" };
|
|
4387
|
+
const sniff = kind === "sniff";
|
|
3514
4388
|
const len = Number(response.headers?.get?.("content-length") || 0);
|
|
3515
|
-
if (len && len > cap) return
|
|
4389
|
+
if (len && len > cap) return { skip: "body-cap" };
|
|
3516
4390
|
let clone;
|
|
3517
4391
|
try {
|
|
3518
4392
|
clone = response.clone();
|
|
3519
4393
|
} catch {
|
|
3520
|
-
return
|
|
4394
|
+
return { skip: "clone-failed" };
|
|
3521
4395
|
}
|
|
3522
4396
|
const body = clone.body;
|
|
3523
4397
|
if (body && typeof body.getReader === "function") {
|
|
@@ -3525,11 +4399,16 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
|
|
|
3525
4399
|
const chunks = [];
|
|
3526
4400
|
let size = 0;
|
|
3527
4401
|
let over = false;
|
|
4402
|
+
let sniffed = !sniff;
|
|
3528
4403
|
try {
|
|
3529
4404
|
for (; ; ) {
|
|
3530
4405
|
const { done, value } = await reader.read();
|
|
3531
4406
|
if (done) break;
|
|
3532
4407
|
if (!value) continue;
|
|
4408
|
+
if (!sniffed) {
|
|
4409
|
+
sniffed = true;
|
|
4410
|
+
if (looksBinary(value)) return { skip: "binary-body" };
|
|
4411
|
+
}
|
|
3533
4412
|
size += value.byteLength;
|
|
3534
4413
|
if (over) continue;
|
|
3535
4414
|
if (size > cap) {
|
|
@@ -3539,20 +4418,22 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
|
|
|
3539
4418
|
chunks.push(value);
|
|
3540
4419
|
}
|
|
3541
4420
|
} catch {
|
|
3542
|
-
return
|
|
4421
|
+
return { skip: "read-failed" };
|
|
3543
4422
|
}
|
|
3544
|
-
if (over) return
|
|
4423
|
+
if (over) return { skip: "body-cap" };
|
|
3545
4424
|
try {
|
|
3546
|
-
return new TextDecoder().decode(concatBytes(chunks, size));
|
|
4425
|
+
return { text: new TextDecoder().decode(concatBytes(chunks, size)) };
|
|
3547
4426
|
} catch {
|
|
3548
|
-
return
|
|
4427
|
+
return { skip: "decode-failed" };
|
|
3549
4428
|
}
|
|
3550
4429
|
}
|
|
3551
4430
|
try {
|
|
3552
4431
|
const text = await clone.text();
|
|
3553
|
-
|
|
4432
|
+
if (text.length > cap) return { skip: "body-cap" };
|
|
4433
|
+
if (sniff && looksBinary(new TextEncoder().encode(text.slice(0, 512)))) return { skip: "binary-body" };
|
|
4434
|
+
return { text };
|
|
3554
4435
|
} catch {
|
|
3555
|
-
return
|
|
4436
|
+
return { skip: "read-failed" };
|
|
3556
4437
|
}
|
|
3557
4438
|
}
|
|
3558
4439
|
function concatBytes(chunks, total) {
|
|
@@ -3573,6 +4454,19 @@ function headerObject(headers) {
|
|
|
3573
4454
|
if (setCookies && setCookies.length) out["set-cookie"] = setCookies;
|
|
3574
4455
|
return out;
|
|
3575
4456
|
}
|
|
4457
|
+
function hasSpanMutations(rule) {
|
|
4458
|
+
let found = false;
|
|
4459
|
+
const walk2 = (conds) => {
|
|
4460
|
+
for (const c of conds ?? []) {
|
|
4461
|
+
if (found) return;
|
|
4462
|
+
if (Array.isArray(c.rules)) walk2(c.rules);
|
|
4463
|
+
const isSpan = c.match && (c.match.type === "regex" || c.match.type === "contains" || c.match.type === "stripos");
|
|
4464
|
+
if (isSpan && Array.isArray(c.mutations) && c.mutations.length) found = true;
|
|
4465
|
+
}
|
|
4466
|
+
};
|
|
4467
|
+
walk2(rule.rule_v2);
|
|
4468
|
+
return found;
|
|
4469
|
+
}
|
|
3576
4470
|
function extractRedactors(rule) {
|
|
3577
4471
|
const out = [];
|
|
3578
4472
|
const walk2 = (conds) => {
|
|
@@ -3609,7 +4503,10 @@ function applyRedactors(body, redactors, mask, transform) {
|
|
|
3609
4503
|
let out = body;
|
|
3610
4504
|
for (const r of redactors) {
|
|
3611
4505
|
if (r.re) out = out.replace(r.re, transform ? (m) => transform(m) : mask);
|
|
3612
|
-
else if (r.literal)
|
|
4506
|
+
else if (r.literal) {
|
|
4507
|
+
const re = new RegExp(r.literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
|
|
4508
|
+
out = out.replace(re, (m) => transform ? transform(m) : mask);
|
|
4509
|
+
}
|
|
3613
4510
|
}
|
|
3614
4511
|
return out;
|
|
3615
4512
|
}
|
|
@@ -3712,12 +4609,52 @@ function restoreBigInts(text) {
|
|
|
3712
4609
|
if (!text.includes(BIGINT_OPEN)) return text;
|
|
3713
4610
|
return text.replace(new RegExp(`"${BIGINT_OPEN}(-?\\d+)${BIGINT_CLOSE}"`, "g"), "$1");
|
|
3714
4611
|
}
|
|
4612
|
+
function isHeaderMutation(action) {
|
|
4613
|
+
return action === "set-header" || action === "remove-header" || action === "harden-cookie";
|
|
4614
|
+
}
|
|
4615
|
+
function applyHeaderMutation(headers, rule) {
|
|
4616
|
+
if (rule.action === "remove-header") {
|
|
4617
|
+
for (const name of rule.remove_headers ?? []) headers[String(name).toLowerCase()] = null;
|
|
4618
|
+
return;
|
|
4619
|
+
}
|
|
4620
|
+
if (rule.action === "set-header") {
|
|
4621
|
+
const ensure = rule.ensure === true;
|
|
4622
|
+
for (const [name, value] of Object.entries(rule.set_headers ?? {})) {
|
|
4623
|
+
const key = String(name).toLowerCase();
|
|
4624
|
+
const present = headers[key] != null && headers[key] !== "";
|
|
4625
|
+
if (ensure && present) continue;
|
|
4626
|
+
headers[key] = String(value);
|
|
4627
|
+
}
|
|
4628
|
+
return;
|
|
4629
|
+
}
|
|
4630
|
+
if (rule.action === "harden-cookie") {
|
|
4631
|
+
const cookie = headers["set-cookie"];
|
|
4632
|
+
const flags = rule.cookie_flags ?? {};
|
|
4633
|
+
if (Array.isArray(cookie)) {
|
|
4634
|
+
headers["set-cookie"] = cookie.map((c) => typeof c === "string" ? hardenCookie(c, flags) : c);
|
|
4635
|
+
} else if (typeof cookie === "string") {
|
|
4636
|
+
headers["set-cookie"] = hardenCookie(cookie, flags);
|
|
4637
|
+
}
|
|
4638
|
+
}
|
|
4639
|
+
}
|
|
4640
|
+
function hardenCookie(cookie, { httpOnly = true, secure = true, sameSite = "Lax" } = {}) {
|
|
4641
|
+
let out = String(cookie);
|
|
4642
|
+
if (httpOnly && !/;\s*httponly/i.test(out)) out += "; HttpOnly";
|
|
4643
|
+
if (secure && !/;\s*secure/i.test(out)) out += "; Secure";
|
|
4644
|
+
if (sameSite && !/;\s*samesite\s*=/i.test(out)) out += `; SameSite=${sameSite}`;
|
|
4645
|
+
return out;
|
|
4646
|
+
}
|
|
3715
4647
|
function rebuildResponse(response, body, redactedHeaders) {
|
|
3716
4648
|
const headers = new Headers(response.headers);
|
|
3717
4649
|
headers.delete("content-length");
|
|
3718
4650
|
if (redactedHeaders) {
|
|
3719
4651
|
for (const [name, value] of Object.entries(redactedHeaders)) {
|
|
3720
|
-
if (
|
|
4652
|
+
if (value === null || value === void 0) {
|
|
4653
|
+
try {
|
|
4654
|
+
headers.delete(name);
|
|
4655
|
+
} catch {
|
|
4656
|
+
}
|
|
4657
|
+
} else if (typeof value === "string") {
|
|
3721
4658
|
if (headers.get(name) !== value) {
|
|
3722
4659
|
try {
|
|
3723
4660
|
headers.set(name, value);
|
|
@@ -3733,7 +4670,8 @@ function rebuildResponse(response, body, redactedHeaders) {
|
|
|
3733
4670
|
}
|
|
3734
4671
|
}
|
|
3735
4672
|
}
|
|
3736
|
-
|
|
4673
|
+
const nullBody = response.status === 101 || response.status === 204 || response.status === 205 || response.status === 304;
|
|
4674
|
+
return new Response(nullBody ? null : body, { status: response.status, statusText: response.statusText, headers });
|
|
3737
4675
|
}
|
|
3738
4676
|
function leakResponse() {
|
|
3739
4677
|
return new Response(JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" }), {
|
|
@@ -3766,6 +4704,33 @@ function defaultOnDetect({ phase, mode, category, rule, message }) {
|
|
|
3766
4704
|
const tag = mode === "block" ? "BLOCK" : "DETECT (dry-run)";
|
|
3767
4705
|
console.warn(`[patchstack] ${tag} phase=${phase ?? "request"} category=${category ?? "?"} rule=${rule?.id ?? "?"} ${message ?? ""}`.trim());
|
|
3768
4706
|
}
|
|
4707
|
+
function fetchRequestMeta(request) {
|
|
4708
|
+
if (!request) return {};
|
|
4709
|
+
let path7 = null;
|
|
4710
|
+
try {
|
|
4711
|
+
path7 = new URL(request.url).pathname;
|
|
4712
|
+
} catch {
|
|
4713
|
+
path7 = typeof request.url === "string" ? request.url : null;
|
|
4714
|
+
}
|
|
4715
|
+
return {
|
|
4716
|
+
method: request.method ?? null,
|
|
4717
|
+
path: path7,
|
|
4718
|
+
ip: request.headers?.get?.("x-forwarded-for") ?? null,
|
|
4719
|
+
userAgent: request.headers?.get?.("user-agent") ?? null
|
|
4720
|
+
};
|
|
4721
|
+
}
|
|
4722
|
+
function nodeRequestMeta(req) {
|
|
4723
|
+
if (!req) return {};
|
|
4724
|
+
const headers = req.headers ?? {};
|
|
4725
|
+
const ua = headers["user-agent"] ?? headers["User-Agent"];
|
|
4726
|
+
const fwd = headers["x-forwarded-for"] ?? headers["X-Forwarded-For"];
|
|
4727
|
+
return {
|
|
4728
|
+
method: req.method ?? null,
|
|
4729
|
+
path: req.originalUrl || req.url || null,
|
|
4730
|
+
ip: req.ip ?? (typeof fwd === "string" ? fwd : null),
|
|
4731
|
+
userAgent: typeof ua === "string" ? ua : Array.isArray(ua) ? ua[0] : null
|
|
4732
|
+
};
|
|
4733
|
+
}
|
|
3769
4734
|
// Annotate the CommonJS export names for ESM import in node:
|
|
3770
4735
|
0 && (module.exports = {
|
|
3771
4736
|
GUARD_PATH,
|