@patchstack/connect 0.3.23 → 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 +2857 -132
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +93 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +18 -1
- package/dist/index.d.ts +18 -1
- package/dist/index.js +92 -1
- package/dist/index.js.map +1 -1
- package/dist/protect.cjs +941 -221
- package/dist/protect.cjs.map +1 -1
- package/dist/protect.d.ts +7 -0
- package/dist/protect.edge.js +3530 -0
- package/dist/protect.edge.js.map +7 -0
- package/dist/protect.js +826 -195
- package/dist/protect.js.map +1 -1
- package/dist/{refresh-manifest-ZIFRZF25.js → refresh-manifest-MUWVIPFR.js} +9 -2
- package/dist/refresh-manifest-MUWVIPFR.js.map +1 -0
- package/package.json +11 -3
- package/dist/refresh-manifest-ZIFRZF25.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
|
}
|
|
@@ -156,9 +245,11 @@ async function resolveConfig(options) {
|
|
|
156
245
|
);
|
|
157
246
|
}
|
|
158
247
|
const apiKeyRaw = fromEnv.apiKey ?? fromFile.apiKey ?? null;
|
|
248
|
+
const pulseAuthRaw = fromEnv.pulseAuth ?? fromFile.pulseAuth ?? apiKeyRaw;
|
|
159
249
|
return {
|
|
160
250
|
siteUuid: siteUuid === null || siteUuid.length === 0 ? null : siteUuid,
|
|
161
251
|
apiKey: apiKeyRaw === null || apiKeyRaw.length === 0 ? null : apiKeyRaw,
|
|
252
|
+
pulseAuth: pulseAuthRaw === null || pulseAuthRaw.length === 0 ? null : pulseAuthRaw,
|
|
162
253
|
endpoint,
|
|
163
254
|
timeoutMs,
|
|
164
255
|
environment,
|
|
@@ -166,7 +257,7 @@ async function resolveConfig(options) {
|
|
|
166
257
|
};
|
|
167
258
|
}
|
|
168
259
|
async function readConfigFile(cwd) {
|
|
169
|
-
const target =
|
|
260
|
+
const target = import_node_path.default.join(cwd, CONFIG_FILENAME);
|
|
170
261
|
let raw;
|
|
171
262
|
try {
|
|
172
263
|
raw = await (0, import_promises.readFile)(target, "utf8");
|
|
@@ -207,6 +298,7 @@ function readEnv() {
|
|
|
207
298
|
return {
|
|
208
299
|
siteUuid: process.env.PATCHSTACK_SITE_UUID ?? void 0,
|
|
209
300
|
apiKey: process.env.PATCHSTACK_API_KEY ?? void 0,
|
|
301
|
+
pulseAuth: process.env.PATCHSTACK_PULSE_AUTH ?? void 0,
|
|
210
302
|
endpoint: process.env.PATCHSTACK_ENDPOINT ?? void 0,
|
|
211
303
|
timeoutMs,
|
|
212
304
|
environment: environmentRaw !== void 0 && environmentRaw.length > 0 ? environmentRaw : void 0
|
|
@@ -218,12 +310,12 @@ function isUuid(value) {
|
|
|
218
310
|
function isEnvironment(value) {
|
|
219
311
|
return value === "production" || value === "sandbox";
|
|
220
312
|
}
|
|
221
|
-
var import_promises,
|
|
313
|
+
var import_promises, import_node_path, CONFIG_FILENAME, DEFAULT_ENVIRONMENT;
|
|
222
314
|
var init_config = __esm({
|
|
223
315
|
"src/config.ts"() {
|
|
224
316
|
"use strict";
|
|
225
317
|
import_promises = require("fs/promises");
|
|
226
|
-
|
|
318
|
+
import_node_path = __toESM(require("path"), 1);
|
|
227
319
|
init_types();
|
|
228
320
|
init_client();
|
|
229
321
|
CONFIG_FILENAME = ".patchstackrc.json";
|
|
@@ -235,7 +327,7 @@ var init_config = __esm({
|
|
|
235
327
|
async function readDeclaredDependencyNames(cwd) {
|
|
236
328
|
let raw;
|
|
237
329
|
try {
|
|
238
|
-
raw = await (0, import_promises2.readFile)(
|
|
330
|
+
raw = await (0, import_promises2.readFile)(import_node_path2.default.join(cwd, "package.json"), "utf8");
|
|
239
331
|
} catch {
|
|
240
332
|
return [];
|
|
241
333
|
}
|
|
@@ -267,12 +359,12 @@ function missingDependencies(declared, packages) {
|
|
|
267
359
|
const present = new Set(packages.map((entry) => entry.name));
|
|
268
360
|
return declared.filter((name) => !present.has(name));
|
|
269
361
|
}
|
|
270
|
-
var import_promises2,
|
|
362
|
+
var import_promises2, import_node_path2;
|
|
271
363
|
var init_consistency = __esm({
|
|
272
364
|
"src/parsers/consistency.ts"() {
|
|
273
365
|
"use strict";
|
|
274
366
|
import_promises2 = require("fs/promises");
|
|
275
|
-
|
|
367
|
+
import_node_path2 = __toESM(require("path"), 1);
|
|
276
368
|
}
|
|
277
369
|
});
|
|
278
370
|
|
|
@@ -338,7 +430,7 @@ function extractFromV1(deps, acc = [], depth = 0) {
|
|
|
338
430
|
return acc;
|
|
339
431
|
}
|
|
340
432
|
function extractNameFromPath(pkgPath) {
|
|
341
|
-
const segments = pkgPath.split("node_modules" +
|
|
433
|
+
const segments = pkgPath.split("node_modules" + import_node_path3.default.sep === pkgPath ? import_node_path3.default.sep : "/");
|
|
342
434
|
const parts = pkgPath.split("/");
|
|
343
435
|
const nmIndex = parts.lastIndexOf("node_modules");
|
|
344
436
|
if (nmIndex === -1 || nmIndex >= parts.length - 1) {
|
|
@@ -359,19 +451,19 @@ function isDirectV2(pkgPath) {
|
|
|
359
451
|
const nmCount = parts.filter((p) => p === "node_modules").length;
|
|
360
452
|
return nmCount === 1;
|
|
361
453
|
}
|
|
362
|
-
var import_promises3,
|
|
454
|
+
var import_promises3, import_node_path3;
|
|
363
455
|
var init_npm = __esm({
|
|
364
456
|
"src/parsers/npm.ts"() {
|
|
365
457
|
"use strict";
|
|
366
458
|
import_promises3 = require("fs/promises");
|
|
367
|
-
|
|
459
|
+
import_node_path3 = __toESM(require("path"), 1);
|
|
368
460
|
init_types();
|
|
369
461
|
}
|
|
370
462
|
});
|
|
371
463
|
|
|
372
464
|
// src/parsers/node_modules.ts
|
|
373
465
|
async function walkNodeModules(cwd) {
|
|
374
|
-
const root =
|
|
466
|
+
const root = import_node_path4.default.join(cwd, "node_modules");
|
|
375
467
|
try {
|
|
376
468
|
const info = await (0, import_promises4.stat)(root);
|
|
377
469
|
if (!info.isDirectory()) {
|
|
@@ -405,7 +497,7 @@ async function walk(dir, acc, depth) {
|
|
|
405
497
|
if (name.startsWith(".")) {
|
|
406
498
|
continue;
|
|
407
499
|
}
|
|
408
|
-
const fullPath =
|
|
500
|
+
const fullPath = import_node_path4.default.join(dir, name);
|
|
409
501
|
if (!await isPlainDirectory(fullPath)) {
|
|
410
502
|
continue;
|
|
411
503
|
}
|
|
@@ -420,7 +512,7 @@ async function walk(dir, acc, depth) {
|
|
|
420
512
|
if (sub.startsWith(".")) {
|
|
421
513
|
continue;
|
|
422
514
|
}
|
|
423
|
-
const scopedDir =
|
|
515
|
+
const scopedDir = import_node_path4.default.join(fullPath, sub);
|
|
424
516
|
if (!await isPlainDirectory(scopedDir)) {
|
|
425
517
|
continue;
|
|
426
518
|
}
|
|
@@ -436,7 +528,7 @@ async function walk(dir, acc, depth) {
|
|
|
436
528
|
async function readPackage(pkgDir, depth, acc) {
|
|
437
529
|
let raw;
|
|
438
530
|
try {
|
|
439
|
-
raw = await (0, import_promises4.readFile)(
|
|
531
|
+
raw = await (0, import_promises4.readFile)(import_node_path4.default.join(pkgDir, "package.json"), "utf8");
|
|
440
532
|
} catch {
|
|
441
533
|
return;
|
|
442
534
|
}
|
|
@@ -459,7 +551,7 @@ async function readPackage(pkgDir, depth, acc) {
|
|
|
459
551
|
});
|
|
460
552
|
}
|
|
461
553
|
async function walkNested(pkgDir, acc, depth) {
|
|
462
|
-
const nested =
|
|
554
|
+
const nested = import_node_path4.default.join(pkgDir, "node_modules");
|
|
463
555
|
if (!await isPlainDirectory(nested)) {
|
|
464
556
|
return;
|
|
465
557
|
}
|
|
@@ -473,12 +565,12 @@ async function isPlainDirectory(dir) {
|
|
|
473
565
|
return false;
|
|
474
566
|
}
|
|
475
567
|
}
|
|
476
|
-
var import_promises4,
|
|
568
|
+
var import_promises4, import_node_path4;
|
|
477
569
|
var init_node_modules = __esm({
|
|
478
570
|
"src/parsers/node_modules.ts"() {
|
|
479
571
|
"use strict";
|
|
480
572
|
import_promises4 = require("fs/promises");
|
|
481
|
-
|
|
573
|
+
import_node_path4 = __toESM(require("path"), 1);
|
|
482
574
|
init_types();
|
|
483
575
|
}
|
|
484
576
|
});
|
|
@@ -761,7 +853,7 @@ async function parseYarnLockfile(lockfilePath) {
|
|
|
761
853
|
"LOCKFILE_PARSE_ERROR"
|
|
762
854
|
);
|
|
763
855
|
}
|
|
764
|
-
const directNames = await readDirectDepNames(
|
|
856
|
+
const directNames = await readDirectDepNames(import_node_path5.default.dirname(lockfilePath));
|
|
765
857
|
const entries = [];
|
|
766
858
|
const seen = /* @__PURE__ */ new Set();
|
|
767
859
|
for (const block of blocks) {
|
|
@@ -905,7 +997,7 @@ async function readDirectDepNames(cwd) {
|
|
|
905
997
|
const names = /* @__PURE__ */ new Set();
|
|
906
998
|
let raw;
|
|
907
999
|
try {
|
|
908
|
-
raw = await (0, import_promises6.readFile)(
|
|
1000
|
+
raw = await (0, import_promises6.readFile)(import_node_path5.default.join(cwd, "package.json"), "utf8");
|
|
909
1001
|
} catch {
|
|
910
1002
|
return names;
|
|
911
1003
|
}
|
|
@@ -930,12 +1022,12 @@ async function readDirectDepNames(cwd) {
|
|
|
930
1022
|
}
|
|
931
1023
|
return names;
|
|
932
1024
|
}
|
|
933
|
-
var import_promises6,
|
|
1025
|
+
var import_promises6, import_node_path5;
|
|
934
1026
|
var init_yarn = __esm({
|
|
935
1027
|
"src/parsers/yarn.ts"() {
|
|
936
1028
|
"use strict";
|
|
937
1029
|
import_promises6 = require("fs/promises");
|
|
938
|
-
|
|
1030
|
+
import_node_path5 = __toESM(require("path"), 1);
|
|
939
1031
|
init_types();
|
|
940
1032
|
}
|
|
941
1033
|
});
|
|
@@ -944,7 +1036,7 @@ var init_yarn = __esm({
|
|
|
944
1036
|
async function detectLockfile(cwd) {
|
|
945
1037
|
const probed = await Promise.all(
|
|
946
1038
|
LOCKFILE_CANDIDATES.map(async (candidate) => {
|
|
947
|
-
const filePath =
|
|
1039
|
+
const filePath = import_node_path6.default.join(cwd, candidate.filename);
|
|
948
1040
|
return { ...candidate, filePath, present: await exists(filePath) };
|
|
949
1041
|
})
|
|
950
1042
|
);
|
|
@@ -1022,7 +1114,7 @@ function staleWarning(source, missing) {
|
|
|
1022
1114
|
async function presentLockfiles(cwd) {
|
|
1023
1115
|
const probed = await Promise.all(
|
|
1024
1116
|
LOCKFILE_CANDIDATES.map(async (candidate) => {
|
|
1025
|
-
const filePath =
|
|
1117
|
+
const filePath = import_node_path6.default.join(cwd, candidate.filename);
|
|
1026
1118
|
return { ...candidate, filePath, present: await exists(filePath) };
|
|
1027
1119
|
})
|
|
1028
1120
|
);
|
|
@@ -1048,12 +1140,12 @@ async function exists(filePath) {
|
|
|
1048
1140
|
return false;
|
|
1049
1141
|
}
|
|
1050
1142
|
}
|
|
1051
|
-
var import_promises7,
|
|
1143
|
+
var import_promises7, import_node_path6, LOCKFILE_CANDIDATES;
|
|
1052
1144
|
var init_parsers = __esm({
|
|
1053
1145
|
"src/parsers/index.ts"() {
|
|
1054
1146
|
"use strict";
|
|
1055
1147
|
import_promises7 = require("fs/promises");
|
|
1056
|
-
|
|
1148
|
+
import_node_path6 = __toESM(require("path"), 1);
|
|
1057
1149
|
init_types();
|
|
1058
1150
|
init_consistency();
|
|
1059
1151
|
init_npm();
|
|
@@ -1212,6 +1304,29 @@ __export(runtime_exports, {
|
|
|
1212
1304
|
});
|
|
1213
1305
|
module.exports = __toCommonJS(runtime_exports);
|
|
1214
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
|
+
|
|
1215
1330
|
// src/protect/engine/client.js
|
|
1216
1331
|
var DEFAULT_BASE_URL = "https://api.patchstack.com";
|
|
1217
1332
|
var DEFAULT_CACHE_TTL = 3e5;
|
|
@@ -1224,9 +1339,11 @@ var PatchstackRuleClient = class {
|
|
|
1224
1339
|
#cacheTime = null;
|
|
1225
1340
|
#ttlEffective = 0;
|
|
1226
1341
|
#etag;
|
|
1227
|
-
|
|
1342
|
+
#timeoutMs;
|
|
1343
|
+
constructor({ token, baseUrl, cacheTtl, etag, timeoutMs } = {}) {
|
|
1344
|
+
this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
|
|
1228
1345
|
this.#token = token ?? process.env.PATCHSTACK_WAF_TOKEN;
|
|
1229
|
-
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");
|
|
1230
1347
|
this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL;
|
|
1231
1348
|
this.#etag = etag ?? null;
|
|
1232
1349
|
if (!this.#token) {
|
|
@@ -1251,7 +1368,7 @@ var PatchstackRuleClient = class {
|
|
|
1251
1368
|
method: "POST",
|
|
1252
1369
|
headers,
|
|
1253
1370
|
body: JSON.stringify({}),
|
|
1254
|
-
signal: AbortSignal.timeout(
|
|
1371
|
+
signal: AbortSignal.timeout(this.#timeoutMs)
|
|
1255
1372
|
});
|
|
1256
1373
|
if (response.status === 304) {
|
|
1257
1374
|
this.#touch(now);
|
|
@@ -1304,6 +1421,9 @@ var PatchstackRuleClient = class {
|
|
|
1304
1421
|
};
|
|
1305
1422
|
|
|
1306
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;
|
|
1307
1427
|
function base64DecodeUtf8(value) {
|
|
1308
1428
|
const str = String(value);
|
|
1309
1429
|
if (typeof Buffer !== "undefined") {
|
|
@@ -1507,7 +1627,9 @@ var RequestResolver = class {
|
|
|
1507
1627
|
default: {
|
|
1508
1628
|
if (key.startsWith("HTTP_")) {
|
|
1509
1629
|
const headerName = key.substring(5).toLowerCase().replace(/_/g, "-");
|
|
1510
|
-
|
|
1630
|
+
const headers = req.headers;
|
|
1631
|
+
if (headers === null || typeof headers !== "object") return [];
|
|
1632
|
+
return Object.prototype.hasOwnProperty.call(headers, headerName) ? [headers[headerName]] : [];
|
|
1511
1633
|
}
|
|
1512
1634
|
return [];
|
|
1513
1635
|
}
|
|
@@ -1515,14 +1637,32 @@ var RequestResolver = class {
|
|
|
1515
1637
|
}
|
|
1516
1638
|
#resolveFiles(key) {
|
|
1517
1639
|
const files = this.#req.files;
|
|
1518
|
-
if (!files) {
|
|
1640
|
+
if (!files || typeof files !== "object") {
|
|
1519
1641
|
return [];
|
|
1520
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)];
|
|
1521
1656
|
if (key.endsWith("*")) {
|
|
1522
|
-
|
|
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);
|
|
1523
1663
|
}
|
|
1524
|
-
|
|
1525
|
-
return
|
|
1664
|
+
if (!Object.prototype.hasOwnProperty.call(files, key)) return [];
|
|
1665
|
+
return filenamesOf(files[key]).filter((v) => v !== void 0);
|
|
1526
1666
|
}
|
|
1527
1667
|
#resolveRaw() {
|
|
1528
1668
|
if (typeof this.#req._rawBody === "string") {
|
|
@@ -1553,6 +1693,9 @@ var RequestResolver = class {
|
|
|
1553
1693
|
if (body) {
|
|
1554
1694
|
parts.push(typeof body === "string" ? body : JSON.stringify(body));
|
|
1555
1695
|
}
|
|
1696
|
+
if (typeof this.#req._rawBody === "string" && this.#req._rawBody) {
|
|
1697
|
+
parts.push(this.#req._rawBody);
|
|
1698
|
+
}
|
|
1556
1699
|
const headers = this.#req.headers ?? {};
|
|
1557
1700
|
const excludedHeaders = /* @__PURE__ */ new Set([
|
|
1558
1701
|
"host",
|
|
@@ -1745,8 +1888,6 @@ function removeSqlComments(value) {
|
|
|
1745
1888
|
let result = value;
|
|
1746
1889
|
result = result.replace(/\/\*[\s\S]*?\*\//g, " ");
|
|
1747
1890
|
result = result.replace(/\/\*![\s\S]*?\*\//g, " ");
|
|
1748
|
-
result = result.replace(/--[^\r\n]*/g, "");
|
|
1749
|
-
result = result.replace(/#[^\r\n]*/g, "");
|
|
1750
1891
|
return result;
|
|
1751
1892
|
}
|
|
1752
1893
|
function removeNullBytes(value) {
|
|
@@ -1801,17 +1942,21 @@ function normalizeRequest(req, options = {}) {
|
|
|
1801
1942
|
_rawBody: rawBody
|
|
1802
1943
|
};
|
|
1803
1944
|
}
|
|
1804
|
-
|
|
1945
|
+
var MAX_NORMALIZE_DEPTH = 200;
|
|
1946
|
+
function normalizeObject(value, options = {}, depth = 0) {
|
|
1805
1947
|
if (typeof value === "string") {
|
|
1806
1948
|
return normalize(value, options);
|
|
1807
1949
|
}
|
|
1950
|
+
if (depth >= MAX_NORMALIZE_DEPTH) {
|
|
1951
|
+
return value;
|
|
1952
|
+
}
|
|
1808
1953
|
if (Array.isArray(value)) {
|
|
1809
|
-
return value.map((item) => normalizeObject(item, options));
|
|
1954
|
+
return value.map((item) => normalizeObject(item, options, depth + 1));
|
|
1810
1955
|
}
|
|
1811
1956
|
if (typeof value === "object" && value !== null) {
|
|
1812
1957
|
const result = {};
|
|
1813
1958
|
for (const [key, val] of Object.entries(value)) {
|
|
1814
|
-
result[key] = normalizeObject(val, options);
|
|
1959
|
+
result[key] = normalizeObject(val, options, depth + 1);
|
|
1815
1960
|
}
|
|
1816
1961
|
return result;
|
|
1817
1962
|
}
|
|
@@ -1819,14 +1964,32 @@ function normalizeObject(value, options = {}) {
|
|
|
1819
1964
|
}
|
|
1820
1965
|
|
|
1821
1966
|
// src/protect/engine/engine.js
|
|
1967
|
+
var GRP = "(?:[^()]|\\([^()]*\\))*";
|
|
1822
1968
|
var REDOS_PATTERNS = [
|
|
1823
1969
|
/\([^)]*[+*}][^)]*\)\s*[+*]/,
|
|
1824
|
-
/\([^)]*\|[^)]*\)\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
|
|
1825
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;
|
|
1826
1986
|
function safeRegExp(pattern) {
|
|
1827
1987
|
if (!pattern) {
|
|
1828
1988
|
return null;
|
|
1829
1989
|
}
|
|
1990
|
+
if (typeof pattern !== "string" || pattern.length > MAX_PATTERN_LENGTH) {
|
|
1991
|
+
return null;
|
|
1992
|
+
}
|
|
1830
1993
|
for (const dangerous of REDOS_PATTERNS) {
|
|
1831
1994
|
if (dangerous.test(pattern)) {
|
|
1832
1995
|
return null;
|
|
@@ -1883,6 +2046,34 @@ function arrayKeyValue(value, matchObj) {
|
|
|
1883
2046
|
}
|
|
1884
2047
|
return false;
|
|
1885
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
|
+
}
|
|
1886
2077
|
var warnedMatchTypes = /* @__PURE__ */ new Set();
|
|
1887
2078
|
function warnUnsupportedMatchType(type) {
|
|
1888
2079
|
if (type == null || warnedMatchTypes.has(type)) {
|
|
@@ -1895,28 +2086,93 @@ function warnUnsupportedMatchType(type) {
|
|
|
1895
2086
|
}
|
|
1896
2087
|
function isInternalHost(hostname) {
|
|
1897
2088
|
if (!hostname) return false;
|
|
1898
|
-
|
|
2089
|
+
let host = String(hostname).toLowerCase().replace(/^\[|\]$/g, "");
|
|
2090
|
+
host = host.replace(/%[^\]]*$/, "");
|
|
2091
|
+
host = host.replace(/\.$/, "");
|
|
1899
2092
|
if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) return true;
|
|
1900
2093
|
if (host === "metadata.google.internal") return true;
|
|
1901
|
-
if (host
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
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;
|
|
1910
2105
|
}
|
|
2106
|
+
const v4 = parseIPv4ToInt(host);
|
|
2107
|
+
if (v4 !== null) return isPrivateV4Int(v4);
|
|
1911
2108
|
return false;
|
|
1912
2109
|
}
|
|
1913
|
-
function
|
|
2110
|
+
function isPrivateV4Int(n) {
|
|
2111
|
+
const a = n >>> 24 & 255;
|
|
2112
|
+
const b = n >>> 16 & 255;
|
|
1914
2113
|
if (a === 127 || a === 10 || a === 0) return true;
|
|
1915
2114
|
if (a === 169 && b === 254) return true;
|
|
1916
2115
|
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
1917
2116
|
if (a === 192 && b === 168) return true;
|
|
2117
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
1918
2118
|
return false;
|
|
1919
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
|
+
}
|
|
1920
2176
|
function ruleAppliesTo(when, resolver) {
|
|
1921
2177
|
try {
|
|
1922
2178
|
if (when.method) {
|
|
@@ -1944,14 +2200,22 @@ function pathMatches(pattern, path7) {
|
|
|
1944
2200
|
}
|
|
1945
2201
|
return path7 === pattern;
|
|
1946
2202
|
}
|
|
2203
|
+
function normalizeDefaultPort(host) {
|
|
2204
|
+
return String(host).toLowerCase().replace(/:(?:80|443)$/, "");
|
|
2205
|
+
}
|
|
1947
2206
|
function isCrossOrigin(resolver) {
|
|
1948
2207
|
try {
|
|
1949
|
-
const host = String(resolver.resolve("server.HTTP_HOST")[0] ?? "")
|
|
2208
|
+
const host = normalizeDefaultPort(String(resolver.resolve("server.HTTP_HOST")[0] ?? ""));
|
|
1950
2209
|
if (!host) return false;
|
|
1951
|
-
const
|
|
1952
|
-
|
|
1953
|
-
const
|
|
1954
|
-
|
|
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;
|
|
1955
2219
|
} catch {
|
|
1956
2220
|
return false;
|
|
1957
2221
|
}
|
|
@@ -1963,6 +2227,41 @@ function hostFromUrl(value) {
|
|
|
1963
2227
|
return null;
|
|
1964
2228
|
}
|
|
1965
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
|
+
}
|
|
1966
2265
|
function matchValue(type, value, matchVal, matchObj) {
|
|
1967
2266
|
if (value === null || value === void 0) {
|
|
1968
2267
|
if (type === "isset") {
|
|
@@ -1970,7 +2269,16 @@ function matchValue(type, value, matchVal, matchObj) {
|
|
|
1970
2269
|
}
|
|
1971
2270
|
return false;
|
|
1972
2271
|
}
|
|
1973
|
-
|
|
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
|
+
}
|
|
1974
2282
|
switch (type) {
|
|
1975
2283
|
case "equals":
|
|
1976
2284
|
return strValue == matchVal;
|
|
@@ -1991,6 +2299,7 @@ function matchValue(type, value, matchVal, matchObj) {
|
|
|
1991
2299
|
case "regex": {
|
|
1992
2300
|
const regex = safeRegExp(matchVal);
|
|
1993
2301
|
if (!regex) {
|
|
2302
|
+
warnRejectedPatternOnce(matchVal);
|
|
1994
2303
|
return false;
|
|
1995
2304
|
}
|
|
1996
2305
|
return regex.test(strValue);
|
|
@@ -2069,6 +2378,15 @@ var RuleEngine = class {
|
|
|
2069
2378
|
this.#whitelists = whitelists;
|
|
2070
2379
|
this.#whitelistKeys = whitelist_keys;
|
|
2071
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
|
+
}
|
|
2072
2390
|
}
|
|
2073
2391
|
// A mitigation engine must never take down the app it protects: any error while
|
|
2074
2392
|
// evaluating a rule is reported and the request is allowed through (fail open). A
|
|
@@ -2149,6 +2467,12 @@ var RuleEngine = class {
|
|
|
2149
2467
|
if (match && match.type === "cross_origin") {
|
|
2150
2468
|
return isCrossOrigin(resolver);
|
|
2151
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
|
+
}
|
|
2152
2476
|
const params = Array.isArray(parameter) ? parameter : [parameter];
|
|
2153
2477
|
const values = params.flatMap((p) => resolver.resolve(p));
|
|
2154
2478
|
if (values.length === 0) {
|
|
@@ -2164,10 +2488,12 @@ var RuleEngine = class {
|
|
|
2164
2488
|
}
|
|
2165
2489
|
continue;
|
|
2166
2490
|
}
|
|
2167
|
-
if (typeof value === "object" && value !== null
|
|
2168
|
-
|
|
2169
|
-
if (matchValue(match.type,
|
|
2170
|
-
|
|
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;
|
|
2171
2497
|
}
|
|
2172
2498
|
}
|
|
2173
2499
|
continue;
|
|
@@ -2199,22 +2525,27 @@ var RuleEngine = class {
|
|
|
2199
2525
|
};
|
|
2200
2526
|
|
|
2201
2527
|
// src/protect/engine/pulse-client.js
|
|
2528
|
+
init_pulse_token();
|
|
2202
2529
|
var DEFAULT_BASE_URL2 = "https://api.patchstack.com/monitor/pulse";
|
|
2203
2530
|
var DEFAULT_CACHE_TTL2 = 3e5;
|
|
2204
2531
|
var JITTER_FRACTION2 = 0.1;
|
|
2205
2532
|
var PulseRuleClient = class {
|
|
2206
2533
|
#siteUuid;
|
|
2534
|
+
#timeoutMs;
|
|
2207
2535
|
#baseUrl;
|
|
2208
2536
|
#cacheTtl;
|
|
2209
2537
|
#cache = null;
|
|
2210
2538
|
#cacheTime = null;
|
|
2211
2539
|
#ttlEffective = 0;
|
|
2212
2540
|
#etag;
|
|
2213
|
-
|
|
2541
|
+
#pulseAuth;
|
|
2542
|
+
constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth } = {}) {
|
|
2543
|
+
this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 3e4;
|
|
2214
2544
|
this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
|
|
2215
|
-
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");
|
|
2216
2546
|
this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL2;
|
|
2217
2547
|
this.#etag = etag ?? null;
|
|
2548
|
+
this.#pulseAuth = pulseAuth ?? null;
|
|
2218
2549
|
if (!this.#siteUuid) {
|
|
2219
2550
|
throw new Error("Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.");
|
|
2220
2551
|
}
|
|
@@ -2226,9 +2557,15 @@ var PulseRuleClient = class {
|
|
|
2226
2557
|
}
|
|
2227
2558
|
const url = `${this.#baseUrl}/rules/${encodeURIComponent(this.#siteUuid)}`;
|
|
2228
2559
|
try {
|
|
2229
|
-
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
|
+
};
|
|
2230
2567
|
if (this.#etag) headers["If-None-Match"] = this.#etag;
|
|
2231
|
-
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) });
|
|
2232
2569
|
if (response.status === 304) {
|
|
2233
2570
|
this.#touch(now);
|
|
2234
2571
|
return this.#cache ?? { success: true, notModified: true, etag: this.#etag, firewall: [], whitelists: [], whitelist_keys: {} };
|
|
@@ -2305,25 +2642,9 @@ async function fromFetchRequest(request, options = {}) {
|
|
|
2305
2642
|
let body = {};
|
|
2306
2643
|
let files;
|
|
2307
2644
|
if (rawBody) {
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
} catch {
|
|
2312
|
-
body = {};
|
|
2313
|
-
}
|
|
2314
|
-
} else if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
2315
|
-
body = {};
|
|
2316
|
-
for (const [k, v] of new URLSearchParams(rawBody)) {
|
|
2317
|
-
body[k] = k in body ? [].concat(body[k], v) : v;
|
|
2318
|
-
}
|
|
2319
|
-
} else if (contentType.includes("multipart/form-data")) {
|
|
2320
|
-
const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
|
|
2321
|
-
if (boundary) {
|
|
2322
|
-
const parsed = parseMultipart(rawBody, boundary);
|
|
2323
|
-
body = parsed.body;
|
|
2324
|
-
files = parsed.files;
|
|
2325
|
-
}
|
|
2326
|
-
}
|
|
2645
|
+
const parsed = parseBody(rawBody, contentType);
|
|
2646
|
+
body = parsed.body;
|
|
2647
|
+
files = parsed.files;
|
|
2327
2648
|
}
|
|
2328
2649
|
const uri = url.pathname + url.search;
|
|
2329
2650
|
const forwarded = headers["cf-connecting-ip"] || headers["x-forwarded-for"] || headers["x-real-ip"] || "";
|
|
@@ -2343,9 +2664,6 @@ async function fromFetchRequest(request, options = {}) {
|
|
|
2343
2664
|
};
|
|
2344
2665
|
}
|
|
2345
2666
|
async function readCappedText(request, max) {
|
|
2346
|
-
const ceiling = Math.max(max, max * 4);
|
|
2347
|
-
const declared = Number(request.headers?.get?.("content-length") || 0);
|
|
2348
|
-
if (declared && declared > ceiling) return "";
|
|
2349
2667
|
let clone;
|
|
2350
2668
|
try {
|
|
2351
2669
|
clone = request.clone();
|
|
@@ -2391,6 +2709,31 @@ function concatChunks(chunks, total) {
|
|
|
2391
2709
|
}
|
|
2392
2710
|
return out;
|
|
2393
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
|
+
}
|
|
2394
2737
|
function parseMultipart(rawBody, boundary) {
|
|
2395
2738
|
const body = {};
|
|
2396
2739
|
const files = {};
|
|
@@ -2405,7 +2748,9 @@ function parseMultipart(rawBody, boundary) {
|
|
|
2405
2748
|
const content = part.slice(sep.index + sep[0].length).replace(/\r?\n$/, "");
|
|
2406
2749
|
const filename = /filename="([^"]*)"/i.exec(disposition)?.[1];
|
|
2407
2750
|
if (filename !== void 0) {
|
|
2408
|
-
|
|
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;
|
|
2409
2754
|
} else {
|
|
2410
2755
|
body[name] = name in body ? [].concat(body[name], content) : content;
|
|
2411
2756
|
}
|
|
@@ -2457,25 +2802,9 @@ function fromNodeRequest(req, rawBody = "") {
|
|
|
2457
2802
|
let body = {};
|
|
2458
2803
|
let files;
|
|
2459
2804
|
if (rawBody) {
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
} catch {
|
|
2464
|
-
body = {};
|
|
2465
|
-
}
|
|
2466
|
-
} else if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
2467
|
-
body = {};
|
|
2468
|
-
for (const [k, v] of new URLSearchParams(rawBody)) {
|
|
2469
|
-
body[k] = k in body ? [].concat(body[k], v) : v;
|
|
2470
|
-
}
|
|
2471
|
-
} else if (contentType.includes("multipart/form-data")) {
|
|
2472
|
-
const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
|
|
2473
|
-
if (boundary) {
|
|
2474
|
-
const parsed = parseMultipart(rawBody, boundary);
|
|
2475
|
-
body = parsed.body;
|
|
2476
|
-
files = parsed.files;
|
|
2477
|
-
}
|
|
2478
|
-
}
|
|
2805
|
+
const parsed = parseBody(rawBody, contentType);
|
|
2806
|
+
body = parsed.body;
|
|
2807
|
+
files = parsed.files;
|
|
2479
2808
|
}
|
|
2480
2809
|
const uri = url.pathname + url.search;
|
|
2481
2810
|
const forwarded = headers["cf-connecting-ip"] || headers["x-forwarded-for"] || headers["x-real-ip"] || "";
|
|
@@ -2509,7 +2838,7 @@ function parseCookies2(header) {
|
|
|
2509
2838
|
}
|
|
2510
2839
|
|
|
2511
2840
|
// src/protect/egress.js
|
|
2512
|
-
async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, lookup, allowHosts } = {}) {
|
|
2841
|
+
async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = true, lookup, allowHosts } = {}) {
|
|
2513
2842
|
const restores = [];
|
|
2514
2843
|
if (typeof shouldBlock !== "function") return () => {
|
|
2515
2844
|
};
|
|
@@ -2531,11 +2860,24 @@ async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, look
|
|
|
2531
2860
|
screen = null;
|
|
2532
2861
|
}
|
|
2533
2862
|
}
|
|
2863
|
+
const skip = (reason, detail) => {
|
|
2864
|
+
try {
|
|
2865
|
+
onSkip?.({ phase: "egress", reason, detail });
|
|
2866
|
+
} catch {
|
|
2867
|
+
}
|
|
2868
|
+
};
|
|
2534
2869
|
const resolvesToDisallowed = (url, host, method) => new Promise((resolve) => {
|
|
2535
|
-
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);
|
|
2536
2875
|
try {
|
|
2537
2876
|
screen.lookup(host, { all: true }, (err, addresses) => {
|
|
2538
|
-
if (err || !Array.isArray(addresses))
|
|
2877
|
+
if (err || !Array.isArray(addresses)) {
|
|
2878
|
+
skip("resolver-failed", { host });
|
|
2879
|
+
return resolve(false);
|
|
2880
|
+
}
|
|
2539
2881
|
for (const a of addresses) {
|
|
2540
2882
|
const ip = a && typeof a === "object" ? a.address : a;
|
|
2541
2883
|
if (ip && block(url, ip, method)) return resolve(true);
|
|
@@ -2543,24 +2885,61 @@ async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, look
|
|
|
2543
2885
|
resolve(false);
|
|
2544
2886
|
});
|
|
2545
2887
|
} catch {
|
|
2888
|
+
skip("resolver-failed", { host });
|
|
2546
2889
|
resolve(false);
|
|
2547
2890
|
}
|
|
2548
2891
|
});
|
|
2549
2892
|
const originalFetch = globalThis.fetch;
|
|
2550
2893
|
if (typeof originalFetch === "function" && !originalFetch.__patchstackGuarded) {
|
|
2551
|
-
const
|
|
2552
|
-
|
|
2894
|
+
const REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
|
|
2895
|
+
const MAX_REDIRECTS = 20;
|
|
2896
|
+
const screenUrl = async (u, method) => {
|
|
2553
2897
|
let host = null;
|
|
2554
2898
|
try {
|
|
2555
|
-
host = new URL(
|
|
2899
|
+
host = new URL(u).hostname;
|
|
2556
2900
|
} catch {
|
|
2557
2901
|
host = null;
|
|
2558
2902
|
}
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
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;
|
|
2562
2942
|
}
|
|
2563
|
-
return originalFetch(input, init);
|
|
2564
2943
|
};
|
|
2565
2944
|
guarded.__patchstackGuarded = true;
|
|
2566
2945
|
globalThis.fetch = guarded;
|
|
@@ -2576,30 +2955,6 @@ async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, look
|
|
|
2576
2955
|
} catch {
|
|
2577
2956
|
}
|
|
2578
2957
|
}
|
|
2579
|
-
const OriginalWS = globalThis.WebSocket;
|
|
2580
|
-
if (typeof OriginalWS === "function" && !OriginalWS.__patchstackGuarded) {
|
|
2581
|
-
const GuardedWS = new Proxy(OriginalWS, {
|
|
2582
|
-
construct(target, args, newTarget) {
|
|
2583
|
-
const url = String(args?.[0] ?? "");
|
|
2584
|
-
let host = null;
|
|
2585
|
-
try {
|
|
2586
|
-
host = new URL(url).hostname;
|
|
2587
|
-
} catch {
|
|
2588
|
-
host = null;
|
|
2589
|
-
}
|
|
2590
|
-
if (block(url, host, "WEBSOCKET")) {
|
|
2591
|
-
throw new Error(`Patchstack blocked an outbound WebSocket to a disallowed address: ${host ?? url}`);
|
|
2592
|
-
}
|
|
2593
|
-
return Reflect.construct(target, args, newTarget);
|
|
2594
|
-
}
|
|
2595
|
-
});
|
|
2596
|
-
OriginalWS.__patchstackGuarded = true;
|
|
2597
|
-
globalThis.WebSocket = GuardedWS;
|
|
2598
|
-
restores.push(() => {
|
|
2599
|
-
if (globalThis.WebSocket === GuardedWS) globalThis.WebSocket = OriginalWS;
|
|
2600
|
-
delete OriginalWS.__patchstackGuarded;
|
|
2601
|
-
});
|
|
2602
|
-
}
|
|
2603
2958
|
return () => {
|
|
2604
2959
|
for (const restore of restores) {
|
|
2605
2960
|
try {
|
|
@@ -2730,6 +3085,7 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
2730
3085
|
phase: "response",
|
|
2731
3086
|
category: "secret-exposure",
|
|
2732
3087
|
action: "redact",
|
|
3088
|
+
prefilter: ["PRIVATE KEY"],
|
|
2733
3089
|
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/" } }]
|
|
2734
3090
|
},
|
|
2735
3091
|
{
|
|
@@ -2738,6 +3094,7 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
2738
3094
|
phase: "response",
|
|
2739
3095
|
category: "secret-exposure",
|
|
2740
3096
|
action: "redact",
|
|
3097
|
+
prefilter: ["AKIA", "ASIA"],
|
|
2741
3098
|
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b/" } }]
|
|
2742
3099
|
},
|
|
2743
3100
|
{
|
|
@@ -2746,14 +3103,29 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
2746
3103
|
phase: "response",
|
|
2747
3104
|
category: "secret-exposure",
|
|
2748
3105
|
action: "redact",
|
|
3106
|
+
prefilter: ["AIza"],
|
|
2749
3107
|
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\bAIza[0-9A-Za-z_-]{35}\\b/" } }]
|
|
2750
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
|
+
},
|
|
2751
3122
|
{
|
|
2752
3123
|
id: "resp-jwt",
|
|
2753
3124
|
title: "JWT in response body",
|
|
2754
3125
|
phase: "response",
|
|
2755
3126
|
category: "secret-exposure",
|
|
2756
3127
|
action: "redact",
|
|
3128
|
+
prefilter: ["eyJ"],
|
|
2757
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/" } }]
|
|
2758
3130
|
},
|
|
2759
3131
|
{
|
|
@@ -2762,6 +3134,7 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
2762
3134
|
phase: "response",
|
|
2763
3135
|
category: "secret-exposure",
|
|
2764
3136
|
action: "redact",
|
|
3137
|
+
prefilter: ["mongodb", "postgres", "mysql", "redis", "amqp"],
|
|
2765
3138
|
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[^\\s:@\\/]+:[^\\s:@\\/]+@/i" } }]
|
|
2766
3139
|
},
|
|
2767
3140
|
{
|
|
@@ -2770,6 +3143,8 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
2770
3143
|
phase: "response",
|
|
2771
3144
|
category: "info-exposure",
|
|
2772
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.
|
|
2773
3148
|
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\n\\s+at\\s+.+\\(.+:\\d+:\\d+\\)/" } }]
|
|
2774
3149
|
},
|
|
2775
3150
|
{
|
|
@@ -2778,6 +3153,7 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
2778
3153
|
phase: "response",
|
|
2779
3154
|
category: "info-exposure",
|
|
2780
3155
|
action: "redact",
|
|
3156
|
+
prefilter: ["SQLSTATE", "Sequelize", "ER_", "ORA-", "PG::", "SQLITE_ERROR", "SQL syntax"],
|
|
2781
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" } }]
|
|
2782
3158
|
},
|
|
2783
3159
|
{
|
|
@@ -2789,6 +3165,7 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
2789
3165
|
// Multi-language exception/traceback signatures a normal API response never carries:
|
|
2790
3166
|
// Python traceback, Java "Exception in thread", .NET System.*Exception, JVM stack frames,
|
|
2791
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:"],
|
|
2792
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+ \\[)/' } }]
|
|
2793
3170
|
}
|
|
2794
3171
|
];
|
|
@@ -2886,8 +3263,22 @@ function renderBlockPage({ url = "", code = "patchstack" } = {}) {
|
|
|
2886
3263
|
}
|
|
2887
3264
|
|
|
2888
3265
|
// src/protect/rules/store.js
|
|
2889
|
-
var
|
|
2890
|
-
|
|
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
|
+
}
|
|
2891
3282
|
function makeStore(options = {}) {
|
|
2892
3283
|
let mem = null;
|
|
2893
3284
|
const durable = durableTier(options);
|
|
@@ -2929,21 +3320,22 @@ function durableTier(options) {
|
|
|
2929
3320
|
write: async (env) => cacheWrite(dir, env)
|
|
2930
3321
|
};
|
|
2931
3322
|
}
|
|
2932
|
-
function
|
|
2933
|
-
return (0, import_node_path.join)(dir, "patchstack-rules.json");
|
|
2934
|
-
}
|
|
2935
|
-
function cacheWrite(dir, env) {
|
|
3323
|
+
async function cacheWrite(dir, env) {
|
|
2936
3324
|
if (!dir) return;
|
|
3325
|
+
const fs = await loadFs();
|
|
3326
|
+
if (!fs) return;
|
|
2937
3327
|
try {
|
|
2938
|
-
|
|
2939
|
-
|
|
3328
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
3329
|
+
fs.writeFileSync(fs.join(dir, "patchstack-rules.json"), JSON.stringify(env));
|
|
2940
3330
|
} catch {
|
|
2941
3331
|
}
|
|
2942
3332
|
}
|
|
2943
|
-
function cacheRead(dir) {
|
|
3333
|
+
async function cacheRead(dir) {
|
|
2944
3334
|
if (!dir) return null;
|
|
3335
|
+
const fs = await loadFs();
|
|
3336
|
+
if (!fs) return null;
|
|
2945
3337
|
try {
|
|
2946
|
-
return toEnvelope(JSON.parse(
|
|
3338
|
+
return toEnvelope(JSON.parse(fs.readFileSync(fs.join(dir, "patchstack-rules.json"), "utf8")));
|
|
2947
3339
|
} catch {
|
|
2948
3340
|
return null;
|
|
2949
3341
|
}
|
|
@@ -2959,56 +3351,204 @@ function toEnvelope(value) {
|
|
|
2959
3351
|
return null;
|
|
2960
3352
|
}
|
|
2961
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
|
+
|
|
2962
3441
|
// src/protect/rules/source.js
|
|
2963
|
-
|
|
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;
|
|
2964
3467
|
if (options.siteUuid) {
|
|
2965
3468
|
const prior = await store.read();
|
|
2966
|
-
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 });
|
|
2967
3470
|
const res = await client.getRules();
|
|
2968
|
-
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle);
|
|
3471
|
+
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
2969
3472
|
if (res.success && !res.notModified) {
|
|
2970
|
-
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);
|
|
2971
3481
|
await store.write({ bundle, etag: res.etag ?? null });
|
|
2972
3482
|
return bundle;
|
|
2973
3483
|
}
|
|
2974
3484
|
if (prior?.bundle) {
|
|
2975
3485
|
options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
|
|
2976
|
-
return normalizeBundle(prior.bundle);
|
|
3486
|
+
return normalizeBundle(prior.bundle, options);
|
|
2977
3487
|
}
|
|
2978
3488
|
if (options.rules) {
|
|
2979
3489
|
options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`));
|
|
2980
|
-
return normalizeBundle(options.rules);
|
|
3490
|
+
return normalizeBundle(options.rules, options);
|
|
2981
3491
|
}
|
|
2982
3492
|
options.onError?.(new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
|
|
2983
3493
|
return emptyBundle();
|
|
2984
3494
|
}
|
|
2985
3495
|
if (options.token) {
|
|
2986
3496
|
const prior = await store.read();
|
|
2987
|
-
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 });
|
|
2988
3498
|
const res = await client.getRules();
|
|
2989
|
-
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle);
|
|
3499
|
+
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
2990
3500
|
if (res.success && !res.notModified) {
|
|
2991
|
-
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);
|
|
2992
3509
|
await store.write({ bundle, etag: res.etag ?? null });
|
|
2993
3510
|
return bundle;
|
|
2994
3511
|
}
|
|
2995
3512
|
if (prior?.bundle) {
|
|
2996
3513
|
options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`));
|
|
2997
|
-
return normalizeBundle(prior.bundle);
|
|
3514
|
+
return normalizeBundle(prior.bundle, options);
|
|
2998
3515
|
}
|
|
2999
3516
|
options.onError?.(new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`));
|
|
3000
3517
|
return emptyBundle();
|
|
3001
3518
|
}
|
|
3002
3519
|
if (options.rules) {
|
|
3003
|
-
return normalizeBundle(options.rules);
|
|
3520
|
+
return normalizeBundle(options.rules, options);
|
|
3004
3521
|
}
|
|
3005
3522
|
return emptyBundle();
|
|
3006
3523
|
}
|
|
3007
|
-
function normalizeBundle(b) {
|
|
3524
|
+
function normalizeBundle(b, options = {}) {
|
|
3008
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
|
+
}
|
|
3009
3549
|
return {
|
|
3010
|
-
firewall:
|
|
3011
|
-
whitelists:
|
|
3550
|
+
firewall: checked.firewall,
|
|
3551
|
+
whitelists: checked.whitelists,
|
|
3012
3552
|
whitelist_keys: b.whitelist_keys ?? {},
|
|
3013
3553
|
...enforcement === "block" || enforcement === "dry-run" ? { enforcement } : {}
|
|
3014
3554
|
};
|
|
@@ -3075,7 +3615,7 @@ function makeRefreshHandler(tick, secret) {
|
|
|
3075
3615
|
var DEFAULT_API_BASE = "https://api.patchstack.com";
|
|
3076
3616
|
var DEFAULT_FLUSH_MS = 1e3;
|
|
3077
3617
|
var MAX_BATCH = 50;
|
|
3078
|
-
var
|
|
3618
|
+
var TOKEN_SKEW_MS2 = 6e4;
|
|
3079
3619
|
function parseApiKey(apiKey) {
|
|
3080
3620
|
if (typeof apiKey !== "string" || apiKey.length === 0) return null;
|
|
3081
3621
|
const idx = apiKey.lastIndexOf("-");
|
|
@@ -3088,7 +3628,11 @@ function parseApiKey(apiKey) {
|
|
|
3088
3628
|
function resolveApiBase(pulseOrManifestUrl) {
|
|
3089
3629
|
const fromEnv = typeof process !== "undefined" ? process.env?.PATCHSTACK_API_BASE : void 0;
|
|
3090
3630
|
if (typeof fromEnv === "string" && fromEnv.length > 0) {
|
|
3091
|
-
|
|
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
|
+
);
|
|
3092
3636
|
}
|
|
3093
3637
|
if (typeof pulseOrManifestUrl === "string" && pulseOrManifestUrl.length > 0) {
|
|
3094
3638
|
try {
|
|
@@ -3123,7 +3667,7 @@ function createFirewallLogReporter(opts) {
|
|
|
3123
3667
|
let cachedToken = null;
|
|
3124
3668
|
let tokenInflight = null;
|
|
3125
3669
|
const fetchAccessToken = async () => {
|
|
3126
|
-
if (cachedToken && Date.now() < cachedToken.expiresAt -
|
|
3670
|
+
if (cachedToken && Date.now() < cachedToken.expiresAt - TOKEN_SKEW_MS2) {
|
|
3127
3671
|
return cachedToken.token;
|
|
3128
3672
|
}
|
|
3129
3673
|
if (tokenInflight) return tokenInflight;
|
|
@@ -3230,10 +3774,6 @@ function telemetryEnabled() {
|
|
|
3230
3774
|
return !/^(0|false|off|no)$/i.test(String(v));
|
|
3231
3775
|
}
|
|
3232
3776
|
|
|
3233
|
-
// src/protect/runtime.js
|
|
3234
|
-
var import_node_fs2 = require("fs");
|
|
3235
|
-
var import_node_path8 = require("path");
|
|
3236
|
-
|
|
3237
3777
|
// src/protect/supabase-guard.js
|
|
3238
3778
|
var GUARD_PATH = "/_patchstack/guard";
|
|
3239
3779
|
var HOP_BY_HOP = /* @__PURE__ */ new Set(["content-encoding", "content-length", "transfer-encoding", "connection"]);
|
|
@@ -3307,7 +3847,7 @@ function createServerFnGuard({ protection }) {
|
|
|
3307
3847
|
async function createProtection(options = {}) {
|
|
3308
3848
|
const onError = options.onError;
|
|
3309
3849
|
const userOnDetect = options.onDetect ?? defaultOnDetect;
|
|
3310
|
-
const apiKey = resolveApiKey(options);
|
|
3850
|
+
const apiKey = await resolveApiKey(options);
|
|
3311
3851
|
const firewallLog = apiKey && telemetryEnabled() && options.reportFirewallLog !== false ? createFirewallLogReporter({
|
|
3312
3852
|
apiKey,
|
|
3313
3853
|
apiBase: resolveApiBase(options.pulseRulesUrl ?? options.baseUrl),
|
|
@@ -3327,7 +3867,9 @@ async function createProtection(options = {}) {
|
|
|
3327
3867
|
}
|
|
3328
3868
|
};
|
|
3329
3869
|
const store = makeStore(options);
|
|
3330
|
-
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 });
|
|
3331
3873
|
let mode = resolveMode(options, bundle);
|
|
3332
3874
|
let requestRules;
|
|
3333
3875
|
let responseRules;
|
|
@@ -3351,17 +3893,42 @@ async function createProtection(options = {}) {
|
|
|
3351
3893
|
responseRuleSet = responseRules.map((rule) => ({
|
|
3352
3894
|
rule,
|
|
3353
3895
|
engine: new RuleEngine({ firewall: [rule], onError }),
|
|
3354
|
-
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
|
|
3355
3906
|
}));
|
|
3356
3907
|
egressEngine = new RuleEngine({ firewall: egressRules, onError });
|
|
3357
3908
|
};
|
|
3358
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
|
+
};
|
|
3359
3922
|
const maskFn = typeof options.maskWith === "function" ? options.maskWith : () => typeof options.maskWith === "string" ? options.maskWith : "[REDACTED]";
|
|
3923
|
+
const ruleMode = (rule) => rule?.enforcement === "dry-run" ? "dry-run" : mode;
|
|
3360
3924
|
const decide = (phase, result, block, allow2, ctx = {}) => {
|
|
3361
3925
|
if (!result || !result.blocked) return allow2();
|
|
3926
|
+
const effectiveMode = ruleMode(result.rule);
|
|
3362
3927
|
onDetect({
|
|
3363
3928
|
phase,
|
|
3364
|
-
mode,
|
|
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,
|
|
3365
3932
|
category: result.rule?.category,
|
|
3366
3933
|
rule: result.rule,
|
|
3367
3934
|
message: result.message,
|
|
@@ -3370,30 +3937,40 @@ async function createProtection(options = {}) {
|
|
|
3370
3937
|
ip: ctx.ip,
|
|
3371
3938
|
userAgent: ctx.userAgent
|
|
3372
3939
|
});
|
|
3373
|
-
return
|
|
3374
|
-
};
|
|
3375
|
-
const isTextCT = (ct) => {
|
|
3376
|
-
ct = (ct || "").toLowerCase();
|
|
3377
|
-
return ct === "" || /(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(ct);
|
|
3940
|
+
return effectiveMode === "block" ? block() : allow2();
|
|
3378
3941
|
};
|
|
3379
|
-
const screenText = (text, meta) => {
|
|
3942
|
+
const screenText = (text, meta, reqCtx) => {
|
|
3380
3943
|
let blockRule = null;
|
|
3381
3944
|
const redactions = [];
|
|
3382
|
-
|
|
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
|
+
}
|
|
3383
3952
|
let result;
|
|
3384
3953
|
try {
|
|
3385
|
-
result = re.evaluate({ _response: { ...meta, body: text } });
|
|
3954
|
+
result = re.evaluate({ ...reqCtx || {}, _response: { ...meta, body: text } });
|
|
3386
3955
|
} catch (err) {
|
|
3387
3956
|
onError?.(err);
|
|
3388
3957
|
continue;
|
|
3389
3958
|
}
|
|
3390
3959
|
if (!result.blocked) continue;
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
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);
|
|
3394
3971
|
else if (!blockRule) blockRule = rule;
|
|
3395
3972
|
}
|
|
3396
|
-
if (mode !== "block" || !blockRule && !redactions.length) return { verdict: "pass" };
|
|
3973
|
+
if (mode !== "block" || !blockRule && !redactions.length && !headerMutations.length) return { verdict: "pass" };
|
|
3397
3974
|
if (blockRule) return { verdict: "block" };
|
|
3398
3975
|
let body = text;
|
|
3399
3976
|
const headers = { ...meta.headers || {} };
|
|
@@ -3415,17 +3992,33 @@ async function createProtection(options = {}) {
|
|
|
3415
3992
|
}
|
|
3416
3993
|
}
|
|
3417
3994
|
}
|
|
3995
|
+
for (const rule of headerMutations) applyHeaderMutation(headers, rule);
|
|
3418
3996
|
return { verdict: "redact", body, headers };
|
|
3419
3997
|
};
|
|
3420
|
-
const
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
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);
|
|
3424
4017
|
if (r.verdict === "block") return leakResponse();
|
|
3425
4018
|
if (r.verdict === "redact") return rebuildResponse(response, r.body, r.headers);
|
|
3426
4019
|
return response;
|
|
3427
4020
|
};
|
|
3428
|
-
const wrapNodeResponse = (res) => {
|
|
4021
|
+
const wrapNodeResponse = (res, reqCtx) => {
|
|
3429
4022
|
const origWrite = res.write.bind(res);
|
|
3430
4023
|
const origEnd = res.end.bind(res);
|
|
3431
4024
|
const chunks = [];
|
|
@@ -3441,6 +4034,7 @@ async function createProtection(options = {}) {
|
|
|
3441
4034
|
chunks.length = 0;
|
|
3442
4035
|
origWrite(buf);
|
|
3443
4036
|
overflow = true;
|
|
4037
|
+
recordSkip("response", "body-cap", { bytes: size });
|
|
3444
4038
|
return;
|
|
3445
4039
|
}
|
|
3446
4040
|
chunks.push(buf);
|
|
@@ -3467,16 +4061,19 @@ async function createProtection(options = {}) {
|
|
|
3467
4061
|
}
|
|
3468
4062
|
collect(chunk, enc);
|
|
3469
4063
|
if (overflow) return origEnd(cb);
|
|
3470
|
-
const
|
|
4064
|
+
const buffer = Buffer.concat(chunks);
|
|
3471
4065
|
let ct = res.getHeader ? res.getHeader("content-type") : void 0;
|
|
3472
4066
|
if (Array.isArray(ct)) ct = ct[0];
|
|
3473
|
-
|
|
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");
|
|
3474
4070
|
for (const c of chunks) origWrite(c);
|
|
3475
4071
|
return origEnd(cb);
|
|
3476
4072
|
}
|
|
4073
|
+
const text = buffer.toString("utf8");
|
|
3477
4074
|
let r;
|
|
3478
4075
|
try {
|
|
3479
|
-
r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} });
|
|
4076
|
+
r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} }, reqCtx);
|
|
3480
4077
|
} catch (err) {
|
|
3481
4078
|
onError?.(err);
|
|
3482
4079
|
for (const c of chunks) origWrite(c);
|
|
@@ -3499,7 +4096,12 @@ async function createProtection(options = {}) {
|
|
|
3499
4096
|
const current = res.getHeaders ? res.getHeaders() : {};
|
|
3500
4097
|
for (const [name, value] of Object.entries(r.headers)) {
|
|
3501
4098
|
if (name.toLowerCase() === "content-length") continue;
|
|
3502
|
-
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)) {
|
|
3503
4105
|
try {
|
|
3504
4106
|
res.setHeader(name, value);
|
|
3505
4107
|
} catch {
|
|
@@ -3529,8 +4131,9 @@ async function createProtection(options = {}) {
|
|
|
3529
4131
|
return false;
|
|
3530
4132
|
}
|
|
3531
4133
|
if (!result.blocked) return false;
|
|
3532
|
-
|
|
3533
|
-
|
|
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";
|
|
3534
4137
|
};
|
|
3535
4138
|
const protection = {
|
|
3536
4139
|
get mode() {
|
|
@@ -3539,9 +4142,18 @@ async function createProtection(options = {}) {
|
|
|
3539
4142
|
get rules() {
|
|
3540
4143
|
return { request: requestRules, response: responseRules, egress: egressRules };
|
|
3541
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
|
+
},
|
|
3542
4154
|
// Screen a fetch Response through the response-phase rules (redact/block). Used by
|
|
3543
4155
|
// .fetch(), and by the Supabase guard on its forwarded upstream response.
|
|
3544
|
-
screenResponse: (response) => screenResp(response),
|
|
4156
|
+
screenResponse: (response, request) => screenResp(response, request ? reqContextFromFetch(request) : void 0),
|
|
3545
4157
|
// (request) => Response | null (null = allow, caller proceeds). Request phase only.
|
|
3546
4158
|
fetchGuard() {
|
|
3547
4159
|
return async (request) => {
|
|
@@ -3562,7 +4174,7 @@ async function createProtection(options = {}) {
|
|
|
3562
4174
|
const blocked = await guard(request);
|
|
3563
4175
|
if (blocked) return blocked;
|
|
3564
4176
|
const response = await handler(request, ...rest);
|
|
3565
|
-
return screenResp(response);
|
|
4177
|
+
return screenResp(response, reqContextFromFetch(request));
|
|
3566
4178
|
};
|
|
3567
4179
|
},
|
|
3568
4180
|
// Express middleware (request phase; expects express-parsed req.query/req.body).
|
|
@@ -3574,7 +4186,7 @@ async function createProtection(options = {}) {
|
|
|
3574
4186
|
result = engine.evaluate(req);
|
|
3575
4187
|
} catch (err) {
|
|
3576
4188
|
onError?.(err);
|
|
3577
|
-
if (exprOptions.screenResponses) wrapNodeResponse(res);
|
|
4189
|
+
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
3578
4190
|
return next();
|
|
3579
4191
|
}
|
|
3580
4192
|
decide(
|
|
@@ -3588,7 +4200,7 @@ async function createProtection(options = {}) {
|
|
|
3588
4200
|
}
|
|
3589
4201
|
},
|
|
3590
4202
|
() => {
|
|
3591
|
-
if (exprOptions.screenResponses) wrapNodeResponse(res);
|
|
4203
|
+
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
3592
4204
|
next();
|
|
3593
4205
|
},
|
|
3594
4206
|
nodeRequestMeta(req)
|
|
@@ -3616,6 +4228,7 @@ async function createProtection(options = {}) {
|
|
|
3616
4228
|
next();
|
|
3617
4229
|
});
|
|
3618
4230
|
req.on("end", () => {
|
|
4231
|
+
if (overflow) recordSkip("request", "body-cap", { bytes: size, limit: maxBytes });
|
|
3619
4232
|
const rawBody = overflow ? "" : Buffer.concat(chunks).toString("utf8");
|
|
3620
4233
|
let shaped;
|
|
3621
4234
|
let result;
|
|
@@ -3641,7 +4254,7 @@ async function createProtection(options = {}) {
|
|
|
3641
4254
|
},
|
|
3642
4255
|
() => {
|
|
3643
4256
|
if (req.body === void 0) req.body = shaped.body;
|
|
3644
|
-
if (nodeOptions.screenResponses) wrapNodeResponse(res);
|
|
4257
|
+
if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
3645
4258
|
next();
|
|
3646
4259
|
},
|
|
3647
4260
|
nodeRequestMeta(req)
|
|
@@ -3654,6 +4267,9 @@ async function createProtection(options = {}) {
|
|
|
3654
4267
|
protection.uninstallEgress = await installEgressGuard({
|
|
3655
4268
|
shouldBlock: egressShouldBlock,
|
|
3656
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),
|
|
3657
4273
|
dnsScreen: options.screenDns !== false,
|
|
3658
4274
|
allowHosts: options.allowHosts
|
|
3659
4275
|
});
|
|
@@ -3678,7 +4294,7 @@ async function createProtection(options = {}) {
|
|
|
3678
4294
|
onError?.(err);
|
|
3679
4295
|
}
|
|
3680
4296
|
}
|
|
3681
|
-
const next = await resolveRules(options, store);
|
|
4297
|
+
const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
|
|
3682
4298
|
mode = resolveMode(options, next);
|
|
3683
4299
|
applyBundle(next);
|
|
3684
4300
|
};
|
|
@@ -3705,7 +4321,7 @@ function resolveMode(options, bundle) {
|
|
|
3705
4321
|
if (options?.mode === "dry-run") return "dry-run";
|
|
3706
4322
|
return "dry-run";
|
|
3707
4323
|
}
|
|
3708
|
-
function resolveApiKey(options) {
|
|
4324
|
+
async function resolveApiKey(options) {
|
|
3709
4325
|
if (typeof options?.apiKey === "string" && options.apiKey.length > 0) return options.apiKey;
|
|
3710
4326
|
if (typeof process !== "undefined") {
|
|
3711
4327
|
const fromEnv = process.env?.PATCHSTACK_API_KEY;
|
|
@@ -3713,29 +4329,69 @@ function resolveApiKey(options) {
|
|
|
3713
4329
|
}
|
|
3714
4330
|
try {
|
|
3715
4331
|
if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
|
|
4332
|
+
const [{ readFileSync }, { join }] = await Promise.all([import("fs"), import("path")]);
|
|
3716
4333
|
const cwd = options?.cwd ?? process.cwd();
|
|
3717
|
-
const raw =
|
|
4334
|
+
const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
|
|
3718
4335
|
const key = JSON.parse(raw)?.apiKey;
|
|
3719
4336
|
if (typeof key === "string" && key.length > 0) return key;
|
|
3720
4337
|
} catch {
|
|
3721
4338
|
}
|
|
3722
4339
|
return void 0;
|
|
3723
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
|
+
}
|
|
3724
4358
|
function byPhase(rules, phase) {
|
|
3725
4359
|
return (rules ?? []).filter((r) => (r.phase ?? "request") === phase);
|
|
3726
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
|
+
}
|
|
3727
4382
|
async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
|
|
3728
|
-
if (!response || typeof response.clone !== "function") return
|
|
3729
|
-
const ct =
|
|
3730
|
-
const
|
|
3731
|
-
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";
|
|
3732
4388
|
const len = Number(response.headers?.get?.("content-length") || 0);
|
|
3733
|
-
if (len && len > cap) return
|
|
4389
|
+
if (len && len > cap) return { skip: "body-cap" };
|
|
3734
4390
|
let clone;
|
|
3735
4391
|
try {
|
|
3736
4392
|
clone = response.clone();
|
|
3737
4393
|
} catch {
|
|
3738
|
-
return
|
|
4394
|
+
return { skip: "clone-failed" };
|
|
3739
4395
|
}
|
|
3740
4396
|
const body = clone.body;
|
|
3741
4397
|
if (body && typeof body.getReader === "function") {
|
|
@@ -3743,11 +4399,16 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
|
|
|
3743
4399
|
const chunks = [];
|
|
3744
4400
|
let size = 0;
|
|
3745
4401
|
let over = false;
|
|
4402
|
+
let sniffed = !sniff;
|
|
3746
4403
|
try {
|
|
3747
4404
|
for (; ; ) {
|
|
3748
4405
|
const { done, value } = await reader.read();
|
|
3749
4406
|
if (done) break;
|
|
3750
4407
|
if (!value) continue;
|
|
4408
|
+
if (!sniffed) {
|
|
4409
|
+
sniffed = true;
|
|
4410
|
+
if (looksBinary(value)) return { skip: "binary-body" };
|
|
4411
|
+
}
|
|
3751
4412
|
size += value.byteLength;
|
|
3752
4413
|
if (over) continue;
|
|
3753
4414
|
if (size > cap) {
|
|
@@ -3757,20 +4418,22 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
|
|
|
3757
4418
|
chunks.push(value);
|
|
3758
4419
|
}
|
|
3759
4420
|
} catch {
|
|
3760
|
-
return
|
|
4421
|
+
return { skip: "read-failed" };
|
|
3761
4422
|
}
|
|
3762
|
-
if (over) return
|
|
4423
|
+
if (over) return { skip: "body-cap" };
|
|
3763
4424
|
try {
|
|
3764
|
-
return new TextDecoder().decode(concatBytes(chunks, size));
|
|
4425
|
+
return { text: new TextDecoder().decode(concatBytes(chunks, size)) };
|
|
3765
4426
|
} catch {
|
|
3766
|
-
return
|
|
4427
|
+
return { skip: "decode-failed" };
|
|
3767
4428
|
}
|
|
3768
4429
|
}
|
|
3769
4430
|
try {
|
|
3770
4431
|
const text = await clone.text();
|
|
3771
|
-
|
|
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 };
|
|
3772
4435
|
} catch {
|
|
3773
|
-
return
|
|
4436
|
+
return { skip: "read-failed" };
|
|
3774
4437
|
}
|
|
3775
4438
|
}
|
|
3776
4439
|
function concatBytes(chunks, total) {
|
|
@@ -3791,6 +4454,19 @@ function headerObject(headers) {
|
|
|
3791
4454
|
if (setCookies && setCookies.length) out["set-cookie"] = setCookies;
|
|
3792
4455
|
return out;
|
|
3793
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
|
+
}
|
|
3794
4470
|
function extractRedactors(rule) {
|
|
3795
4471
|
const out = [];
|
|
3796
4472
|
const walk2 = (conds) => {
|
|
@@ -3827,7 +4503,10 @@ function applyRedactors(body, redactors, mask, transform) {
|
|
|
3827
4503
|
let out = body;
|
|
3828
4504
|
for (const r of redactors) {
|
|
3829
4505
|
if (r.re) out = out.replace(r.re, transform ? (m) => transform(m) : mask);
|
|
3830
|
-
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
|
+
}
|
|
3831
4510
|
}
|
|
3832
4511
|
return out;
|
|
3833
4512
|
}
|
|
@@ -3930,12 +4609,52 @@ function restoreBigInts(text) {
|
|
|
3930
4609
|
if (!text.includes(BIGINT_OPEN)) return text;
|
|
3931
4610
|
return text.replace(new RegExp(`"${BIGINT_OPEN}(-?\\d+)${BIGINT_CLOSE}"`, "g"), "$1");
|
|
3932
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
|
+
}
|
|
3933
4647
|
function rebuildResponse(response, body, redactedHeaders) {
|
|
3934
4648
|
const headers = new Headers(response.headers);
|
|
3935
4649
|
headers.delete("content-length");
|
|
3936
4650
|
if (redactedHeaders) {
|
|
3937
4651
|
for (const [name, value] of Object.entries(redactedHeaders)) {
|
|
3938
|
-
if (
|
|
4652
|
+
if (value === null || value === void 0) {
|
|
4653
|
+
try {
|
|
4654
|
+
headers.delete(name);
|
|
4655
|
+
} catch {
|
|
4656
|
+
}
|
|
4657
|
+
} else if (typeof value === "string") {
|
|
3939
4658
|
if (headers.get(name) !== value) {
|
|
3940
4659
|
try {
|
|
3941
4660
|
headers.set(name, value);
|
|
@@ -3951,7 +4670,8 @@ function rebuildResponse(response, body, redactedHeaders) {
|
|
|
3951
4670
|
}
|
|
3952
4671
|
}
|
|
3953
4672
|
}
|
|
3954
|
-
|
|
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 });
|
|
3955
4675
|
}
|
|
3956
4676
|
function leakResponse() {
|
|
3957
4677
|
return new Response(JSON.stringify({ error: "Response withheld by Patchstack (sensitive data detected)" }), {
|