@patchstack/connect 0.3.28 → 0.3.30
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 +37 -21
- package/README.md +24 -10
- package/dist/{chunk-MJOTFUDE.js → chunk-LLKP5EJS.js} +1 -1
- package/dist/chunk-LLKP5EJS.js.map +1 -0
- package/dist/cli.js +1267 -224
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +419 -104
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +28 -9
- package/dist/index.d.ts +28 -9
- package/dist/index.js +412 -97
- package/dist/index.js.map +1 -1
- package/dist/protect/templates/astro-middleware.ts +33 -13
- package/dist/protect/templates/demo-rules.json +2 -3
- package/dist/protect/templates/express-guard.cjs +26 -15
- package/dist/protect/templates/express-guard.js +26 -15
- package/dist/protect/templates/express-guard.ts +33 -16
- package/dist/protect/templates/fastify-plugin.cjs +33 -11
- package/dist/protect/templates/fastify-plugin.js +33 -11
- package/dist/protect/templates/fastify-plugin.ts +40 -12
- package/dist/protect/templates/generic-guard.cjs +28 -12
- package/dist/protect/templates/generic-guard.js +28 -13
- package/dist/protect/templates/generic-guard.ts +41 -20
- package/dist/protect/templates/guard.ts +47 -27
- package/dist/protect/templates/next-middleware.ts +29 -12
- package/dist/protect/templates/nuxt-middleware.ts +29 -12
- package/dist/protect/templates/rules.json +2 -2
- package/dist/protect/templates/sveltekit-hooks.ts +33 -13
- package/dist/protect.cjs +1039 -235
- package/dist/protect.cjs.map +1 -1
- package/dist/protect.d.ts +39 -11
- package/dist/protect.edge.js +663 -132
- package/dist/protect.edge.js.map +4 -4
- package/dist/protect.js +665 -134
- package/dist/protect.js.map +1 -1
- package/dist/{refresh-manifest-VRBE6RH6.js → refresh-manifest-ZSP76JWQ.js} +351 -94
- package/dist/refresh-manifest-ZSP76JWQ.js.map +1 -0
- package/package.json +5 -2
- package/dist/chunk-MJOTFUDE.js.map +0 -1
- package/dist/refresh-manifest-VRBE6RH6.js.map +0 -1
package/dist/protect.cjs
CHANGED
|
@@ -237,6 +237,7 @@ var init_client = __esm({
|
|
|
237
237
|
// src/config.ts
|
|
238
238
|
async function resolveConfig(options) {
|
|
239
239
|
const fromFile = await readConfigFile(options.cwd);
|
|
240
|
+
const fromSecretFile = await readSecretFile(options.cwd);
|
|
240
241
|
const fromEnv = readEnv();
|
|
241
242
|
const siteUuid = options.cliSiteUuid ?? fromEnv.siteUuid ?? fromFile.siteUuid ?? null;
|
|
242
243
|
const endpoint = options.cliEndpoint ?? fromEnv.endpoint ?? fromFile.endpoint ?? DEFAULT_ENDPOINT;
|
|
@@ -261,8 +262,8 @@ async function resolveConfig(options) {
|
|
|
261
262
|
"CONFIG_MISSING"
|
|
262
263
|
);
|
|
263
264
|
}
|
|
264
|
-
const apiKeyRaw = fromEnv.apiKey ?? fromFile.apiKey ?? null;
|
|
265
|
-
const pulseAuthRaw = fromEnv.pulseAuth ?? fromFile.pulseAuth ?? apiKeyRaw;
|
|
265
|
+
const apiKeyRaw = fromEnv.apiKey ?? fromSecretFile.apiKey ?? fromFile.apiKey ?? null;
|
|
266
|
+
const pulseAuthRaw = fromEnv.pulseAuth ?? fromSecretFile.pulseAuth ?? fromFile.pulseAuth ?? apiKeyRaw;
|
|
266
267
|
return {
|
|
267
268
|
siteUuid: siteUuid === null || siteUuid.length === 0 ? null : siteUuid,
|
|
268
269
|
apiKey: apiKeyRaw === null || apiKeyRaw.length === 0 ? null : apiKeyRaw,
|
|
@@ -273,8 +274,15 @@ async function resolveConfig(options) {
|
|
|
273
274
|
widget: fromFile.widget !== false
|
|
274
275
|
};
|
|
275
276
|
}
|
|
277
|
+
async function readSecretFile(cwd) {
|
|
278
|
+
const file = await readJsonFile(cwd, SECRET_FILENAME);
|
|
279
|
+
return { apiKey: file.apiKey, pulseAuth: file.pulseAuth };
|
|
280
|
+
}
|
|
276
281
|
async function readConfigFile(cwd) {
|
|
277
|
-
|
|
282
|
+
return readJsonFile(cwd, CONFIG_FILENAME);
|
|
283
|
+
}
|
|
284
|
+
async function readJsonFile(cwd, filename) {
|
|
285
|
+
const target = import_node_path.default.join(cwd, filename);
|
|
278
286
|
let raw;
|
|
279
287
|
try {
|
|
280
288
|
raw = await (0, import_promises.readFile)(target, "utf8");
|
|
@@ -327,7 +335,7 @@ function isUuid(value) {
|
|
|
327
335
|
function isEnvironment(value) {
|
|
328
336
|
return value === "production" || value === "sandbox";
|
|
329
337
|
}
|
|
330
|
-
var import_promises, import_node_path, CONFIG_FILENAME, DEFAULT_ENVIRONMENT;
|
|
338
|
+
var import_promises, import_node_path, CONFIG_FILENAME, SECRET_FILENAME, DEFAULT_ENVIRONMENT;
|
|
331
339
|
var init_config = __esm({
|
|
332
340
|
"src/config.ts"() {
|
|
333
341
|
"use strict";
|
|
@@ -336,6 +344,7 @@ var init_config = __esm({
|
|
|
336
344
|
init_types();
|
|
337
345
|
init_client();
|
|
338
346
|
CONFIG_FILENAME = ".patchstackrc.json";
|
|
347
|
+
SECRET_FILENAME = ".patchstackrc.local.json";
|
|
339
348
|
DEFAULT_ENVIRONMENT = "production";
|
|
340
349
|
}
|
|
341
350
|
});
|
|
@@ -358,7 +367,7 @@ async function readDeclaredDependencyNames(cwd) {
|
|
|
358
367
|
return [];
|
|
359
368
|
}
|
|
360
369
|
const names = /* @__PURE__ */ new Set();
|
|
361
|
-
for (const group of ["dependencies", "devDependencies"]) {
|
|
370
|
+
for (const group of ["dependencies", "devDependencies", "optionalDependencies"]) {
|
|
362
371
|
const deps = parsed[group];
|
|
363
372
|
if (typeof deps !== "object" || deps === null) {
|
|
364
373
|
continue;
|
|
@@ -376,6 +385,23 @@ function missingDependencies(declared, packages) {
|
|
|
376
385
|
const present = new Set(packages.map((entry) => entry.name));
|
|
377
386
|
return declared.filter((name) => !present.has(name));
|
|
378
387
|
}
|
|
388
|
+
function disagreements(packages, other) {
|
|
389
|
+
const otherVersions = /* @__PURE__ */ new Map();
|
|
390
|
+
for (const entry of other) {
|
|
391
|
+
if (!otherVersions.has(entry.name)) otherVersions.set(entry.name, entry.version);
|
|
392
|
+
}
|
|
393
|
+
const seen = /* @__PURE__ */ new Set();
|
|
394
|
+
const conflicts = [];
|
|
395
|
+
for (const entry of packages) {
|
|
396
|
+
if (seen.has(entry.name)) continue;
|
|
397
|
+
seen.add(entry.name);
|
|
398
|
+
const otherVersion = otherVersions.get(entry.name);
|
|
399
|
+
if (otherVersion !== void 0 && otherVersion !== entry.version) {
|
|
400
|
+
conflicts.push({ name: entry.name, version: entry.version, otherVersion });
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return conflicts;
|
|
404
|
+
}
|
|
379
405
|
var import_promises2, import_node_path2;
|
|
380
406
|
var init_consistency = __esm({
|
|
381
407
|
"src/parsers/consistency.ts"() {
|
|
@@ -385,11 +411,163 @@ var init_consistency = __esm({
|
|
|
385
411
|
}
|
|
386
412
|
});
|
|
387
413
|
|
|
414
|
+
// src/parsers/report.ts
|
|
415
|
+
function newReport() {
|
|
416
|
+
return { unreadable: 0, samples: [] };
|
|
417
|
+
}
|
|
418
|
+
function recordUnreadable(report, sample) {
|
|
419
|
+
if (!report) return;
|
|
420
|
+
report.unreadable++;
|
|
421
|
+
if (report.samples.length < MAX_SAMPLES) report.samples.push(sample);
|
|
422
|
+
}
|
|
423
|
+
function unreadableWarning(filename, report) {
|
|
424
|
+
if (report.unreadable === 0) return null;
|
|
425
|
+
const shown = report.samples.join(", ");
|
|
426
|
+
const one = report.unreadable === 1;
|
|
427
|
+
return `${filename}: ${report.unreadable} entr${one ? "y was" : "ies were"} not in a form this scanner reads (${shown}${report.unreadable > report.samples.length ? ", \u2026" : ""}), so ${one ? "it is" : "they are"} absent from this manifest. Anything installed only through ${one ? "that entry" : "those entries"} is not being checked.`;
|
|
428
|
+
}
|
|
429
|
+
var MAX_SAMPLES;
|
|
430
|
+
var init_report = __esm({
|
|
431
|
+
"src/parsers/report.ts"() {
|
|
432
|
+
"use strict";
|
|
433
|
+
MAX_SAMPLES = 3;
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// src/parsers/bun.ts
|
|
438
|
+
async function parseBunLockfile(lockfilePath, report) {
|
|
439
|
+
let raw;
|
|
440
|
+
try {
|
|
441
|
+
raw = await (0, import_promises3.readFile)(lockfilePath, "utf8");
|
|
442
|
+
} catch (cause) {
|
|
443
|
+
throw new PatchstackError(`Could not read lockfile at ${lockfilePath}`, "LOCKFILE_NOT_FOUND", cause);
|
|
444
|
+
}
|
|
445
|
+
let parsed;
|
|
446
|
+
try {
|
|
447
|
+
parsed = JSON.parse(stripTrailingCommas(raw));
|
|
448
|
+
} catch (cause) {
|
|
449
|
+
throw new PatchstackError(`Lockfile at ${lockfilePath} is not valid JSON`, "LOCKFILE_PARSE_ERROR", cause);
|
|
450
|
+
}
|
|
451
|
+
const packages = parsed?.packages;
|
|
452
|
+
if (typeof packages !== "object" || packages === null) {
|
|
453
|
+
throw new PatchstackError(`Lockfile at ${lockfilePath} has no "packages" entries`, "LOCKFILE_PARSE_ERROR");
|
|
454
|
+
}
|
|
455
|
+
const direct = directDependencyNames(parsed);
|
|
456
|
+
const entries = [];
|
|
457
|
+
let skipped = 0;
|
|
458
|
+
for (const [key, value] of Object.entries(packages)) {
|
|
459
|
+
const descriptor = Array.isArray(value) ? value[0] : value;
|
|
460
|
+
if (typeof descriptor !== "string") {
|
|
461
|
+
skipped++;
|
|
462
|
+
recordUnreadable(report, key);
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
const split = splitDescriptor(descriptor);
|
|
466
|
+
if (split === null) {
|
|
467
|
+
skipped++;
|
|
468
|
+
if (!isDeliberatelyExcluded(descriptor)) recordUnreadable(report, key);
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
entries.push({
|
|
472
|
+
name: split.name,
|
|
473
|
+
version: split.version,
|
|
474
|
+
...direct.has(split.name) ? { direct: true } : {},
|
|
475
|
+
...key === split.name ? {} : { path: key }
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
if (entries.length === 0) {
|
|
479
|
+
throw new PatchstackError(
|
|
480
|
+
`Lockfile at ${lockfilePath} lists no registry packages${skipped > 0 ? ` (${skipped} non-registry entr${skipped === 1 ? "y" : "ies"} skipped)` : ""}`,
|
|
481
|
+
"LOCKFILE_PARSE_ERROR"
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
return entries;
|
|
485
|
+
}
|
|
486
|
+
function splitDescriptor(descriptor) {
|
|
487
|
+
const at = descriptor.lastIndexOf("@");
|
|
488
|
+
if (at <= 0) return null;
|
|
489
|
+
const name = descriptor.slice(0, at);
|
|
490
|
+
const version = descriptor.slice(at + 1);
|
|
491
|
+
if (name === "" || version === "") return null;
|
|
492
|
+
if (NON_REGISTRY_VERSION.test(version)) return null;
|
|
493
|
+
return { name, version };
|
|
494
|
+
}
|
|
495
|
+
function isDeliberatelyExcluded(descriptor) {
|
|
496
|
+
const at = descriptor.lastIndexOf("@");
|
|
497
|
+
if (at <= 0) return false;
|
|
498
|
+
return NON_REGISTRY_VERSION.test(descriptor.slice(at + 1));
|
|
499
|
+
}
|
|
500
|
+
function directDependencyNames(parsed) {
|
|
501
|
+
const names = /* @__PURE__ */ new Set();
|
|
502
|
+
const workspaces = parsed?.workspaces;
|
|
503
|
+
if (typeof workspaces !== "object" || workspaces === null) return names;
|
|
504
|
+
for (const workspace of Object.values(workspaces)) {
|
|
505
|
+
if (typeof workspace !== "object" || workspace === null) continue;
|
|
506
|
+
for (const group of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) {
|
|
507
|
+
const deps = workspace[group];
|
|
508
|
+
if (typeof deps !== "object" || deps === null) continue;
|
|
509
|
+
for (const name of Object.keys(deps)) names.add(name);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return names;
|
|
513
|
+
}
|
|
514
|
+
function stripTrailingCommas(source) {
|
|
515
|
+
const out = [];
|
|
516
|
+
let inString = false;
|
|
517
|
+
let pendingComma = -1;
|
|
518
|
+
for (let i = 0; i < source.length; i++) {
|
|
519
|
+
const ch = source[i];
|
|
520
|
+
if (inString) {
|
|
521
|
+
out.push(ch);
|
|
522
|
+
if (ch === "\\") {
|
|
523
|
+
const next = source[i + 1];
|
|
524
|
+
if (next !== void 0) {
|
|
525
|
+
out.push(next);
|
|
526
|
+
i++;
|
|
527
|
+
}
|
|
528
|
+
} else if (ch === '"') {
|
|
529
|
+
inString = false;
|
|
530
|
+
}
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (ch === '"') {
|
|
534
|
+
inString = true;
|
|
535
|
+
pendingComma = -1;
|
|
536
|
+
out.push(ch);
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
if (ch === ",") {
|
|
540
|
+
pendingComma = out.length;
|
|
541
|
+
out.push(ch);
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
if (ch === "}" || ch === "]") {
|
|
545
|
+
if (pendingComma !== -1) out[pendingComma] = " ";
|
|
546
|
+
pendingComma = -1;
|
|
547
|
+
out.push(ch);
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
if (!/\s/.test(ch)) pendingComma = -1;
|
|
551
|
+
out.push(ch);
|
|
552
|
+
}
|
|
553
|
+
return out.join("");
|
|
554
|
+
}
|
|
555
|
+
var import_promises3, NON_REGISTRY_VERSION;
|
|
556
|
+
var init_bun = __esm({
|
|
557
|
+
"src/parsers/bun.ts"() {
|
|
558
|
+
"use strict";
|
|
559
|
+
import_promises3 = require("fs/promises");
|
|
560
|
+
init_types();
|
|
561
|
+
init_report();
|
|
562
|
+
NON_REGISTRY_VERSION = /^[a-z][a-z0-9+.-]*:|\//i;
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
|
|
388
566
|
// src/parsers/npm.ts
|
|
389
567
|
async function parseNpmLockfile(lockfilePath) {
|
|
390
568
|
let raw;
|
|
391
569
|
try {
|
|
392
|
-
raw = await (0,
|
|
570
|
+
raw = await (0, import_promises4.readFile)(lockfilePath, "utf8");
|
|
393
571
|
} catch (cause) {
|
|
394
572
|
throw new PatchstackError(`Could not read lockfile at ${lockfilePath}`, "LOCKFILE_NOT_FOUND", cause);
|
|
395
573
|
}
|
|
@@ -468,11 +646,11 @@ function isDirectV2(pkgPath) {
|
|
|
468
646
|
const nmCount = parts.filter((p) => p === "node_modules").length;
|
|
469
647
|
return nmCount === 1;
|
|
470
648
|
}
|
|
471
|
-
var
|
|
649
|
+
var import_promises4, import_node_path3;
|
|
472
650
|
var init_npm = __esm({
|
|
473
651
|
"src/parsers/npm.ts"() {
|
|
474
652
|
"use strict";
|
|
475
|
-
|
|
653
|
+
import_promises4 = require("fs/promises");
|
|
476
654
|
import_node_path3 = __toESM(require("path"), 1);
|
|
477
655
|
init_types();
|
|
478
656
|
}
|
|
@@ -482,7 +660,7 @@ var init_npm = __esm({
|
|
|
482
660
|
async function walkNodeModules(cwd) {
|
|
483
661
|
const root = import_node_path4.default.join(cwd, "node_modules");
|
|
484
662
|
try {
|
|
485
|
-
const info = await (0,
|
|
663
|
+
const info = await (0, import_promises5.stat)(root);
|
|
486
664
|
if (!info.isDirectory()) {
|
|
487
665
|
throw new PatchstackError(
|
|
488
666
|
`${root} exists but is not a directory.`,
|
|
@@ -499,104 +677,131 @@ async function walkNodeModules(cwd) {
|
|
|
499
677
|
cause
|
|
500
678
|
);
|
|
501
679
|
}
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
680
|
+
const seen = /* @__PURE__ */ new Map();
|
|
681
|
+
try {
|
|
682
|
+
seen.set(await (0, import_promises5.realpath)(cwd), -1);
|
|
683
|
+
} catch {
|
|
684
|
+
}
|
|
685
|
+
return dedupeByShallowest(await walk(root, 0, seen));
|
|
686
|
+
}
|
|
687
|
+
function dedupeByShallowest(found) {
|
|
688
|
+
const best = /* @__PURE__ */ new Map();
|
|
689
|
+
for (const item of found) {
|
|
690
|
+
const current = best.get(item.realpath);
|
|
691
|
+
if (current === void 0 || item.depth < current.depth) best.set(item.realpath, item);
|
|
692
|
+
}
|
|
693
|
+
return [...best.values()].map(({ entry }) => entry);
|
|
505
694
|
}
|
|
506
|
-
async function walk(dir,
|
|
695
|
+
async function walk(dir, depth, seen) {
|
|
507
696
|
let names;
|
|
508
697
|
try {
|
|
509
|
-
names = await (0,
|
|
698
|
+
names = await (0, import_promises5.readdir)(dir);
|
|
510
699
|
} catch {
|
|
511
|
-
return;
|
|
700
|
+
return [];
|
|
512
701
|
}
|
|
513
|
-
|
|
514
|
-
if (name.startsWith("."))
|
|
515
|
-
continue;
|
|
516
|
-
}
|
|
702
|
+
const perEntry = await mapLimit(names, async (name) => {
|
|
703
|
+
if (name.startsWith(".") && !PACKAGE_STORE_DIRS.has(name)) return [];
|
|
517
704
|
const fullPath = import_node_path4.default.join(dir, name);
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
705
|
+
const resolved = await claim(fullPath, seen, depth);
|
|
706
|
+
if (resolved === null) return [];
|
|
707
|
+
if (PACKAGE_STORE_DIRS.has(name)) return walk(fullPath, depth, seen);
|
|
521
708
|
if (name.startsWith("@")) {
|
|
522
709
|
let subNames;
|
|
523
710
|
try {
|
|
524
|
-
subNames = await (0,
|
|
711
|
+
subNames = await (0, import_promises5.readdir)(fullPath);
|
|
525
712
|
} catch {
|
|
526
|
-
|
|
713
|
+
return [];
|
|
527
714
|
}
|
|
528
|
-
|
|
529
|
-
if (sub.startsWith("."))
|
|
530
|
-
continue;
|
|
531
|
-
}
|
|
715
|
+
const scoped = await mapLimit(subNames, async (sub) => {
|
|
716
|
+
if (sub.startsWith(".")) return [];
|
|
532
717
|
const scopedDir = import_node_path4.default.join(fullPath, sub);
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
}
|
|
539
|
-
continue;
|
|
718
|
+
const scopedReal = await claim(scopedDir, seen, depth);
|
|
719
|
+
if (scopedReal === null) return [];
|
|
720
|
+
return packageAndNested(scopedDir, scopedReal, depth, seen);
|
|
721
|
+
});
|
|
722
|
+
return scoped.flat();
|
|
540
723
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
724
|
+
return packageAndNested(fullPath, resolved, depth, seen);
|
|
725
|
+
});
|
|
726
|
+
return perEntry.flat();
|
|
727
|
+
}
|
|
728
|
+
async function packageAndNested(pkgDir, resolved, depth, seen) {
|
|
729
|
+
const [own, nested] = await Promise.all([readPackage(pkgDir, depth), walkNested(pkgDir, depth, seen)]);
|
|
730
|
+
return own === null ? nested : [{ entry: own, realpath: resolved, depth }, ...nested];
|
|
544
731
|
}
|
|
545
|
-
async function
|
|
732
|
+
async function mapLimit(items, fn) {
|
|
733
|
+
const results = new Array(items.length);
|
|
734
|
+
let next = 0;
|
|
735
|
+
const workers = Array.from({ length: Math.min(CONCURRENCY, items.length) }, async () => {
|
|
736
|
+
while (next < items.length) {
|
|
737
|
+
const index = next++;
|
|
738
|
+
results[index] = await fn(items[index]);
|
|
739
|
+
}
|
|
740
|
+
});
|
|
741
|
+
await Promise.all(workers);
|
|
742
|
+
return results;
|
|
743
|
+
}
|
|
744
|
+
async function readPackage(pkgDir, depth) {
|
|
546
745
|
let raw;
|
|
547
746
|
try {
|
|
548
|
-
raw = await (0,
|
|
747
|
+
raw = await (0, import_promises5.readFile)(import_node_path4.default.join(pkgDir, "package.json"), "utf8");
|
|
549
748
|
} catch {
|
|
550
|
-
return;
|
|
749
|
+
return null;
|
|
551
750
|
}
|
|
552
751
|
let parsed;
|
|
553
752
|
try {
|
|
554
753
|
parsed = JSON.parse(raw);
|
|
555
754
|
} catch {
|
|
556
|
-
return;
|
|
755
|
+
return null;
|
|
557
756
|
}
|
|
558
757
|
if (typeof parsed.name !== "string" || parsed.name.length === 0) {
|
|
559
|
-
return;
|
|
758
|
+
return null;
|
|
560
759
|
}
|
|
561
760
|
if (typeof parsed.version !== "string" || parsed.version.length === 0) {
|
|
562
|
-
return;
|
|
761
|
+
return null;
|
|
563
762
|
}
|
|
564
|
-
|
|
565
|
-
name: parsed.name,
|
|
566
|
-
version: parsed.version,
|
|
567
|
-
direct: depth === 0
|
|
568
|
-
});
|
|
763
|
+
return { name: parsed.name, version: parsed.version, direct: depth === 0 };
|
|
569
764
|
}
|
|
570
|
-
async function walkNested(pkgDir,
|
|
765
|
+
async function walkNested(pkgDir, depth, seen) {
|
|
571
766
|
const nested = import_node_path4.default.join(pkgDir, "node_modules");
|
|
572
|
-
if (
|
|
573
|
-
return;
|
|
767
|
+
if (await claim(nested, seen, depth + 1) === null) {
|
|
768
|
+
return [];
|
|
574
769
|
}
|
|
575
|
-
|
|
770
|
+
return walk(nested, depth + 1, seen);
|
|
576
771
|
}
|
|
577
|
-
async function
|
|
772
|
+
async function claim(dir, seen, depth) {
|
|
773
|
+
let resolved;
|
|
578
774
|
try {
|
|
579
|
-
const info = await (0,
|
|
580
|
-
|
|
775
|
+
const info = await (0, import_promises5.lstat)(dir);
|
|
776
|
+
if (!info.isDirectory() && !info.isSymbolicLink()) return null;
|
|
777
|
+
resolved = await (0, import_promises5.realpath)(dir);
|
|
778
|
+
const target = await (0, import_promises5.stat)(resolved);
|
|
779
|
+
if (!target.isDirectory()) return null;
|
|
581
780
|
} catch {
|
|
582
|
-
return
|
|
781
|
+
return null;
|
|
583
782
|
}
|
|
783
|
+
const claimed = seen.get(resolved);
|
|
784
|
+
if (claimed !== void 0 && claimed <= depth) return null;
|
|
785
|
+
seen.set(resolved, depth);
|
|
786
|
+
return resolved;
|
|
584
787
|
}
|
|
585
|
-
var
|
|
788
|
+
var import_promises5, import_node_path4, PACKAGE_STORE_DIRS, CONCURRENCY;
|
|
586
789
|
var init_node_modules = __esm({
|
|
587
790
|
"src/parsers/node_modules.ts"() {
|
|
588
791
|
"use strict";
|
|
589
|
-
|
|
792
|
+
import_promises5 = require("fs/promises");
|
|
590
793
|
import_node_path4 = __toESM(require("path"), 1);
|
|
591
794
|
init_types();
|
|
795
|
+
PACKAGE_STORE_DIRS = /* @__PURE__ */ new Set([".bun", ".pnpm", ".store"]);
|
|
796
|
+
CONCURRENCY = 32;
|
|
592
797
|
}
|
|
593
798
|
});
|
|
594
799
|
|
|
595
800
|
// src/parsers/pnpm.ts
|
|
596
|
-
async function parsePnpmLockfile(lockfilePath) {
|
|
801
|
+
async function parsePnpmLockfile(lockfilePath, report) {
|
|
597
802
|
let raw;
|
|
598
803
|
try {
|
|
599
|
-
raw = await (0,
|
|
804
|
+
raw = await (0, import_promises6.readFile)(lockfilePath, "utf8");
|
|
600
805
|
} catch (cause) {
|
|
601
806
|
throw new PatchstackError(
|
|
602
807
|
`Could not read lockfile at ${lockfilePath}`,
|
|
@@ -617,6 +822,7 @@ async function parsePnpmLockfile(lockfilePath) {
|
|
|
617
822
|
for (const key of packageKeys) {
|
|
618
823
|
const parsed = parsePackageKey(key);
|
|
619
824
|
if (parsed === null) {
|
|
825
|
+
recordUnreadable(report, key);
|
|
620
826
|
continue;
|
|
621
827
|
}
|
|
622
828
|
entries.push({
|
|
@@ -837,12 +1043,13 @@ function extractLeafName(trimmed) {
|
|
|
837
1043
|
}
|
|
838
1044
|
return name.length > 0 ? name : null;
|
|
839
1045
|
}
|
|
840
|
-
var
|
|
1046
|
+
var import_promises6, DEP_SECTIONS;
|
|
841
1047
|
var init_pnpm = __esm({
|
|
842
1048
|
"src/parsers/pnpm.ts"() {
|
|
843
1049
|
"use strict";
|
|
844
|
-
|
|
1050
|
+
import_promises6 = require("fs/promises");
|
|
845
1051
|
init_types();
|
|
1052
|
+
init_report();
|
|
846
1053
|
DEP_SECTIONS = /* @__PURE__ */ new Set([
|
|
847
1054
|
"dependencies",
|
|
848
1055
|
"devDependencies",
|
|
@@ -852,10 +1059,10 @@ var init_pnpm = __esm({
|
|
|
852
1059
|
});
|
|
853
1060
|
|
|
854
1061
|
// src/parsers/yarn.ts
|
|
855
|
-
async function parseYarnLockfile(lockfilePath) {
|
|
1062
|
+
async function parseYarnLockfile(lockfilePath, report) {
|
|
856
1063
|
let raw;
|
|
857
1064
|
try {
|
|
858
|
-
raw = await (0,
|
|
1065
|
+
raw = await (0, import_promises7.readFile)(lockfilePath, "utf8");
|
|
859
1066
|
} catch (cause) {
|
|
860
1067
|
throw new PatchstackError(
|
|
861
1068
|
`Could not read lockfile at ${lockfilePath}`,
|
|
@@ -863,7 +1070,7 @@ async function parseYarnLockfile(lockfilePath) {
|
|
|
863
1070
|
cause
|
|
864
1071
|
);
|
|
865
1072
|
}
|
|
866
|
-
const blocks = parseBlocks(raw);
|
|
1073
|
+
const blocks = parseBlocks(raw, report);
|
|
867
1074
|
if (blocks.length === 0) {
|
|
868
1075
|
throw new PatchstackError(
|
|
869
1076
|
`Lockfile at ${lockfilePath} contains no package entries`,
|
|
@@ -892,13 +1099,18 @@ async function parseYarnLockfile(lockfilePath) {
|
|
|
892
1099
|
}
|
|
893
1100
|
return entries;
|
|
894
1101
|
}
|
|
895
|
-
function parseBlocks(raw) {
|
|
1102
|
+
function parseBlocks(raw, report) {
|
|
896
1103
|
const lines = raw.split(/\r?\n/);
|
|
897
1104
|
const blocks = [];
|
|
898
1105
|
let current = null;
|
|
899
1106
|
const finalize = () => {
|
|
900
|
-
if (current
|
|
1107
|
+
if (current === null) {
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
if (current.version.length > 0 && current.names.size > 0) {
|
|
901
1111
|
blocks.push(current);
|
|
1112
|
+
} else if (current.unreadable) {
|
|
1113
|
+
recordUnreadable(report, current.key);
|
|
902
1114
|
}
|
|
903
1115
|
current = null;
|
|
904
1116
|
};
|
|
@@ -915,13 +1127,13 @@ function parseBlocks(raw) {
|
|
|
915
1127
|
}
|
|
916
1128
|
const keyLine = trimmed.slice(0, -1);
|
|
917
1129
|
const names = /* @__PURE__ */ new Set();
|
|
1130
|
+
let unreadable = false;
|
|
918
1131
|
for (const spec of splitDescriptors(keyLine)) {
|
|
919
|
-
const
|
|
920
|
-
if (name !==
|
|
921
|
-
|
|
922
|
-
}
|
|
1132
|
+
const classified = classifyDescriptor(spec);
|
|
1133
|
+
if (classified.name !== void 0) names.add(classified.name);
|
|
1134
|
+
else if (!classified.excluded) unreadable = true;
|
|
923
1135
|
}
|
|
924
|
-
current = { names, version: "" };
|
|
1136
|
+
current = { names, version: "", key: keyLine, unreadable: unreadable && names.size === 0 };
|
|
925
1137
|
continue;
|
|
926
1138
|
}
|
|
927
1139
|
if (current === null) {
|
|
@@ -976,20 +1188,36 @@ function splitDescriptors(keyLine) {
|
|
|
976
1188
|
}
|
|
977
1189
|
return parts;
|
|
978
1190
|
}
|
|
979
|
-
function
|
|
1191
|
+
function classifyDescriptor(rawSpec) {
|
|
980
1192
|
let s = rawSpec.trim();
|
|
981
1193
|
if (s.length === 0) {
|
|
982
|
-
return
|
|
1194
|
+
return { excluded: true };
|
|
983
1195
|
}
|
|
984
1196
|
if (s.startsWith('"') && s.endsWith('"') || s.startsWith("'") && s.endsWith("'")) {
|
|
985
1197
|
s = s.slice(1, -1);
|
|
986
1198
|
}
|
|
987
|
-
|
|
1199
|
+
if (NON_PACKAGE_KEYS.has(s)) {
|
|
1200
|
+
return { excluded: true };
|
|
1201
|
+
}
|
|
1202
|
+
const atIdx = s.indexOf("@", 1);
|
|
988
1203
|
if (atIdx <= 0) {
|
|
989
|
-
return
|
|
1204
|
+
return {};
|
|
990
1205
|
}
|
|
991
1206
|
const name = s.slice(0, atIdx);
|
|
992
|
-
|
|
1207
|
+
const range = s.slice(atIdx + 1);
|
|
1208
|
+
if (name.length === 0) {
|
|
1209
|
+
return {};
|
|
1210
|
+
}
|
|
1211
|
+
if (NON_REGISTRY_PROTOCOLS.test(range)) {
|
|
1212
|
+
return { excluded: true };
|
|
1213
|
+
}
|
|
1214
|
+
const alias = /^npm:(.+)$/i.exec(range);
|
|
1215
|
+
if (alias !== null) {
|
|
1216
|
+
const target = alias[1] ?? "";
|
|
1217
|
+
const targetAt = target.indexOf("@", 1);
|
|
1218
|
+
return { name: targetAt > 0 ? target.slice(0, targetAt) : name };
|
|
1219
|
+
}
|
|
1220
|
+
return { name };
|
|
993
1221
|
}
|
|
994
1222
|
function parseVersionField(content) {
|
|
995
1223
|
if (!content.startsWith("version")) {
|
|
@@ -1014,7 +1242,7 @@ async function readDirectDepNames(cwd) {
|
|
|
1014
1242
|
const names = /* @__PURE__ */ new Set();
|
|
1015
1243
|
let raw;
|
|
1016
1244
|
try {
|
|
1017
|
-
raw = await (0,
|
|
1245
|
+
raw = await (0, import_promises7.readFile)(import_node_path5.default.join(cwd, "package.json"), "utf8");
|
|
1018
1246
|
} catch {
|
|
1019
1247
|
return names;
|
|
1020
1248
|
}
|
|
@@ -1039,13 +1267,16 @@ async function readDirectDepNames(cwd) {
|
|
|
1039
1267
|
}
|
|
1040
1268
|
return names;
|
|
1041
1269
|
}
|
|
1042
|
-
var
|
|
1270
|
+
var import_promises7, import_node_path5, NON_PACKAGE_KEYS, NON_REGISTRY_PROTOCOLS;
|
|
1043
1271
|
var init_yarn = __esm({
|
|
1044
1272
|
"src/parsers/yarn.ts"() {
|
|
1045
1273
|
"use strict";
|
|
1046
|
-
|
|
1274
|
+
import_promises7 = require("fs/promises");
|
|
1047
1275
|
import_node_path5 = __toESM(require("path"), 1);
|
|
1048
1276
|
init_types();
|
|
1277
|
+
init_report();
|
|
1278
|
+
NON_PACKAGE_KEYS = /* @__PURE__ */ new Set(["__metadata"]);
|
|
1279
|
+
NON_REGISTRY_PROTOCOLS = /^(?:workspace|patch|virtual|portal|file|link|exec|git|github|https?|ssh):/i;
|
|
1049
1280
|
}
|
|
1050
1281
|
});
|
|
1051
1282
|
|
|
@@ -1080,34 +1311,58 @@ async function scanLockfile(cwd) {
|
|
|
1080
1311
|
const declared = await readDeclaredDependencyNames(cwd);
|
|
1081
1312
|
const warnings = [];
|
|
1082
1313
|
let firstParsed = null;
|
|
1083
|
-
let
|
|
1314
|
+
let walked = null;
|
|
1315
|
+
let walkWasCandidate = false;
|
|
1316
|
+
const parsedSources = [];
|
|
1084
1317
|
for (const candidate of candidates) {
|
|
1085
1318
|
let packages;
|
|
1319
|
+
const report = newReport();
|
|
1086
1320
|
try {
|
|
1087
|
-
packages = await runStrategy(candidate, cwd);
|
|
1321
|
+
packages = await runStrategy(candidate, cwd, report);
|
|
1088
1322
|
} catch {
|
|
1089
1323
|
continue;
|
|
1090
1324
|
}
|
|
1091
|
-
|
|
1325
|
+
const unreadable = unreadableWarning(candidate.filename, report);
|
|
1326
|
+
if (unreadable !== null) warnings.push(unreadable);
|
|
1327
|
+
if (candidate.strategy === "node-modules-walk") {
|
|
1328
|
+
walked = packages;
|
|
1329
|
+
walkWasCandidate = true;
|
|
1330
|
+
}
|
|
1092
1331
|
firstParsed ??= { packages, filename: candidate.filename };
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1332
|
+
parsedSources.push({ packages, filename: candidate.filename });
|
|
1333
|
+
}
|
|
1334
|
+
for (const source of parsedSources) {
|
|
1335
|
+
const missing = missingDependencies(declared, source.packages);
|
|
1336
|
+
if (missing.length > 0) {
|
|
1337
|
+
warnings.push(staleWarning(source.filename, missing));
|
|
1338
|
+
continue;
|
|
1339
|
+
}
|
|
1340
|
+
const conflicts = otherCompleteSources(parsedSources, source, declared).flatMap(
|
|
1341
|
+
(other) => disagreements(source.packages, other.packages).map((conflict) => ({ other: other.filename, conflict }))
|
|
1342
|
+
);
|
|
1343
|
+
if (conflicts.length === 0) {
|
|
1344
|
+
return manifestWith(source.packages, warnings);
|
|
1096
1345
|
}
|
|
1097
|
-
warnings.push(
|
|
1346
|
+
warnings.push(conflictWarning(source.filename, conflicts));
|
|
1347
|
+
break;
|
|
1098
1348
|
}
|
|
1099
|
-
if (
|
|
1349
|
+
if (walked === null) {
|
|
1100
1350
|
try {
|
|
1101
|
-
|
|
1102
|
-
const missing = missingDependencies(declared, packages);
|
|
1103
|
-
if (missing.length > 0) {
|
|
1104
|
-
warnings.push(staleWarning("node_modules/", missing));
|
|
1105
|
-
}
|
|
1106
|
-
warnings.push("Scanned node_modules/ instead. Delete the stale lockfile to silence this warning.");
|
|
1107
|
-
return manifestWith(packages, warnings);
|
|
1351
|
+
walked = await walkNodeModules(cwd);
|
|
1108
1352
|
} catch {
|
|
1353
|
+
walked = null;
|
|
1109
1354
|
}
|
|
1110
1355
|
}
|
|
1356
|
+
if (walked !== null) {
|
|
1357
|
+
const missing = missingDependencies(declared, walked);
|
|
1358
|
+
if (missing.length > 0) {
|
|
1359
|
+
warnings.push(staleWarning("node_modules/", missing));
|
|
1360
|
+
}
|
|
1361
|
+
warnings.push(
|
|
1362
|
+
walkWasCandidate ? "Reporting node_modules/, which is what the build loads." : "Scanned node_modules/ instead. Delete the stale lockfile to silence this warning."
|
|
1363
|
+
);
|
|
1364
|
+
return manifestWith(walked, warnings);
|
|
1365
|
+
}
|
|
1111
1366
|
if (firstParsed === null) {
|
|
1112
1367
|
const expected = LOCKFILE_CANDIDATES.map((candidate) => candidate.filename).join(", ");
|
|
1113
1368
|
throw new PatchstackError(
|
|
@@ -1123,6 +1378,15 @@ async function scanLockfile(cwd) {
|
|
|
1123
1378
|
function manifestWith(packages, warnings) {
|
|
1124
1379
|
return warnings.length > 0 ? { ecosystem: "npm", packages, warnings } : { ecosystem: "npm", packages };
|
|
1125
1380
|
}
|
|
1381
|
+
function otherCompleteSources(all, current, declared) {
|
|
1382
|
+
return all.filter(
|
|
1383
|
+
(source) => source.filename !== current.filename && missingDependencies(declared, source.packages).length === 0
|
|
1384
|
+
);
|
|
1385
|
+
}
|
|
1386
|
+
function conflictWarning(filename, conflicts) {
|
|
1387
|
+
const shown = conflicts.slice(0, 3).map(({ other, conflict }) => `${conflict.name} ${conflict.version} vs ${conflict.otherVersion} in ${other}`).join("; ");
|
|
1388
|
+
return `${filename} and another lockfile disagree about installed versions (${shown}${conflicts.length > 3 ? `, +${conflicts.length - 3} more` : ""}). Scanned node_modules/ instead. Remove whichever lockfile your package manager does not maintain.`;
|
|
1389
|
+
}
|
|
1126
1390
|
function staleWarning(source, missing) {
|
|
1127
1391
|
const sample = missing.slice(0, 3).join(", ");
|
|
1128
1392
|
const suffix = missing.length > 3 ? `, +${missing.length - 3} more` : "";
|
|
@@ -1137,41 +1401,50 @@ async function presentLockfiles(cwd) {
|
|
|
1137
1401
|
);
|
|
1138
1402
|
return probed.filter((candidate) => candidate.present).map(({ filename, strategy, filePath }) => ({ ecosystem: "npm", filename, strategy, filePath }));
|
|
1139
1403
|
}
|
|
1140
|
-
async function runStrategy(detected, cwd) {
|
|
1404
|
+
async function runStrategy(detected, cwd, report) {
|
|
1141
1405
|
switch (detected.strategy) {
|
|
1142
1406
|
case "npm-lockfile":
|
|
1143
1407
|
return parseNpmLockfile(detected.filePath);
|
|
1408
|
+
case "bun-lockfile":
|
|
1409
|
+
return parseBunLockfile(detected.filePath, report);
|
|
1410
|
+
// The hand-written scanners. Each meets entries it was not written for, and each is asked to say how
|
|
1411
|
+
// many it dropped, because a manifest short a package reads exactly like a project without it.
|
|
1144
1412
|
case "pnpm-lockfile":
|
|
1145
|
-
return parsePnpmLockfile(detected.filePath);
|
|
1413
|
+
return parsePnpmLockfile(detected.filePath, report);
|
|
1146
1414
|
case "yarn-lockfile":
|
|
1147
|
-
return parseYarnLockfile(detected.filePath);
|
|
1415
|
+
return parseYarnLockfile(detected.filePath, report);
|
|
1148
1416
|
case "node-modules-walk":
|
|
1149
1417
|
return walkNodeModules(cwd);
|
|
1150
1418
|
}
|
|
1151
1419
|
}
|
|
1152
1420
|
async function exists(filePath) {
|
|
1153
1421
|
try {
|
|
1154
|
-
await (0,
|
|
1422
|
+
await (0, import_promises8.access)(filePath);
|
|
1155
1423
|
return true;
|
|
1156
1424
|
} catch {
|
|
1157
1425
|
return false;
|
|
1158
1426
|
}
|
|
1159
1427
|
}
|
|
1160
|
-
var
|
|
1428
|
+
var import_promises8, import_node_path6, LOCKFILE_CANDIDATES;
|
|
1161
1429
|
var init_parsers = __esm({
|
|
1162
1430
|
"src/parsers/index.ts"() {
|
|
1163
1431
|
"use strict";
|
|
1164
|
-
|
|
1432
|
+
import_promises8 = require("fs/promises");
|
|
1165
1433
|
import_node_path6 = __toESM(require("path"), 1);
|
|
1166
1434
|
init_types();
|
|
1167
1435
|
init_consistency();
|
|
1436
|
+
init_bun();
|
|
1168
1437
|
init_npm();
|
|
1169
1438
|
init_node_modules();
|
|
1170
1439
|
init_pnpm();
|
|
1171
1440
|
init_yarn();
|
|
1441
|
+
init_report();
|
|
1172
1442
|
LOCKFILE_CANDIDATES = [
|
|
1173
1443
|
{ filename: "package-lock.json", strategy: "npm-lockfile" },
|
|
1174
|
-
|
|
1444
|
+
// Read directly, not walked. Bun's isolated layout puts packages under a dot-directory and makes the
|
|
1445
|
+
// top level symlinks into it, so a `node_modules` walk finds nothing there — and isolated is the default
|
|
1446
|
+
// for new workspaces.
|
|
1447
|
+
{ filename: "bun.lock", strategy: "bun-lockfile" },
|
|
1175
1448
|
{ filename: "bun.lockb", strategy: "node-modules-walk" },
|
|
1176
1449
|
{ filename: "pnpm-lock.yaml", strategy: "pnpm-lockfile" },
|
|
1177
1450
|
{ filename: "yarn.lock", strategy: "yarn-lockfile" }
|
|
@@ -1450,6 +1723,45 @@ function base64DecodeUtf8(value) {
|
|
|
1450
1723
|
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
|
1451
1724
|
return new TextDecoder().decode(bytes);
|
|
1452
1725
|
}
|
|
1726
|
+
var NAMED_ENTITIES = {
|
|
1727
|
+
lt: "<",
|
|
1728
|
+
gt: ">",
|
|
1729
|
+
amp: "&",
|
|
1730
|
+
quot: '"',
|
|
1731
|
+
apos: "'",
|
|
1732
|
+
"#39": "'",
|
|
1733
|
+
nbsp: "\xA0",
|
|
1734
|
+
sol: "/",
|
|
1735
|
+
bsol: "\\",
|
|
1736
|
+
colon: ":",
|
|
1737
|
+
lpar: "(",
|
|
1738
|
+
rpar: ")",
|
|
1739
|
+
equals: "=",
|
|
1740
|
+
grave: "`",
|
|
1741
|
+
Tab: " ",
|
|
1742
|
+
NewLine: "\n",
|
|
1743
|
+
semi: ";",
|
|
1744
|
+
excl: "!",
|
|
1745
|
+
num: "#",
|
|
1746
|
+
dollar: "$",
|
|
1747
|
+
percnt: "%",
|
|
1748
|
+
ast: "*"
|
|
1749
|
+
};
|
|
1750
|
+
function decodeHtmlEntities(input) {
|
|
1751
|
+
return input.replace(/&(#[xX]?[0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);?/g, (whole, body) => {
|
|
1752
|
+
if (body[0] === "#") {
|
|
1753
|
+
const hex = body[1] === "x" || body[1] === "X";
|
|
1754
|
+
const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
|
|
1755
|
+
if (!Number.isFinite(code) || code < 0 || code > 1114111) return whole;
|
|
1756
|
+
try {
|
|
1757
|
+
return String.fromCodePoint(code);
|
|
1758
|
+
} catch {
|
|
1759
|
+
return whole;
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, body) ? NAMED_ENTITIES[body] : whole;
|
|
1763
|
+
});
|
|
1764
|
+
}
|
|
1453
1765
|
var RequestResolver = class {
|
|
1454
1766
|
#req;
|
|
1455
1767
|
#cookies;
|
|
@@ -1572,6 +1884,8 @@ var RequestResolver = class {
|
|
|
1572
1884
|
} catch {
|
|
1573
1885
|
return value;
|
|
1574
1886
|
}
|
|
1887
|
+
case "htmlentitydecode":
|
|
1888
|
+
return decodeHtmlEntities(String(value));
|
|
1575
1889
|
case "intval":
|
|
1576
1890
|
return parseInt(String(value), 10) || 0;
|
|
1577
1891
|
case "getArrayValues":
|
|
@@ -3426,25 +3740,428 @@ function toEnvelope(value) {
|
|
|
3426
3740
|
return null;
|
|
3427
3741
|
}
|
|
3428
3742
|
|
|
3429
|
-
// src/protect/rules/
|
|
3430
|
-
var
|
|
3743
|
+
// src/protect/rules/contract.js
|
|
3744
|
+
var SOURCES = Object.freeze({
|
|
3745
|
+
raw: Object.freeze({ keyed: false }),
|
|
3746
|
+
all: Object.freeze({ keyed: false }),
|
|
3747
|
+
false: Object.freeze({ keyed: false }),
|
|
3748
|
+
rules: Object.freeze({ keyed: false, group: true }),
|
|
3749
|
+
get: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
3750
|
+
post: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
3751
|
+
request: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
3752
|
+
cookie: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
3753
|
+
// A bare `files.<name>` resolves to the filename; an optional trailing attribute selects another part
|
|
3754
|
+
// of the upload. Optional, not required — filename-scoped rules use the bare form.
|
|
3755
|
+
files: Object.freeze({
|
|
3756
|
+
keyed: true,
|
|
3757
|
+
keys: "any",
|
|
3758
|
+
wildcard: true,
|
|
3759
|
+
optional_key_suffixes: Object.freeze(["content", "filename", "type"])
|
|
3760
|
+
}),
|
|
3761
|
+
server: Object.freeze({
|
|
3762
|
+
keyed: true,
|
|
3763
|
+
keys: Object.freeze([
|
|
3764
|
+
"REQUEST_URI",
|
|
3765
|
+
"REQUEST_METHOD",
|
|
3766
|
+
"HTTP_USER_AGENT",
|
|
3767
|
+
"HTTP_REFERER",
|
|
3768
|
+
"HTTP_HOST",
|
|
3769
|
+
"REMOTE_ADDR",
|
|
3770
|
+
"ip",
|
|
3771
|
+
"CONTENT_TYPE",
|
|
3772
|
+
"CONTENT_LENGTH"
|
|
3773
|
+
]),
|
|
3774
|
+
key_prefixes: Object.freeze(["HTTP_"])
|
|
3775
|
+
}),
|
|
3776
|
+
response: Object.freeze({
|
|
3777
|
+
keyed: true,
|
|
3778
|
+
keys: Object.freeze(["status", "body", "headers"]),
|
|
3779
|
+
key_prefixes: Object.freeze(["header."])
|
|
3780
|
+
}),
|
|
3781
|
+
egress: Object.freeze({ keyed: true, keys: Object.freeze(["url", "host", "method"]) })
|
|
3782
|
+
});
|
|
3783
|
+
var MATCH_TYPES = Object.freeze([
|
|
3784
|
+
"array_in_array",
|
|
3785
|
+
"array_key_value",
|
|
3786
|
+
"contains",
|
|
3787
|
+
"cors_reflected",
|
|
3788
|
+
"cross_origin",
|
|
3789
|
+
"ctype_alnum",
|
|
3790
|
+
"ctype_digit",
|
|
3791
|
+
"ctype_special",
|
|
3792
|
+
"equals",
|
|
3793
|
+
"equals_strict",
|
|
3794
|
+
"hostname",
|
|
3795
|
+
"in_array",
|
|
3796
|
+
"inline_js_xss",
|
|
3797
|
+
"inline_xss",
|
|
3798
|
+
"internal_host",
|
|
3799
|
+
"is_numeric",
|
|
3800
|
+
"isset",
|
|
3801
|
+
"less_than",
|
|
3802
|
+
"more_than",
|
|
3803
|
+
"not_contains",
|
|
3804
|
+
"not_in_array",
|
|
3805
|
+
"off_origin",
|
|
3806
|
+
"quotes",
|
|
3807
|
+
"regex",
|
|
3808
|
+
"stripos"
|
|
3809
|
+
]);
|
|
3810
|
+
var PARAMETERLESS_MATCH_TYPES = Object.freeze(["cross_origin", "off_origin", "cors_reflected"]);
|
|
3811
|
+
var OPERANDLESS_MATCH_TYPES = Object.freeze([
|
|
3812
|
+
"isset",
|
|
3813
|
+
"quotes",
|
|
3814
|
+
"is_numeric",
|
|
3815
|
+
"ctype_alnum",
|
|
3816
|
+
"ctype_digit",
|
|
3817
|
+
"ctype_special",
|
|
3818
|
+
"inline_xss",
|
|
3819
|
+
"inline_js_xss",
|
|
3820
|
+
"internal_host",
|
|
3821
|
+
"cross_origin",
|
|
3822
|
+
"off_origin",
|
|
3823
|
+
"cors_reflected"
|
|
3824
|
+
]);
|
|
3825
|
+
var OPERAND_SHAPES = Object.freeze([
|
|
3826
|
+
"non-empty-string",
|
|
3827
|
+
"regex-literal",
|
|
3828
|
+
"string-or-string-list",
|
|
3829
|
+
"scalar",
|
|
3830
|
+
"string",
|
|
3831
|
+
"numeric",
|
|
3832
|
+
"scalar-or-non-empty-list",
|
|
3833
|
+
"non-empty-list",
|
|
3834
|
+
"object",
|
|
3835
|
+
"non-empty-object",
|
|
3836
|
+
"non-empty-string-list",
|
|
3837
|
+
"header-name-map",
|
|
3838
|
+
"header-name-list",
|
|
3839
|
+
"cookie-flags"
|
|
3840
|
+
]);
|
|
3841
|
+
var COOKIE_FLAGS = Object.freeze(["httpOnly", "secure", "sameSite"]);
|
|
3842
|
+
var SAME_SITE_VALUES = Object.freeze(["Strict", "Lax", "None"]);
|
|
3843
|
+
var HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
3844
|
+
var REGEX_LITERAL = /^\/(.+?)\/([gimsuy]*)$/s;
|
|
3845
|
+
var MATCH_OPERANDS = Object.freeze({
|
|
3846
|
+
array_key_value: Object.freeze({
|
|
3847
|
+
required: Object.freeze(["key", "match"]),
|
|
3848
|
+
shapes: Object.freeze({ key: "string-or-string-list", match: "object" })
|
|
3849
|
+
}),
|
|
3850
|
+
regex: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "regex-literal" }) }),
|
|
3851
|
+
contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
3852
|
+
stripos: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
3853
|
+
not_contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
3854
|
+
// A hostname is compared to `new URL(value).hostname`, so an empty one only ever equals the empty
|
|
3855
|
+
// hostname of an opaque-scheme URL (`file:`, `mailto:`, `data:`) — never a host, and a false positive
|
|
3856
|
+
// on those. The other three are the list-membership family: an empty list makes `in_array` and
|
|
3857
|
+
// `array_in_array` inert and `not_in_array` true for every value.
|
|
3858
|
+
hostname: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
3859
|
+
in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
|
|
3860
|
+
not_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
|
|
3861
|
+
array_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-list" }) }),
|
|
3862
|
+
// The comparison family. `equals` coerces, so any scalar can match. `equals_strict` does not, and its
|
|
3863
|
+
// subject is always a string by the time it is compared — so a number operand is unconditionally
|
|
3864
|
+
// inert, which is the trap the engine's own note about `'1' !== 1` describes.
|
|
3865
|
+
equals: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar" }) }),
|
|
3866
|
+
equals_strict: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "string" }) }),
|
|
3867
|
+
less_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) }),
|
|
3868
|
+
more_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) })
|
|
3869
|
+
});
|
|
3870
|
+
function operandShapeProblem(shape, value) {
|
|
3871
|
+
const isFilledString = (v) => typeof v === "string" && v.trim() !== "";
|
|
3872
|
+
const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
|
|
3873
|
+
const isHeaderName = (v) => typeof v === "string" && HEADER_NAME.test(v);
|
|
3874
|
+
switch (shape) {
|
|
3875
|
+
case "non-empty-string":
|
|
3876
|
+
return isFilledString(value) ? null : "must be a non-empty string";
|
|
3877
|
+
case "regex-literal":
|
|
3878
|
+
if (!isFilledString(value)) return "must be a non-empty string";
|
|
3879
|
+
return REGEX_LITERAL.test(value) ? null : "must be a delimited pattern, as in /pattern/i";
|
|
3880
|
+
case "string-or-string-list":
|
|
3881
|
+
if (isFilledString(value)) return null;
|
|
3882
|
+
if (Array.isArray(value) && value.length > 0 && value.every(isFilledString)) return null;
|
|
3883
|
+
return "must be a non-empty string, or a non-empty list of them";
|
|
3884
|
+
case "scalar":
|
|
3885
|
+
return isScalar(value) ? null : "must be a string, number or boolean";
|
|
3886
|
+
case "string":
|
|
3887
|
+
return typeof value === "string" ? null : "must be a string (this comparison is type-strict)";
|
|
3888
|
+
case "numeric":
|
|
3889
|
+
return isScalar(value) && Number.isFinite(Number(value)) ? null : "must be a number";
|
|
3890
|
+
case "scalar-or-non-empty-list":
|
|
3891
|
+
if (isScalar(value)) return null;
|
|
3892
|
+
if (Array.isArray(value) && value.length > 0 && value.every(isScalar)) return null;
|
|
3893
|
+
return "must be a scalar, or a non-empty list of them";
|
|
3894
|
+
case "non-empty-list":
|
|
3895
|
+
return Array.isArray(value) && value.length > 0 && value.every(isScalar) ? null : "must be a non-empty list of scalars";
|
|
3896
|
+
case "header-name-map": {
|
|
3897
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
|
|
3898
|
+
const names = Object.keys(value);
|
|
3899
|
+
if (names.length === 0) return "must be an object with at least one entry";
|
|
3900
|
+
const bad = names.find((name) => !isHeaderName(name));
|
|
3901
|
+
return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
|
|
3902
|
+
}
|
|
3903
|
+
case "header-name-list": {
|
|
3904
|
+
if (!Array.isArray(value) || value.length === 0) return "must be a non-empty list of HTTP header names";
|
|
3905
|
+
const bad = value.find((name) => !isHeaderName(name));
|
|
3906
|
+
return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
|
|
3907
|
+
}
|
|
3908
|
+
case "cookie-flags": {
|
|
3909
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
|
|
3910
|
+
const unknown = Object.keys(value).filter((key) => !COOKIE_FLAGS.includes(key));
|
|
3911
|
+
if (unknown.length > 0) return `has no flag named ${unknown.map((k) => JSON.stringify(k)).join(", ")}`;
|
|
3912
|
+
for (const flag of ["httpOnly", "secure"]) {
|
|
3913
|
+
if (value[flag] !== void 0 && typeof value[flag] !== "boolean") {
|
|
3914
|
+
return `flag "${flag}" must be true or false`;
|
|
3915
|
+
}
|
|
3916
|
+
}
|
|
3917
|
+
const sameSite = value.sameSite;
|
|
3918
|
+
const sameSiteOff = sameSite === false;
|
|
3919
|
+
if (sameSite !== void 0 && !sameSiteOff) {
|
|
3920
|
+
if (typeof sameSite !== "string" || !SAME_SITE_VALUES.some((v) => v.toLowerCase() === sameSite.toLowerCase())) {
|
|
3921
|
+
return `flag "sameSite" must be false, or one of ${SAME_SITE_VALUES.join(", ")}`;
|
|
3922
|
+
}
|
|
3923
|
+
}
|
|
3924
|
+
if (value.httpOnly === false && value.secure === false && sameSiteOff) {
|
|
3925
|
+
return "turns off every flag, so it would harden nothing";
|
|
3926
|
+
}
|
|
3927
|
+
return null;
|
|
3928
|
+
}
|
|
3929
|
+
case "object":
|
|
3930
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? null : "must be an object";
|
|
3931
|
+
case "non-empty-object":
|
|
3932
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0 ? null : "must be an object with at least one entry";
|
|
3933
|
+
case "non-empty-string-list":
|
|
3934
|
+
return Array.isArray(value) && value.length > 0 && value.every(isFilledString) ? null : "must be a non-empty list of non-empty strings";
|
|
3935
|
+
default:
|
|
3936
|
+
return null;
|
|
3937
|
+
}
|
|
3938
|
+
}
|
|
3939
|
+
var MUTATIONS = Object.freeze([
|
|
3940
|
+
"base64_decode",
|
|
3941
|
+
"getArrayValues",
|
|
3942
|
+
"htmlentitydecode",
|
|
3943
|
+
"intval",
|
|
3944
|
+
"json_decode",
|
|
3945
|
+
"json_encode",
|
|
3946
|
+
"urldecode"
|
|
3947
|
+
]);
|
|
3948
|
+
var PHASES = Object.freeze(["request", "response", "egress"]);
|
|
3949
|
+
var ACTIONS = Object.freeze([
|
|
3950
|
+
"block",
|
|
3951
|
+
"redact",
|
|
3952
|
+
"encode",
|
|
3953
|
+
"set-header",
|
|
3954
|
+
"remove-header",
|
|
3955
|
+
"harden-cookie"
|
|
3956
|
+
]);
|
|
3957
|
+
var WHEN_KEYS = Object.freeze(["path", "method"]);
|
|
3958
|
+
var RULE_PROPERTIES = Object.freeze([
|
|
3959
|
+
"id",
|
|
3960
|
+
"rule_id",
|
|
3961
|
+
"title",
|
|
3962
|
+
"category",
|
|
3963
|
+
"phase",
|
|
3964
|
+
"action",
|
|
3965
|
+
"rule_v2",
|
|
3966
|
+
"when",
|
|
3967
|
+
"message",
|
|
3968
|
+
"enforcement",
|
|
3969
|
+
"source_revision",
|
|
3970
|
+
"prefilter",
|
|
3971
|
+
"max_bytes",
|
|
3972
|
+
"bypass_limit",
|
|
3973
|
+
"set_headers",
|
|
3974
|
+
"remove_headers",
|
|
3975
|
+
"cookie_flags",
|
|
3976
|
+
"ensure"
|
|
3977
|
+
]);
|
|
3978
|
+
var ACTION_PROPERTIES = Object.freeze({
|
|
3979
|
+
block: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["request", "response", "egress"]) }),
|
|
3980
|
+
redact: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
|
|
3981
|
+
encode: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
|
|
3982
|
+
"set-header": Object.freeze({
|
|
3983
|
+
required: Object.freeze(["set_headers"]),
|
|
3984
|
+
defaulted: Object.freeze(["ensure"]),
|
|
3985
|
+
phases: Object.freeze(["response"]),
|
|
3986
|
+
shapes: Object.freeze({ set_headers: "header-name-map" })
|
|
3987
|
+
}),
|
|
3988
|
+
"remove-header": Object.freeze({
|
|
3989
|
+
required: Object.freeze(["remove_headers"]),
|
|
3990
|
+
defaulted: Object.freeze([]),
|
|
3991
|
+
phases: Object.freeze(["response"]),
|
|
3992
|
+
shapes: Object.freeze({ remove_headers: "header-name-list" })
|
|
3993
|
+
}),
|
|
3994
|
+
"harden-cookie": Object.freeze({
|
|
3995
|
+
required: Object.freeze([]),
|
|
3996
|
+
defaulted: Object.freeze(["cookie_flags"]),
|
|
3997
|
+
phases: Object.freeze(["response"]),
|
|
3998
|
+
shapes: Object.freeze({ cookie_flags: "cookie-flags" })
|
|
3999
|
+
})
|
|
4000
|
+
});
|
|
4001
|
+
var LIMITS = Object.freeze({
|
|
3431
4002
|
maxRules: 5e3,
|
|
3432
4003
|
maxWhitelists: 2e3,
|
|
3433
4004
|
maxConditionsPerRule: 250,
|
|
3434
4005
|
maxNestingDepth: 12,
|
|
3435
4006
|
maxRegexLength: 1e3,
|
|
3436
4007
|
maxValueLength: 8192
|
|
3437
|
-
};
|
|
3438
|
-
var
|
|
3439
|
-
|
|
4008
|
+
});
|
|
4009
|
+
var GROUP_PARAMETER = "rules";
|
|
4010
|
+
function isGroup(condition) {
|
|
4011
|
+
return condition?.parameter === GROUP_PARAMETER && Array.isArray(condition?.rules);
|
|
4012
|
+
}
|
|
4013
|
+
function conditionShapeProblem(condition) {
|
|
4014
|
+
if (!condition || typeof condition !== "object") return "condition is not an object";
|
|
4015
|
+
const named = condition.parameter === GROUP_PARAMETER;
|
|
4016
|
+
const nested = Array.isArray(condition.rules);
|
|
4017
|
+
if (named && !nested) {
|
|
4018
|
+
return `parameter "${GROUP_PARAMETER}" is a group and needs a "rules" array`;
|
|
4019
|
+
}
|
|
4020
|
+
if (nested && !named) {
|
|
4021
|
+
return `condition carries nested rules but its parameter is ${JSON.stringify(condition.parameter)}; a group must be {"parameter":"${GROUP_PARAMETER}"}`;
|
|
4022
|
+
}
|
|
4023
|
+
if (named && nested) {
|
|
4024
|
+
if (condition.match !== void 0) return "a group carries no match of its own; the engine ignores it";
|
|
4025
|
+
if (condition.mutations !== void 0) return "a group carries no mutations of its own; the engine ignores them";
|
|
4026
|
+
if (condition.rules.length === 0) return "group has no conditions (would never match)";
|
|
4027
|
+
}
|
|
4028
|
+
return null;
|
|
4029
|
+
}
|
|
4030
|
+
function parameterProblem(parameter) {
|
|
4031
|
+
if (parameter === void 0 || parameter === null) return null;
|
|
4032
|
+
if (Array.isArray(parameter)) {
|
|
4033
|
+
if (parameter.length === 0) return "parameter list is empty";
|
|
4034
|
+
for (const member of parameter) {
|
|
4035
|
+
const problem = parameterProblem(member);
|
|
4036
|
+
if (problem !== null) return problem;
|
|
4037
|
+
}
|
|
4038
|
+
return null;
|
|
4039
|
+
}
|
|
4040
|
+
if (typeof parameter !== "string" || parameter === "") return "parameter must be a non-empty string";
|
|
4041
|
+
if (parameter === GROUP_PARAMETER) {
|
|
4042
|
+
return `"${GROUP_PARAMETER}" is the group source and is only valid as a whole group, not as a parameter`;
|
|
4043
|
+
}
|
|
4044
|
+
const dot = parameter.indexOf(".");
|
|
4045
|
+
const name = dot === -1 ? parameter : parameter.slice(0, dot);
|
|
4046
|
+
const source = SOURCES[name];
|
|
4047
|
+
if (source === void 0) return `unknown parameter source "${name}"`;
|
|
4048
|
+
if (dot === -1) {
|
|
4049
|
+
return source.keyed === true ? `source "${name}" needs a key, e.g. "${name}.fieldname"` : null;
|
|
4050
|
+
}
|
|
4051
|
+
if (source.keyed !== true) return `source "${name}" takes no key`;
|
|
4052
|
+
const key = parameter.slice(dot + 1);
|
|
4053
|
+
if (key === "") return `source "${name}" needs a key after the dot`;
|
|
4054
|
+
return keyProblem(name, source, key);
|
|
4055
|
+
}
|
|
4056
|
+
function keyProblem(name, source, key) {
|
|
4057
|
+
if (key.endsWith("*") && source.wildcard !== true) {
|
|
4058
|
+
return `"${name}" does not fan out over a wildcard key`;
|
|
4059
|
+
}
|
|
4060
|
+
const bare = key.endsWith("*") ? key.slice(0, -1) : key;
|
|
4061
|
+
if (source.keys === "any") return null;
|
|
4062
|
+
if (Array.isArray(source.keys) && source.keys.includes(bare)) return null;
|
|
4063
|
+
for (const prefix of source.key_prefixes ?? []) {
|
|
4064
|
+
if (bare.startsWith(prefix) && bare.length > prefix.length) return null;
|
|
4065
|
+
}
|
|
4066
|
+
const accepted = [
|
|
4067
|
+
...Array.isArray(source.keys) ? source.keys : [],
|
|
4068
|
+
...(source.key_prefixes ?? []).map((p) => `${p}\u2026`)
|
|
4069
|
+
].join(", ");
|
|
4070
|
+
return `"${name}" has no key "${key}" (expected one of: ${accepted})`;
|
|
4071
|
+
}
|
|
4072
|
+
function matchProblem(match, parameter) {
|
|
4073
|
+
if (!match || typeof match !== "object") return "condition has no match object";
|
|
4074
|
+
if (typeof match.type !== "string" || match.type === "") return "match.type must be a non-empty string";
|
|
4075
|
+
if (!MATCH_TYPES.includes(match.type)) return `unknown match type "${match.type}"`;
|
|
4076
|
+
const parameterless = PARAMETERLESS_MATCH_TYPES.includes(match.type);
|
|
4077
|
+
if (!parameterless && (parameter === void 0 || parameter === null)) {
|
|
4078
|
+
return `match type "${match.type}" needs a parameter`;
|
|
4079
|
+
}
|
|
4080
|
+
if (parameterless && parameter !== void 0 && parameter !== null) {
|
|
4081
|
+
return `match type "${match.type}" reads the whole request and takes no parameter`;
|
|
4082
|
+
}
|
|
4083
|
+
const operands = MATCH_OPERANDS[match.type];
|
|
4084
|
+
if (operands) {
|
|
4085
|
+
for (const required of operands.required) {
|
|
4086
|
+
if (match[required] === void 0 || match[required] === null) {
|
|
4087
|
+
return `match type "${match.type}" needs "${required}"`;
|
|
4088
|
+
}
|
|
4089
|
+
}
|
|
4090
|
+
for (const [operand, shape] of Object.entries(operands.shapes ?? {})) {
|
|
4091
|
+
if (match[operand] === void 0) continue;
|
|
4092
|
+
const problem = operandShapeProblem(shape, match[operand]);
|
|
4093
|
+
if (problem) return `match type "${match.type}" operand "${operand}" ${problem}`;
|
|
4094
|
+
}
|
|
4095
|
+
} else if (!OPERANDLESS_MATCH_TYPES.includes(match.type) && match.value === void 0) {
|
|
4096
|
+
return `match type "${match.type}" needs a value`;
|
|
4097
|
+
}
|
|
4098
|
+
return null;
|
|
4099
|
+
}
|
|
4100
|
+
function mutationsProblem(mutations) {
|
|
4101
|
+
if (mutations === void 0 || mutations === null) return null;
|
|
4102
|
+
if (!Array.isArray(mutations)) return "mutations must be an array";
|
|
4103
|
+
for (const mutation of mutations) {
|
|
4104
|
+
if (typeof mutation !== "string" || !MUTATIONS.includes(mutation)) {
|
|
4105
|
+
return `unknown mutation "${String(mutation)}" \u2014 applied as a no-op, so the condition would test the untransformed value`;
|
|
4106
|
+
}
|
|
4107
|
+
}
|
|
4108
|
+
return null;
|
|
4109
|
+
}
|
|
4110
|
+
function whenProblem(when) {
|
|
4111
|
+
if (when === void 0 || when === null) return null;
|
|
4112
|
+
if (typeof when !== "object" || Array.isArray(when)) return "when must be an object";
|
|
4113
|
+
const keys = Object.keys(when);
|
|
4114
|
+
if (keys.length === 0) return "when is empty \u2014 remove it, or name a path or method";
|
|
4115
|
+
const unknown = keys.filter((key) => !WHEN_KEYS.includes(key));
|
|
4116
|
+
if (unknown.length > 0) {
|
|
4117
|
+
return `when names no supported key (${unknown.join(", ")}) \u2014 the engine understands ${WHEN_KEYS.join(" and ")}, and an unrecognised scope is ignored, so the rule would apply to every request`;
|
|
4118
|
+
}
|
|
4119
|
+
if ("path" in when && !isNonEmptyString(when.path)) {
|
|
4120
|
+
return "when.path must be a non-empty string";
|
|
4121
|
+
}
|
|
4122
|
+
if ("method" in when) {
|
|
4123
|
+
const methods = Array.isArray(when.method) ? when.method : [when.method];
|
|
4124
|
+
if (methods.length === 0) return "when.method is an empty list, so the rule would match no method";
|
|
4125
|
+
if (!methods.every(isNonEmptyString)) return "when.method must be a non-empty string, or a list of them";
|
|
4126
|
+
}
|
|
4127
|
+
return null;
|
|
4128
|
+
}
|
|
4129
|
+
function isNonEmptyString(value) {
|
|
4130
|
+
return typeof value === "string" && value.trim() !== "";
|
|
4131
|
+
}
|
|
4132
|
+
function actionProblem(action, rule) {
|
|
4133
|
+
if (action === void 0 || action === null) return null;
|
|
4134
|
+
if (!ACTIONS.includes(action)) return `unknown action "${action}"`;
|
|
4135
|
+
const spec = ACTION_PROPERTIES[action];
|
|
4136
|
+
for (const required of spec?.required ?? []) {
|
|
4137
|
+
if (rule?.[required] === void 0 || rule?.[required] === null) {
|
|
4138
|
+
return `action "${action}" needs "${required}"`;
|
|
4139
|
+
}
|
|
4140
|
+
}
|
|
4141
|
+
for (const [property, shape] of Object.entries(spec?.shapes ?? {})) {
|
|
4142
|
+
if (rule?.[property] === void 0 || rule?.[property] === null) continue;
|
|
4143
|
+
const problem = operandShapeProblem(shape, rule[property]);
|
|
4144
|
+
if (problem) return `action "${action}" property "${property}" ${problem}`;
|
|
4145
|
+
}
|
|
4146
|
+
const phase = rule?.phase ?? "request";
|
|
4147
|
+
if (spec !== void 0 && !spec.phases.includes(phase)) {
|
|
4148
|
+
return `action "${action}" is carried out on the ${spec.phases.join("/")} phase; on "${phase}" the rule blocks instead`;
|
|
4149
|
+
}
|
|
4150
|
+
return null;
|
|
4151
|
+
}
|
|
4152
|
+
|
|
4153
|
+
// src/protect/rules/validate.js
|
|
4154
|
+
var LIMITS2 = LIMITS;
|
|
4155
|
+
var PHASES2 = new Set(PHASES);
|
|
4156
|
+
var ACTIONS2 = new Set(ACTIONS);
|
|
3440
4157
|
function validateBundle(bundle, opts = {}) {
|
|
3441
4158
|
const rejected = [];
|
|
3442
4159
|
const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
|
|
3443
4160
|
const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
|
|
3444
4161
|
const firewall = [];
|
|
3445
4162
|
for (const rule of inFirewall) {
|
|
3446
|
-
if (firewall.length >=
|
|
3447
|
-
rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${
|
|
4163
|
+
if (firewall.length >= LIMITS2.maxRules) {
|
|
4164
|
+
rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS2.maxRules})` });
|
|
3448
4165
|
continue;
|
|
3449
4166
|
}
|
|
3450
4167
|
const reason = ruleProblem(rule);
|
|
@@ -3453,8 +4170,8 @@ function validateBundle(bundle, opts = {}) {
|
|
|
3453
4170
|
}
|
|
3454
4171
|
const whitelists = [];
|
|
3455
4172
|
for (const wl of inWhitelists) {
|
|
3456
|
-
if (whitelists.length >=
|
|
3457
|
-
rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${
|
|
4173
|
+
if (whitelists.length >= LIMITS2.maxWhitelists) {
|
|
4174
|
+
rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS2.maxWhitelists})` });
|
|
3458
4175
|
continue;
|
|
3459
4176
|
}
|
|
3460
4177
|
if (!opts.allowGlobalWhitelists && wl && Array.isArray(wl.rule_v2) && !wl.rule_id) {
|
|
@@ -3476,8 +4193,11 @@ function idOf(rule) {
|
|
|
3476
4193
|
}
|
|
3477
4194
|
function ruleProblem(rule) {
|
|
3478
4195
|
if (!rule || typeof rule !== "object") return "not an object";
|
|
3479
|
-
if (rule.phase !== void 0 && !
|
|
3480
|
-
|
|
4196
|
+
if (rule.phase !== void 0 && !PHASES2.has(rule.phase)) return `unknown phase "${rule.phase}"`;
|
|
4197
|
+
const actionReason = actionProblem(rule.action, rule);
|
|
4198
|
+
if (actionReason) return actionReason;
|
|
4199
|
+
const scopeReason = whenProblem(rule.when);
|
|
4200
|
+
if (scopeReason) return scopeReason;
|
|
3481
4201
|
const capOverride = rule.max_bytes;
|
|
3482
4202
|
if (capOverride !== void 0 && !(Number(capOverride) > 0)) return "max_bytes must be a positive number";
|
|
3483
4203
|
return conditionsProblem(rule.rule_v2);
|
|
@@ -3485,28 +4205,33 @@ function ruleProblem(rule) {
|
|
|
3485
4205
|
function conditionsProblem(conditions, depth = 0) {
|
|
3486
4206
|
if (!Array.isArray(conditions)) return "rule_v2 must be an array of conditions";
|
|
3487
4207
|
if (conditions.length === 0) return "rule_v2 is empty (would never match)";
|
|
3488
|
-
if (depth >
|
|
3489
|
-
if (conditions.length >
|
|
3490
|
-
return `more than ${
|
|
4208
|
+
if (depth > LIMITS2.maxNestingDepth) return `nesting deeper than ${LIMITS2.maxNestingDepth}`;
|
|
4209
|
+
if (conditions.length > LIMITS2.maxConditionsPerRule) {
|
|
4210
|
+
return `more than ${LIMITS2.maxConditionsPerRule} conditions`;
|
|
3491
4211
|
}
|
|
3492
4212
|
for (const c of conditions) {
|
|
3493
|
-
|
|
3494
|
-
if (
|
|
4213
|
+
const shapeReason = conditionShapeProblem(c);
|
|
4214
|
+
if (shapeReason) return shapeReason;
|
|
4215
|
+
if (isGroup(c)) {
|
|
3495
4216
|
const nested = conditionsProblem(c.rules, depth + 1);
|
|
3496
4217
|
if (nested) return nested;
|
|
3497
4218
|
continue;
|
|
3498
4219
|
}
|
|
4220
|
+
const parameterReason = parameterProblem(c.parameter);
|
|
4221
|
+
if (parameterReason) return parameterReason;
|
|
4222
|
+
const mutationReason = mutationsProblem(c.mutations);
|
|
4223
|
+
if (mutationReason) return mutationReason;
|
|
4224
|
+
const matchReason = matchProblem(c.match, c.parameter);
|
|
4225
|
+
if (matchReason) return matchReason;
|
|
3499
4226
|
const m = c.match;
|
|
3500
|
-
if (!m || typeof m !== "object") return "condition has no match object";
|
|
3501
|
-
if (typeof m.type !== "string" || m.type === "") return "match.type must be a non-empty string";
|
|
3502
4227
|
if (m.type === "regex") {
|
|
3503
4228
|
if (typeof m.value !== "string") return "regex match.value must be a string";
|
|
3504
|
-
if (m.value.length >
|
|
3505
|
-
} else if (typeof m.value === "string" && m.value.length >
|
|
3506
|
-
return `match.value longer than ${
|
|
4229
|
+
if (m.value.length > LIMITS2.maxRegexLength) return `regex longer than ${LIMITS2.maxRegexLength} chars`;
|
|
4230
|
+
} else if (typeof m.value === "string" && m.value.length > LIMITS2.maxValueLength) {
|
|
4231
|
+
return `match.value longer than ${LIMITS2.maxValueLength} chars`;
|
|
3507
4232
|
}
|
|
3508
4233
|
if (m.match) {
|
|
3509
|
-
const nested = conditionsProblem([{ match: m.match }], depth + 1);
|
|
4234
|
+
const nested = conditionsProblem([{ parameter: c.parameter, match: m.match }], depth + 1);
|
|
3510
4235
|
if (nested) return nested;
|
|
3511
4236
|
}
|
|
3512
4237
|
}
|
|
@@ -3534,64 +4259,67 @@ function reportRejections(rejected, options, label) {
|
|
|
3534
4259
|
`${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" : ""}`
|
|
3535
4260
|
), "onError");
|
|
3536
4261
|
}
|
|
4262
|
+
function fromSource(bundle, reason) {
|
|
4263
|
+
return reason === void 0 ? { ...bundle, source: { ok: true } } : { ...bundle, source: { ok: false, reason } };
|
|
4264
|
+
}
|
|
3537
4265
|
async function resolveRules(options, store, ctx = {}) {
|
|
3538
4266
|
const timeoutMs = ctx.timeoutMs;
|
|
3539
4267
|
if (options.siteUuid) {
|
|
3540
4268
|
const prior = await store.read();
|
|
3541
4269
|
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, reportsDetections: options.reportDetections === true });
|
|
3542
4270
|
const res = await client.getRules();
|
|
3543
|
-
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
4271
|
+
if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
|
|
3544
4272
|
if (res.success && !res.notModified) {
|
|
3545
4273
|
const rejected = liveUpdateRejections(res, options);
|
|
3546
4274
|
if (rejected.length > 0) {
|
|
3547
4275
|
reportRejections(rejected, options, "rule update rejected");
|
|
3548
|
-
if (prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
3549
|
-
if (options.rules) return normalizeBundle(options.rules, options);
|
|
3550
|
-
return emptyBundle();
|
|
4276
|
+
if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
|
|
4277
|
+
if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
|
|
4278
|
+
return fromSource(emptyBundle(), "update rejected");
|
|
3551
4279
|
}
|
|
3552
4280
|
const bundle = normalizeBundle(res, options);
|
|
3553
4281
|
await store.write({ bundle, etag: res.etag ?? null });
|
|
3554
|
-
return bundle;
|
|
4282
|
+
return fromSource(bundle);
|
|
3555
4283
|
}
|
|
3556
4284
|
if (prior?.bundle) {
|
|
3557
4285
|
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
|
|
3558
|
-
return normalizeBundle(prior.bundle, options);
|
|
4286
|
+
return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
|
|
3559
4287
|
}
|
|
3560
4288
|
if (options.rules) {
|
|
3561
4289
|
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`), "onError");
|
|
3562
|
-
return normalizeBundle(options.rules, options);
|
|
4290
|
+
return fromSource(normalizeBundle(options.rules, options), res.error ?? "no usable response");
|
|
3563
4291
|
}
|
|
3564
4292
|
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
|
|
3565
|
-
return emptyBundle();
|
|
4293
|
+
return fromSource(emptyBundle(), res.error ?? "no usable response");
|
|
3566
4294
|
}
|
|
3567
4295
|
if (options.token) {
|
|
3568
4296
|
const prior = await store.read();
|
|
3569
4297
|
const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs });
|
|
3570
4298
|
const res = await client.getRules();
|
|
3571
|
-
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
4299
|
+
if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
|
|
3572
4300
|
if (res.success && !res.notModified) {
|
|
3573
4301
|
const rejected = liveUpdateRejections(res, options);
|
|
3574
4302
|
if (rejected.length > 0) {
|
|
3575
4303
|
reportRejections(rejected, options, "rule update rejected");
|
|
3576
|
-
if (prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
3577
|
-
if (options.rules) return normalizeBundle(options.rules, options);
|
|
3578
|
-
return emptyBundle();
|
|
4304
|
+
if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
|
|
4305
|
+
if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
|
|
4306
|
+
return fromSource(emptyBundle(), "update rejected");
|
|
3579
4307
|
}
|
|
3580
4308
|
const bundle = normalizeBundle(res, options);
|
|
3581
4309
|
await store.write({ bundle, etag: res.etag ?? null });
|
|
3582
|
-
return bundle;
|
|
4310
|
+
return fromSource(bundle);
|
|
3583
4311
|
}
|
|
3584
4312
|
if (prior?.bundle) {
|
|
3585
4313
|
notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
|
|
3586
|
-
return normalizeBundle(prior.bundle, options);
|
|
4314
|
+
return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
|
|
3587
4315
|
}
|
|
3588
4316
|
notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
|
|
3589
|
-
return emptyBundle();
|
|
4317
|
+
return fromSource(emptyBundle(), res.error ?? "no usable response");
|
|
3590
4318
|
}
|
|
3591
4319
|
if (options.rules) {
|
|
3592
|
-
return normalizeBundle(options.rules, options);
|
|
4320
|
+
return fromSource(normalizeBundle(options.rules, options));
|
|
3593
4321
|
}
|
|
3594
|
-
return emptyBundle();
|
|
4322
|
+
return fromSource(emptyBundle());
|
|
3595
4323
|
}
|
|
3596
4324
|
function normalizeBundle(b, options = {}) {
|
|
3597
4325
|
const enforcement = b?.enforcement ?? b?.mode;
|
|
@@ -3644,8 +4372,9 @@ function startRefresh(tick, { refreshMs, onError } = {}) {
|
|
|
3644
4372
|
const run = async () => {
|
|
3645
4373
|
if (stopped) return;
|
|
3646
4374
|
try {
|
|
3647
|
-
await tick();
|
|
3648
|
-
|
|
4375
|
+
const status = await tick();
|
|
4376
|
+
if (status && status.ok === false) failures++;
|
|
4377
|
+
else failures = 0;
|
|
3649
4378
|
} catch (err) {
|
|
3650
4379
|
failures++;
|
|
3651
4380
|
notify(onError, err, "onError");
|
|
@@ -3672,7 +4401,8 @@ function makeRefreshHandler(tick, secret) {
|
|
|
3672
4401
|
if (provided !== secret) return new Response("forbidden", { status: 403 });
|
|
3673
4402
|
let refreshed = true;
|
|
3674
4403
|
try {
|
|
3675
|
-
await tick();
|
|
4404
|
+
const status = await tick();
|
|
4405
|
+
if (status && status.ok === false) refreshed = false;
|
|
3676
4406
|
} catch {
|
|
3677
4407
|
refreshed = false;
|
|
3678
4408
|
}
|
|
@@ -3701,6 +4431,12 @@ function ruleParameters(rule) {
|
|
|
3701
4431
|
walk2(rule?.rule_v2);
|
|
3702
4432
|
return [...out];
|
|
3703
4433
|
}
|
|
4434
|
+
function revisionOf(rule) {
|
|
4435
|
+
const revision = rule?.source_revision;
|
|
4436
|
+
if (typeof revision === "string" && revision !== "") return revision;
|
|
4437
|
+
if (typeof revision === "number" && Number.isFinite(revision)) return String(revision);
|
|
4438
|
+
return null;
|
|
4439
|
+
}
|
|
3704
4440
|
function routeOf(path7) {
|
|
3705
4441
|
if (typeof path7 !== "string" || path7 === "") return null;
|
|
3706
4442
|
const cut = path7.search(/[?#]/);
|
|
@@ -3709,16 +4445,30 @@ function routeOf(path7) {
|
|
|
3709
4445
|
function createDetectionReporter(opts) {
|
|
3710
4446
|
const siteUuid = opts.siteUuid ?? process.env?.PATCHSTACK_SITE_UUID;
|
|
3711
4447
|
if (!siteUuid) {
|
|
3712
|
-
return {
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
4448
|
+
return {
|
|
4449
|
+
record() {
|
|
4450
|
+
},
|
|
4451
|
+
flush() {
|
|
4452
|
+
},
|
|
4453
|
+
stop() {
|
|
4454
|
+
},
|
|
4455
|
+
setRulesEtag() {
|
|
4456
|
+
},
|
|
4457
|
+
dropped: () => 0,
|
|
4458
|
+
health: () => ({ sent: 0, delivered: 0, failed: 0, dropped: 0, lastDeliveredAt: null })
|
|
4459
|
+
};
|
|
3716
4460
|
}
|
|
3717
4461
|
const configured = opts.baseUrl ?? process.env?.PATCHSTACK_PULSE_RULES_URL;
|
|
3718
4462
|
const baseUrl = typeof configured === "string" && isSafeOrigin(configured) ? configured.replace(/\/$/, "") : DEFAULT_BASE_URL3;
|
|
3719
4463
|
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
3720
4464
|
const flushMs = Number.isFinite(opts.flushMs) && opts.flushMs > 0 ? opts.flushMs : DEFAULT_FLUSH_MS;
|
|
3721
4465
|
const maxQueue = Number.isFinite(opts.maxQueue) && opts.maxQueue > 0 ? opts.maxQueue : MAX_QUEUE;
|
|
4466
|
+
let rulesEtag = opts.rulesEtag ?? null;
|
|
4467
|
+
let sent = 0;
|
|
4468
|
+
let delivered = 0;
|
|
4469
|
+
let failed = 0;
|
|
4470
|
+
let droppedTotal = 0;
|
|
4471
|
+
let lastDeliveredAt = null;
|
|
3722
4472
|
let queue = [];
|
|
3723
4473
|
let timer = null;
|
|
3724
4474
|
let stopped = false;
|
|
@@ -3732,6 +4482,8 @@ function createDetectionReporter(opts) {
|
|
|
3732
4482
|
const batch = queue.splice(0, MAX_BATCH);
|
|
3733
4483
|
const droppedWith = dropped;
|
|
3734
4484
|
dropped = 0;
|
|
4485
|
+
droppedTotal += droppedWith;
|
|
4486
|
+
sent += batch.length;
|
|
3735
4487
|
void (async () => {
|
|
3736
4488
|
try {
|
|
3737
4489
|
const res = await fetchImpl(`${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, {
|
|
@@ -3740,15 +4492,22 @@ function createDetectionReporter(opts) {
|
|
|
3740
4492
|
"Content-Type": "application/json",
|
|
3741
4493
|
Accept: "application/json",
|
|
3742
4494
|
"User-Agent": "@patchstack/connect",
|
|
3743
|
-
// Same credential path as the rules fetch
|
|
3744
|
-
//
|
|
4495
|
+
// Same credential path as the rules fetch. The detections endpoint is site-addressed and
|
|
4496
|
+
// requires a verified, site-bound token, so a batch sent without one is refused — which is
|
|
4497
|
+
// why the runtime does not build a reporter when no credential resolves, rather than
|
|
4498
|
+
// posting into a 401.
|
|
3745
4499
|
...await pulseAuthHeader({ pulseAuth: opts.pulseAuth, endpoint: baseUrl }, fetchImpl)
|
|
3746
4500
|
},
|
|
3747
4501
|
body: JSON.stringify({ detections: batch, dropped: droppedWith })
|
|
3748
4502
|
});
|
|
3749
|
-
if (res &&
|
|
3750
|
-
|
|
4503
|
+
if (res && res.ok) {
|
|
4504
|
+
delivered += batch.length;
|
|
4505
|
+
lastDeliveredAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4506
|
+
} else {
|
|
4507
|
+
failed += batch.length;
|
|
4508
|
+
}
|
|
3751
4509
|
} catch {
|
|
4510
|
+
failed += batch.length;
|
|
3752
4511
|
}
|
|
3753
4512
|
})();
|
|
3754
4513
|
};
|
|
@@ -3777,7 +4536,12 @@ function createDetectionReporter(opts) {
|
|
|
3777
4536
|
// The state this detection was handled under, which is the whole point: `false` is a rule that
|
|
3778
4537
|
// saw traffic it would have stopped.
|
|
3779
4538
|
enforced: detection.mode === "block",
|
|
3780
|
-
rules_etag:
|
|
4539
|
+
rules_etag: rulesEtag,
|
|
4540
|
+
// The revision of THIS rule, as the bundle delivered it. The bundle identity above answers "which
|
|
4541
|
+
// bundle", which changes whenever anything in it changes — so it cannot say whether the counts for
|
|
4542
|
+
// one rule describe the document that rule has now. Passed through untouched, and null when the
|
|
4543
|
+
// bundle carried none.
|
|
4544
|
+
rule_revision: revisionOf(detection.rule),
|
|
3781
4545
|
detected_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
3782
4546
|
});
|
|
3783
4547
|
if (queue.length >= MAX_BATCH) {
|
|
@@ -3791,7 +4555,24 @@ function createDetectionReporter(opts) {
|
|
|
3791
4555
|
stopped = true;
|
|
3792
4556
|
flush();
|
|
3793
4557
|
},
|
|
3794
|
-
|
|
4558
|
+
/**
|
|
4559
|
+
* Point later events at the bundle now running. Called after an ACCEPTED swap only — a rejected
|
|
4560
|
+
* or failed refresh keeps the previous rules, so it must keep the previous identity too.
|
|
4561
|
+
*
|
|
4562
|
+
* Already-queued events are not rewritten: the stamp is taken when an event is recorded, which is
|
|
4563
|
+
* the only moment the two are known to agree.
|
|
4564
|
+
*
|
|
4565
|
+
* @param {string | null | undefined} next
|
|
4566
|
+
*/
|
|
4567
|
+
setRulesEtag(next) {
|
|
4568
|
+
rulesEtag = next ?? null;
|
|
4569
|
+
},
|
|
4570
|
+
dropped: () => dropped,
|
|
4571
|
+
/**
|
|
4572
|
+
* Delivery health, counted in events: attempted, acknowledged, refused or unreachable, and dropped
|
|
4573
|
+
* for queue pressure — plus when a batch was last acknowledged. No request data of any kind.
|
|
4574
|
+
*/
|
|
4575
|
+
health: () => ({ sent, delivered, failed, dropped: droppedTotal + dropped, lastDeliveredAt })
|
|
3795
4576
|
};
|
|
3796
4577
|
}
|
|
3797
4578
|
|
|
@@ -4056,22 +4837,32 @@ async function createProtection(options = {}) {
|
|
|
4056
4837
|
const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
|
|
4057
4838
|
const pulseAuth = await resolvePulseAuth(options);
|
|
4058
4839
|
if (options.siteUuid && !pulseAuth) {
|
|
4059
|
-
const message = "Patchstack: no API credential resolved for site " + options.siteUuid + ". Rule updates
|
|
4840
|
+
const message = "Patchstack: no API credential resolved for site " + options.siteUuid + ". Rule updates will be rejected and this guard would keep running on its cached rules. Set PATCHSTACK_API_KEY (or pass { pulseAuth }) \u2014 required on runtimes without a filesystem.";
|
|
4060
4841
|
notify(onError, new Error(message), "onError");
|
|
4061
4842
|
console.warn(message);
|
|
4062
4843
|
}
|
|
4063
4844
|
const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth });
|
|
4845
|
+
let detectionReporting = "off";
|
|
4064
4846
|
if (options.reportDetections === true && options.siteUuid && telemetryEnabled()) {
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4847
|
+
if (!pulseAuth) {
|
|
4848
|
+
detectionReporting = "unavailable-no-credential";
|
|
4849
|
+
const message = "Patchstack: detection reporting is enabled for site " + options.siteUuid + " but no API credential resolved, so no report could be delivered. Reporting is off.";
|
|
4850
|
+
notify(onError, new Error(message), "onError");
|
|
4851
|
+
console.warn(message);
|
|
4852
|
+
} else {
|
|
4853
|
+
detectionReporting = "on";
|
|
4854
|
+
detections = createDetectionReporter({
|
|
4855
|
+
siteUuid: options.siteUuid,
|
|
4856
|
+
baseUrl: options.pulseRulesUrl,
|
|
4857
|
+
pulseAuth,
|
|
4858
|
+
// The bundle the guard is actually running, so a hit can be attributed to the rules that produced
|
|
4859
|
+
// it rather than to whatever is current when the report is read. Kept current across refreshes —
|
|
4860
|
+
// see the refresh tick below.
|
|
4861
|
+
rulesEtag: (await store.read())?.etag ?? null,
|
|
4862
|
+
fetchImpl: options.fetchImpl,
|
|
4863
|
+
flushMs: options.detectionFlushMs
|
|
4864
|
+
});
|
|
4865
|
+
}
|
|
4075
4866
|
}
|
|
4076
4867
|
let mode = resolveMode(options, bundle);
|
|
4077
4868
|
let requestRules;
|
|
@@ -4405,11 +5196,17 @@ async function createProtection(options = {}) {
|
|
|
4405
5196
|
);
|
|
4406
5197
|
};
|
|
4407
5198
|
},
|
|
4408
|
-
// Node / Connect middleware — buffers the body itself (request phase).
|
|
5199
|
+
// Node / Connect middleware — buffers the body itself (request phase). Register it BEFORE any body
|
|
5200
|
+
// parser: it reads the request stream, and exposes what it read as `req.body` so a parser is not
|
|
5201
|
+
// also needed. (`.express()` is the other way round — it reads a body somebody else parsed.)
|
|
4409
5202
|
// Pass { screenResponses: true } to also screen the outgoing response (buffers it).
|
|
4410
5203
|
node(nodeOptions = {}) {
|
|
4411
5204
|
const maxBytes = nodeOptions.maxBodyBytes ?? 1024 * 1024;
|
|
4412
5205
|
return (req, res, next) => {
|
|
5206
|
+
if (req.readableEnded || req.body !== void 0) {
|
|
5207
|
+
screenNodeRequest(req, res, next, "", req.body);
|
|
5208
|
+
return;
|
|
5209
|
+
}
|
|
4413
5210
|
const chunks = [];
|
|
4414
5211
|
let size = 0;
|
|
4415
5212
|
let overflow = false;
|
|
@@ -4427,38 +5224,41 @@ async function createProtection(options = {}) {
|
|
|
4427
5224
|
});
|
|
4428
5225
|
req.on("end", () => {
|
|
4429
5226
|
if (overflow) recordSkip("request", "body-cap", { bytes: size, limit: maxBytes });
|
|
4430
|
-
|
|
4431
|
-
let shaped;
|
|
4432
|
-
let result;
|
|
4433
|
-
try {
|
|
4434
|
-
shaped = fromNodeRequest(req, rawBody);
|
|
4435
|
-
result = engine.evaluate(shaped);
|
|
4436
|
-
} catch (err) {
|
|
4437
|
-
notify(onError, err, "onError");
|
|
4438
|
-
return next();
|
|
4439
|
-
}
|
|
4440
|
-
decide(
|
|
4441
|
-
"request",
|
|
4442
|
-
result,
|
|
4443
|
-
() => {
|
|
4444
|
-
res.statusCode = 403;
|
|
4445
|
-
if (isDocumentNavigation((n) => req.headers?.[n])) {
|
|
4446
|
-
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
4447
|
-
res.end(renderBlockPage({ url: req.url || "/", code: result?.rule?.id }));
|
|
4448
|
-
} else {
|
|
4449
|
-
res.setHeader("content-type", "application/json");
|
|
4450
|
-
res.end(JSON.stringify(blockBody(result)));
|
|
4451
|
-
}
|
|
4452
|
-
},
|
|
4453
|
-
() => {
|
|
4454
|
-
if (req.body === void 0) req.body = shaped.body;
|
|
4455
|
-
if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
4456
|
-
next();
|
|
4457
|
-
},
|
|
4458
|
-
nodeRequestMeta(req)
|
|
4459
|
-
);
|
|
5227
|
+
screenNodeRequest(req, res, next, overflow ? "" : Buffer.concat(chunks).toString("utf8"));
|
|
4460
5228
|
});
|
|
4461
5229
|
};
|
|
5230
|
+
function screenNodeRequest(req, res, next, rawBody, parsedBody) {
|
|
5231
|
+
let shaped;
|
|
5232
|
+
let result;
|
|
5233
|
+
try {
|
|
5234
|
+
shaped = fromNodeRequest(req, rawBody);
|
|
5235
|
+
if (parsedBody !== void 0 && parsedBody !== null) shaped.body = parsedBody;
|
|
5236
|
+
result = engine.evaluate(shaped);
|
|
5237
|
+
} catch (err) {
|
|
5238
|
+
notify(onError, err, "onError");
|
|
5239
|
+
return next();
|
|
5240
|
+
}
|
|
5241
|
+
decide(
|
|
5242
|
+
"request",
|
|
5243
|
+
result,
|
|
5244
|
+
() => {
|
|
5245
|
+
res.statusCode = 403;
|
|
5246
|
+
if (isDocumentNavigation((n) => req.headers?.[n])) {
|
|
5247
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
5248
|
+
res.end(renderBlockPage({ url: req.url || "/", code: result?.rule?.id }));
|
|
5249
|
+
} else {
|
|
5250
|
+
res.setHeader("content-type", "application/json");
|
|
5251
|
+
res.end(JSON.stringify(blockBody(result)));
|
|
5252
|
+
}
|
|
5253
|
+
},
|
|
5254
|
+
() => {
|
|
5255
|
+
if (req.body === void 0) req.body = shaped.body;
|
|
5256
|
+
if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
5257
|
+
next();
|
|
5258
|
+
},
|
|
5259
|
+
nodeRequestMeta(req)
|
|
5260
|
+
);
|
|
5261
|
+
}
|
|
4462
5262
|
}
|
|
4463
5263
|
};
|
|
4464
5264
|
if (options.egress) {
|
|
@@ -4495,24 +5295,22 @@ async function createProtection(options = {}) {
|
|
|
4495
5295
|
const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
|
|
4496
5296
|
mode = resolveMode(options, next);
|
|
4497
5297
|
applyBundle(next);
|
|
5298
|
+
if (detections) detections.setRulesEtag((await store.read())?.etag ?? null);
|
|
5299
|
+
return next.source ?? { ok: true };
|
|
4498
5300
|
};
|
|
4499
5301
|
if (live) {
|
|
4500
5302
|
protection.refresh = () => runRefreshTick();
|
|
4501
5303
|
protection.refreshHandler = () => makeRefreshHandler(runRefreshTick, refreshSecret);
|
|
4502
5304
|
}
|
|
4503
|
-
|
|
4504
|
-
|
|
4505
|
-
|
|
4506
|
-
|
|
4507
|
-
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
|
|
4512
|
-
firewallLog.stop();
|
|
4513
|
-
detections?.stop();
|
|
4514
|
-
};
|
|
4515
|
-
}
|
|
5305
|
+
const loop = options.refreshMs > 0 && live ? startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError }) : null;
|
|
5306
|
+
protection.stop = () => {
|
|
5307
|
+
loop?.stop();
|
|
5308
|
+
firewallLog?.stop();
|
|
5309
|
+
detections?.stop();
|
|
5310
|
+
};
|
|
5311
|
+
protection.stopRefresh = protection.stop;
|
|
5312
|
+
protection.detectionReporting = detectionReporting;
|
|
5313
|
+
if (detections) protection.detectionHealth = () => detections.health();
|
|
4516
5314
|
return protection;
|
|
4517
5315
|
}
|
|
4518
5316
|
function resolveMode(options, bundle) {
|
|
@@ -4523,22 +5321,35 @@ function resolveMode(options, bundle) {
|
|
|
4523
5321
|
if (options?.mode === "dry-run") return "dry-run";
|
|
4524
5322
|
return "dry-run";
|
|
4525
5323
|
}
|
|
5324
|
+
var CREDENTIAL_FILES = [".patchstackrc.local.json", ".patchstackrc.json"];
|
|
5325
|
+
async function readCredentialField(options, field) {
|
|
5326
|
+
if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
|
|
5327
|
+
let readFileSync;
|
|
5328
|
+
let join;
|
|
5329
|
+
try {
|
|
5330
|
+
const [fs, pathMod] = await Promise.all([import("fs"), import("path")]);
|
|
5331
|
+
readFileSync = fs.readFileSync;
|
|
5332
|
+
join = pathMod.join;
|
|
5333
|
+
} catch {
|
|
5334
|
+
return void 0;
|
|
5335
|
+
}
|
|
5336
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
5337
|
+
for (const filename of CREDENTIAL_FILES) {
|
|
5338
|
+
try {
|
|
5339
|
+
const value = JSON.parse(readFileSync(join(cwd, filename), "utf8"))?.[field];
|
|
5340
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
5341
|
+
} catch {
|
|
5342
|
+
}
|
|
5343
|
+
}
|
|
5344
|
+
return void 0;
|
|
5345
|
+
}
|
|
4526
5346
|
async function resolveApiKey(options) {
|
|
4527
5347
|
if (typeof options?.apiKey === "string" && options.apiKey.length > 0) return options.apiKey;
|
|
4528
5348
|
if (typeof process !== "undefined") {
|
|
4529
5349
|
const fromEnv = process.env?.PATCHSTACK_API_KEY;
|
|
4530
5350
|
if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
|
|
4531
5351
|
}
|
|
4532
|
-
|
|
4533
|
-
if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
|
|
4534
|
-
const [{ readFileSync }, { join }] = await Promise.all([import("fs"), import("path")]);
|
|
4535
|
-
const cwd = options?.cwd ?? process.cwd();
|
|
4536
|
-
const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
|
|
4537
|
-
const key = JSON.parse(raw)?.apiKey;
|
|
4538
|
-
if (typeof key === "string" && key.length > 0) return key;
|
|
4539
|
-
} catch {
|
|
4540
|
-
}
|
|
4541
|
-
return void 0;
|
|
5352
|
+
return readCredentialField(options, "apiKey");
|
|
4542
5353
|
}
|
|
4543
5354
|
async function resolvePulseAuth(options) {
|
|
4544
5355
|
if (typeof options?.pulseAuth === "string" && options.pulseAuth.length > 0) return options.pulseAuth;
|
|
@@ -4546,15 +5357,8 @@ async function resolvePulseAuth(options) {
|
|
|
4546
5357
|
const fromEnv = process.env?.PATCHSTACK_PULSE_AUTH;
|
|
4547
5358
|
if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
|
|
4548
5359
|
}
|
|
4549
|
-
|
|
4550
|
-
|
|
4551
|
-
const [{ readFileSync }, { join }] = await Promise.all([import("fs"), import("path")]);
|
|
4552
|
-
const cwd = options?.cwd ?? process.cwd();
|
|
4553
|
-
const raw = readFileSync(join(cwd, ".patchstackrc.json"), "utf8");
|
|
4554
|
-
const key = JSON.parse(raw)?.pulseAuth;
|
|
4555
|
-
if (typeof key === "string" && key.length > 0) return key;
|
|
4556
|
-
} catch {
|
|
4557
|
-
}
|
|
5360
|
+
const fromFile = await readCredentialField(options, "pulseAuth");
|
|
5361
|
+
if (fromFile !== void 0) return fromFile;
|
|
4558
5362
|
return resolveApiKey(options);
|
|
4559
5363
|
}
|
|
4560
5364
|
function byPhase(rules, phase) {
|
|
@@ -4839,11 +5643,13 @@ function applyHeaderMutation(headers, rule) {
|
|
|
4839
5643
|
}
|
|
4840
5644
|
}
|
|
4841
5645
|
}
|
|
5646
|
+
var SAME_SITE = ["Strict", "Lax", "None"];
|
|
4842
5647
|
function hardenCookie(cookie, { httpOnly = true, secure = true, sameSite = "Lax" } = {}) {
|
|
4843
5648
|
let out = String(cookie);
|
|
4844
5649
|
if (httpOnly && !/;\s*httponly/i.test(out)) out += "; HttpOnly";
|
|
4845
5650
|
if (secure && !/;\s*secure/i.test(out)) out += "; Secure";
|
|
4846
|
-
|
|
5651
|
+
const canonical = SAME_SITE.find((v) => typeof sameSite === "string" && v.toLowerCase() === sameSite.toLowerCase());
|
|
5652
|
+
if (canonical && !/;\s*samesite\s*=/i.test(out)) out += `; SameSite=${canonical}`;
|
|
4847
5653
|
return out;
|
|
4848
5654
|
}
|
|
4849
5655
|
function rebuildResponse(response, body, redactedHeaders) {
|
|
@@ -4857,11 +5663,9 @@ function rebuildResponse(response, body, redactedHeaders) {
|
|
|
4857
5663
|
} catch {
|
|
4858
5664
|
}
|
|
4859
5665
|
} else if (typeof value === "string") {
|
|
4860
|
-
|
|
4861
|
-
|
|
4862
|
-
|
|
4863
|
-
} catch {
|
|
4864
|
-
}
|
|
5666
|
+
try {
|
|
5667
|
+
if (headers.get(name) !== value) headers.set(name, value);
|
|
5668
|
+
} catch {
|
|
4865
5669
|
}
|
|
4866
5670
|
} else if (Array.isArray(value)) {
|
|
4867
5671
|
try {
|