@patchstack/connect 0.3.29 → 0.3.31
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 +42 -23
- package/README.md +40 -11
- package/dist/{chunk-MJOTFUDE.js → chunk-LLKP5EJS.js} +1 -1
- package/dist/chunk-LLKP5EJS.js.map +1 -0
- package/dist/cli.js +1265 -287
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +456 -111
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +76 -10
- package/dist/index.d.ts +76 -10
- package/dist/index.js +449 -104
- 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 +1204 -252
- package/dist/protect.cjs.map +1 -1
- package/dist/protect.d.ts +39 -11
- package/dist/protect.edge.js +792 -143
- package/dist/protect.edge.js.map +4 -4
- package/dist/protect.js +794 -145
- package/dist/protect.js.map +1 -1
- package/dist/{refresh-manifest-VRBE6RH6.js → refresh-manifest-KNZQBC4V.js} +387 -100
- package/dist/refresh-manifest-KNZQBC4V.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,134 @@ 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, cwd));
|
|
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, root) {
|
|
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, root);
|
|
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, root);
|
|
721
|
+
});
|
|
722
|
+
return scoped.flat();
|
|
540
723
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
724
|
+
return packageAndNested(fullPath, resolved, depth, seen, root);
|
|
725
|
+
});
|
|
726
|
+
return perEntry.flat();
|
|
727
|
+
}
|
|
728
|
+
async function packageAndNested(pkgDir, resolved, depth, seen, root) {
|
|
729
|
+
const [own, nested] = await Promise.all([readPackage(pkgDir, depth, root), walkNested(pkgDir, depth, seen, root)]);
|
|
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
|
+
function installLocation(root, pkgDir, impl = import_node_path4.default) {
|
|
745
|
+
return impl.relative(root, pkgDir).split(impl.sep).join("/");
|
|
746
|
+
}
|
|
747
|
+
async function readPackage(pkgDir, depth, root) {
|
|
546
748
|
let raw;
|
|
547
749
|
try {
|
|
548
|
-
raw = await (0,
|
|
750
|
+
raw = await (0, import_promises5.readFile)(import_node_path4.default.join(pkgDir, "package.json"), "utf8");
|
|
549
751
|
} catch {
|
|
550
|
-
return;
|
|
752
|
+
return null;
|
|
551
753
|
}
|
|
552
754
|
let parsed;
|
|
553
755
|
try {
|
|
554
756
|
parsed = JSON.parse(raw);
|
|
555
757
|
} catch {
|
|
556
|
-
return;
|
|
758
|
+
return null;
|
|
557
759
|
}
|
|
558
760
|
if (typeof parsed.name !== "string" || parsed.name.length === 0) {
|
|
559
|
-
return;
|
|
761
|
+
return null;
|
|
560
762
|
}
|
|
561
763
|
if (typeof parsed.version !== "string" || parsed.version.length === 0) {
|
|
562
|
-
return;
|
|
764
|
+
return null;
|
|
563
765
|
}
|
|
564
|
-
|
|
565
|
-
name: parsed.name,
|
|
566
|
-
version: parsed.version,
|
|
567
|
-
direct: depth === 0
|
|
568
|
-
});
|
|
766
|
+
return { name: parsed.name, version: parsed.version, path: installLocation(root, pkgDir), direct: depth === 0 };
|
|
569
767
|
}
|
|
570
|
-
async function walkNested(pkgDir,
|
|
768
|
+
async function walkNested(pkgDir, depth, seen, root) {
|
|
571
769
|
const nested = import_node_path4.default.join(pkgDir, "node_modules");
|
|
572
|
-
if (
|
|
573
|
-
return;
|
|
770
|
+
if (await claim(nested, seen, depth + 1) === null) {
|
|
771
|
+
return [];
|
|
574
772
|
}
|
|
575
|
-
|
|
773
|
+
return walk(nested, depth + 1, seen, root);
|
|
576
774
|
}
|
|
577
|
-
async function
|
|
775
|
+
async function claim(dir, seen, depth) {
|
|
776
|
+
let resolved;
|
|
578
777
|
try {
|
|
579
|
-
const info = await (0,
|
|
580
|
-
|
|
778
|
+
const info = await (0, import_promises5.lstat)(dir);
|
|
779
|
+
if (!info.isDirectory() && !info.isSymbolicLink()) return null;
|
|
780
|
+
resolved = await (0, import_promises5.realpath)(dir);
|
|
781
|
+
const target = await (0, import_promises5.stat)(resolved);
|
|
782
|
+
if (!target.isDirectory()) return null;
|
|
581
783
|
} catch {
|
|
582
|
-
return
|
|
784
|
+
return null;
|
|
583
785
|
}
|
|
786
|
+
const claimed = seen.get(resolved);
|
|
787
|
+
if (claimed !== void 0 && claimed <= depth) return null;
|
|
788
|
+
seen.set(resolved, depth);
|
|
789
|
+
return resolved;
|
|
584
790
|
}
|
|
585
|
-
var
|
|
791
|
+
var import_promises5, import_node_path4, PACKAGE_STORE_DIRS, CONCURRENCY;
|
|
586
792
|
var init_node_modules = __esm({
|
|
587
793
|
"src/parsers/node_modules.ts"() {
|
|
588
794
|
"use strict";
|
|
589
|
-
|
|
795
|
+
import_promises5 = require("fs/promises");
|
|
590
796
|
import_node_path4 = __toESM(require("path"), 1);
|
|
591
797
|
init_types();
|
|
798
|
+
PACKAGE_STORE_DIRS = /* @__PURE__ */ new Set([".bun", ".pnpm", ".store"]);
|
|
799
|
+
CONCURRENCY = 32;
|
|
592
800
|
}
|
|
593
801
|
});
|
|
594
802
|
|
|
595
803
|
// src/parsers/pnpm.ts
|
|
596
|
-
async function parsePnpmLockfile(lockfilePath) {
|
|
804
|
+
async function parsePnpmLockfile(lockfilePath, report) {
|
|
597
805
|
let raw;
|
|
598
806
|
try {
|
|
599
|
-
raw = await (0,
|
|
807
|
+
raw = await (0, import_promises6.readFile)(lockfilePath, "utf8");
|
|
600
808
|
} catch (cause) {
|
|
601
809
|
throw new PatchstackError(
|
|
602
810
|
`Could not read lockfile at ${lockfilePath}`,
|
|
@@ -617,6 +825,7 @@ async function parsePnpmLockfile(lockfilePath) {
|
|
|
617
825
|
for (const key of packageKeys) {
|
|
618
826
|
const parsed = parsePackageKey(key);
|
|
619
827
|
if (parsed === null) {
|
|
828
|
+
recordUnreadable(report, key);
|
|
620
829
|
continue;
|
|
621
830
|
}
|
|
622
831
|
entries.push({
|
|
@@ -837,12 +1046,13 @@ function extractLeafName(trimmed) {
|
|
|
837
1046
|
}
|
|
838
1047
|
return name.length > 0 ? name : null;
|
|
839
1048
|
}
|
|
840
|
-
var
|
|
1049
|
+
var import_promises6, DEP_SECTIONS;
|
|
841
1050
|
var init_pnpm = __esm({
|
|
842
1051
|
"src/parsers/pnpm.ts"() {
|
|
843
1052
|
"use strict";
|
|
844
|
-
|
|
1053
|
+
import_promises6 = require("fs/promises");
|
|
845
1054
|
init_types();
|
|
1055
|
+
init_report();
|
|
846
1056
|
DEP_SECTIONS = /* @__PURE__ */ new Set([
|
|
847
1057
|
"dependencies",
|
|
848
1058
|
"devDependencies",
|
|
@@ -852,10 +1062,10 @@ var init_pnpm = __esm({
|
|
|
852
1062
|
});
|
|
853
1063
|
|
|
854
1064
|
// src/parsers/yarn.ts
|
|
855
|
-
async function parseYarnLockfile(lockfilePath) {
|
|
1065
|
+
async function parseYarnLockfile(lockfilePath, report) {
|
|
856
1066
|
let raw;
|
|
857
1067
|
try {
|
|
858
|
-
raw = await (0,
|
|
1068
|
+
raw = await (0, import_promises7.readFile)(lockfilePath, "utf8");
|
|
859
1069
|
} catch (cause) {
|
|
860
1070
|
throw new PatchstackError(
|
|
861
1071
|
`Could not read lockfile at ${lockfilePath}`,
|
|
@@ -863,7 +1073,7 @@ async function parseYarnLockfile(lockfilePath) {
|
|
|
863
1073
|
cause
|
|
864
1074
|
);
|
|
865
1075
|
}
|
|
866
|
-
const blocks = parseBlocks(raw);
|
|
1076
|
+
const blocks = parseBlocks(raw, report);
|
|
867
1077
|
if (blocks.length === 0) {
|
|
868
1078
|
throw new PatchstackError(
|
|
869
1079
|
`Lockfile at ${lockfilePath} contains no package entries`,
|
|
@@ -892,13 +1102,18 @@ async function parseYarnLockfile(lockfilePath) {
|
|
|
892
1102
|
}
|
|
893
1103
|
return entries;
|
|
894
1104
|
}
|
|
895
|
-
function parseBlocks(raw) {
|
|
1105
|
+
function parseBlocks(raw, report) {
|
|
896
1106
|
const lines = raw.split(/\r?\n/);
|
|
897
1107
|
const blocks = [];
|
|
898
1108
|
let current = null;
|
|
899
1109
|
const finalize = () => {
|
|
900
|
-
if (current
|
|
1110
|
+
if (current === null) {
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
if (current.version.length > 0 && current.names.size > 0) {
|
|
901
1114
|
blocks.push(current);
|
|
1115
|
+
} else if (current.unreadable) {
|
|
1116
|
+
recordUnreadable(report, current.key);
|
|
902
1117
|
}
|
|
903
1118
|
current = null;
|
|
904
1119
|
};
|
|
@@ -915,13 +1130,13 @@ function parseBlocks(raw) {
|
|
|
915
1130
|
}
|
|
916
1131
|
const keyLine = trimmed.slice(0, -1);
|
|
917
1132
|
const names = /* @__PURE__ */ new Set();
|
|
1133
|
+
let unreadable = false;
|
|
918
1134
|
for (const spec of splitDescriptors(keyLine)) {
|
|
919
|
-
const
|
|
920
|
-
if (name !==
|
|
921
|
-
|
|
922
|
-
}
|
|
1135
|
+
const classified = classifyDescriptor(spec);
|
|
1136
|
+
if (classified.name !== void 0) names.add(classified.name);
|
|
1137
|
+
else if (!classified.excluded) unreadable = true;
|
|
923
1138
|
}
|
|
924
|
-
current = { names, version: "" };
|
|
1139
|
+
current = { names, version: "", key: keyLine, unreadable: unreadable && names.size === 0 };
|
|
925
1140
|
continue;
|
|
926
1141
|
}
|
|
927
1142
|
if (current === null) {
|
|
@@ -976,20 +1191,36 @@ function splitDescriptors(keyLine) {
|
|
|
976
1191
|
}
|
|
977
1192
|
return parts;
|
|
978
1193
|
}
|
|
979
|
-
function
|
|
1194
|
+
function classifyDescriptor(rawSpec) {
|
|
980
1195
|
let s = rawSpec.trim();
|
|
981
1196
|
if (s.length === 0) {
|
|
982
|
-
return
|
|
1197
|
+
return { excluded: true };
|
|
983
1198
|
}
|
|
984
1199
|
if (s.startsWith('"') && s.endsWith('"') || s.startsWith("'") && s.endsWith("'")) {
|
|
985
1200
|
s = s.slice(1, -1);
|
|
986
1201
|
}
|
|
987
|
-
|
|
1202
|
+
if (NON_PACKAGE_KEYS.has(s)) {
|
|
1203
|
+
return { excluded: true };
|
|
1204
|
+
}
|
|
1205
|
+
const atIdx = s.indexOf("@", 1);
|
|
988
1206
|
if (atIdx <= 0) {
|
|
989
|
-
return
|
|
1207
|
+
return {};
|
|
990
1208
|
}
|
|
991
1209
|
const name = s.slice(0, atIdx);
|
|
992
|
-
|
|
1210
|
+
const range = s.slice(atIdx + 1);
|
|
1211
|
+
if (name.length === 0) {
|
|
1212
|
+
return {};
|
|
1213
|
+
}
|
|
1214
|
+
if (NON_REGISTRY_PROTOCOLS.test(range)) {
|
|
1215
|
+
return { excluded: true };
|
|
1216
|
+
}
|
|
1217
|
+
const alias = /^npm:(.+)$/i.exec(range);
|
|
1218
|
+
if (alias !== null) {
|
|
1219
|
+
const target = alias[1] ?? "";
|
|
1220
|
+
const targetAt = target.indexOf("@", 1);
|
|
1221
|
+
return { name: targetAt > 0 ? target.slice(0, targetAt) : name };
|
|
1222
|
+
}
|
|
1223
|
+
return { name };
|
|
993
1224
|
}
|
|
994
1225
|
function parseVersionField(content) {
|
|
995
1226
|
if (!content.startsWith("version")) {
|
|
@@ -1014,7 +1245,7 @@ async function readDirectDepNames(cwd) {
|
|
|
1014
1245
|
const names = /* @__PURE__ */ new Set();
|
|
1015
1246
|
let raw;
|
|
1016
1247
|
try {
|
|
1017
|
-
raw = await (0,
|
|
1248
|
+
raw = await (0, import_promises7.readFile)(import_node_path5.default.join(cwd, "package.json"), "utf8");
|
|
1018
1249
|
} catch {
|
|
1019
1250
|
return names;
|
|
1020
1251
|
}
|
|
@@ -1039,13 +1270,16 @@ async function readDirectDepNames(cwd) {
|
|
|
1039
1270
|
}
|
|
1040
1271
|
return names;
|
|
1041
1272
|
}
|
|
1042
|
-
var
|
|
1273
|
+
var import_promises7, import_node_path5, NON_PACKAGE_KEYS, NON_REGISTRY_PROTOCOLS;
|
|
1043
1274
|
var init_yarn = __esm({
|
|
1044
1275
|
"src/parsers/yarn.ts"() {
|
|
1045
1276
|
"use strict";
|
|
1046
|
-
|
|
1277
|
+
import_promises7 = require("fs/promises");
|
|
1047
1278
|
import_node_path5 = __toESM(require("path"), 1);
|
|
1048
1279
|
init_types();
|
|
1280
|
+
init_report();
|
|
1281
|
+
NON_PACKAGE_KEYS = /* @__PURE__ */ new Set(["__metadata"]);
|
|
1282
|
+
NON_REGISTRY_PROTOCOLS = /^(?:workspace|patch|virtual|portal|file|link|exec|git|github|https?|ssh):/i;
|
|
1049
1283
|
}
|
|
1050
1284
|
});
|
|
1051
1285
|
|
|
@@ -1080,34 +1314,58 @@ async function scanLockfile(cwd) {
|
|
|
1080
1314
|
const declared = await readDeclaredDependencyNames(cwd);
|
|
1081
1315
|
const warnings = [];
|
|
1082
1316
|
let firstParsed = null;
|
|
1083
|
-
let
|
|
1317
|
+
let walked = null;
|
|
1318
|
+
let walkWasCandidate = false;
|
|
1319
|
+
const parsedSources = [];
|
|
1084
1320
|
for (const candidate of candidates) {
|
|
1085
1321
|
let packages;
|
|
1322
|
+
const report = newReport();
|
|
1086
1323
|
try {
|
|
1087
|
-
packages = await runStrategy(candidate, cwd);
|
|
1324
|
+
packages = await runStrategy(candidate, cwd, report);
|
|
1088
1325
|
} catch {
|
|
1089
1326
|
continue;
|
|
1090
1327
|
}
|
|
1091
|
-
|
|
1328
|
+
const unreadable = unreadableWarning(candidate.filename, report);
|
|
1329
|
+
if (unreadable !== null) warnings.push(unreadable);
|
|
1330
|
+
if (candidate.strategy === "node-modules-walk") {
|
|
1331
|
+
walked = packages;
|
|
1332
|
+
walkWasCandidate = true;
|
|
1333
|
+
}
|
|
1092
1334
|
firstParsed ??= { packages, filename: candidate.filename };
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1335
|
+
parsedSources.push({ packages, filename: candidate.filename });
|
|
1336
|
+
}
|
|
1337
|
+
for (const source of parsedSources) {
|
|
1338
|
+
const missing = missingDependencies(declared, source.packages);
|
|
1339
|
+
if (missing.length > 0) {
|
|
1340
|
+
warnings.push(staleWarning(source.filename, missing));
|
|
1341
|
+
continue;
|
|
1096
1342
|
}
|
|
1097
|
-
|
|
1343
|
+
const conflicts = otherCompleteSources(parsedSources, source, declared).flatMap(
|
|
1344
|
+
(other) => disagreements(source.packages, other.packages).map((conflict) => ({ other: other.filename, conflict }))
|
|
1345
|
+
);
|
|
1346
|
+
if (conflicts.length === 0) {
|
|
1347
|
+
return manifestWith(source.packages, warnings);
|
|
1348
|
+
}
|
|
1349
|
+
warnings.push(conflictWarning(source.filename, conflicts));
|
|
1350
|
+
break;
|
|
1098
1351
|
}
|
|
1099
|
-
if (
|
|
1352
|
+
if (walked === null) {
|
|
1100
1353
|
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);
|
|
1354
|
+
walked = await walkNodeModules(cwd);
|
|
1108
1355
|
} catch {
|
|
1356
|
+
walked = null;
|
|
1109
1357
|
}
|
|
1110
1358
|
}
|
|
1359
|
+
if (walked !== null) {
|
|
1360
|
+
const missing = missingDependencies(declared, walked);
|
|
1361
|
+
if (missing.length > 0) {
|
|
1362
|
+
warnings.push(staleWarning("node_modules/", missing));
|
|
1363
|
+
}
|
|
1364
|
+
warnings.push(
|
|
1365
|
+
walkWasCandidate ? "Reporting node_modules/, which is what the build loads." : "Scanned node_modules/ instead. Delete the stale lockfile to silence this warning."
|
|
1366
|
+
);
|
|
1367
|
+
return manifestWith(walked, warnings);
|
|
1368
|
+
}
|
|
1111
1369
|
if (firstParsed === null) {
|
|
1112
1370
|
const expected = LOCKFILE_CANDIDATES.map((candidate) => candidate.filename).join(", ");
|
|
1113
1371
|
throw new PatchstackError(
|
|
@@ -1123,6 +1381,15 @@ async function scanLockfile(cwd) {
|
|
|
1123
1381
|
function manifestWith(packages, warnings) {
|
|
1124
1382
|
return warnings.length > 0 ? { ecosystem: "npm", packages, warnings } : { ecosystem: "npm", packages };
|
|
1125
1383
|
}
|
|
1384
|
+
function otherCompleteSources(all, current, declared) {
|
|
1385
|
+
return all.filter(
|
|
1386
|
+
(source) => source.filename !== current.filename && missingDependencies(declared, source.packages).length === 0
|
|
1387
|
+
);
|
|
1388
|
+
}
|
|
1389
|
+
function conflictWarning(filename, conflicts) {
|
|
1390
|
+
const shown = conflicts.slice(0, 3).map(({ other, conflict }) => `${conflict.name} ${conflict.version} vs ${conflict.otherVersion} in ${other}`).join("; ");
|
|
1391
|
+
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.`;
|
|
1392
|
+
}
|
|
1126
1393
|
function staleWarning(source, missing) {
|
|
1127
1394
|
const sample = missing.slice(0, 3).join(", ");
|
|
1128
1395
|
const suffix = missing.length > 3 ? `, +${missing.length - 3} more` : "";
|
|
@@ -1137,41 +1404,50 @@ async function presentLockfiles(cwd) {
|
|
|
1137
1404
|
);
|
|
1138
1405
|
return probed.filter((candidate) => candidate.present).map(({ filename, strategy, filePath }) => ({ ecosystem: "npm", filename, strategy, filePath }));
|
|
1139
1406
|
}
|
|
1140
|
-
async function runStrategy(detected, cwd) {
|
|
1407
|
+
async function runStrategy(detected, cwd, report) {
|
|
1141
1408
|
switch (detected.strategy) {
|
|
1142
1409
|
case "npm-lockfile":
|
|
1143
1410
|
return parseNpmLockfile(detected.filePath);
|
|
1411
|
+
case "bun-lockfile":
|
|
1412
|
+
return parseBunLockfile(detected.filePath, report);
|
|
1413
|
+
// The hand-written scanners. Each meets entries it was not written for, and each is asked to say how
|
|
1414
|
+
// many it dropped, because a manifest short a package reads exactly like a project without it.
|
|
1144
1415
|
case "pnpm-lockfile":
|
|
1145
|
-
return parsePnpmLockfile(detected.filePath);
|
|
1416
|
+
return parsePnpmLockfile(detected.filePath, report);
|
|
1146
1417
|
case "yarn-lockfile":
|
|
1147
|
-
return parseYarnLockfile(detected.filePath);
|
|
1418
|
+
return parseYarnLockfile(detected.filePath, report);
|
|
1148
1419
|
case "node-modules-walk":
|
|
1149
1420
|
return walkNodeModules(cwd);
|
|
1150
1421
|
}
|
|
1151
1422
|
}
|
|
1152
1423
|
async function exists(filePath) {
|
|
1153
1424
|
try {
|
|
1154
|
-
await (0,
|
|
1425
|
+
await (0, import_promises8.access)(filePath);
|
|
1155
1426
|
return true;
|
|
1156
1427
|
} catch {
|
|
1157
1428
|
return false;
|
|
1158
1429
|
}
|
|
1159
1430
|
}
|
|
1160
|
-
var
|
|
1431
|
+
var import_promises8, import_node_path6, LOCKFILE_CANDIDATES;
|
|
1161
1432
|
var init_parsers = __esm({
|
|
1162
1433
|
"src/parsers/index.ts"() {
|
|
1163
1434
|
"use strict";
|
|
1164
|
-
|
|
1435
|
+
import_promises8 = require("fs/promises");
|
|
1165
1436
|
import_node_path6 = __toESM(require("path"), 1);
|
|
1166
1437
|
init_types();
|
|
1167
1438
|
init_consistency();
|
|
1439
|
+
init_bun();
|
|
1168
1440
|
init_npm();
|
|
1169
1441
|
init_node_modules();
|
|
1170
1442
|
init_pnpm();
|
|
1171
1443
|
init_yarn();
|
|
1444
|
+
init_report();
|
|
1172
1445
|
LOCKFILE_CANDIDATES = [
|
|
1173
1446
|
{ filename: "package-lock.json", strategy: "npm-lockfile" },
|
|
1174
|
-
|
|
1447
|
+
// Read directly, not walked. Bun's isolated layout puts packages under a dot-directory and makes the
|
|
1448
|
+
// top level symlinks into it, so a `node_modules` walk finds nothing there — and isolated is the default
|
|
1449
|
+
// for new workspaces.
|
|
1450
|
+
{ filename: "bun.lock", strategy: "bun-lockfile" },
|
|
1175
1451
|
{ filename: "bun.lockb", strategy: "node-modules-walk" },
|
|
1176
1452
|
{ filename: "pnpm-lock.yaml", strategy: "pnpm-lockfile" },
|
|
1177
1453
|
{ filename: "yarn.lock", strategy: "yarn-lockfile" }
|
|
@@ -1180,21 +1456,33 @@ var init_parsers = __esm({
|
|
|
1180
1456
|
});
|
|
1181
1457
|
|
|
1182
1458
|
// src/normalize.ts
|
|
1183
|
-
function buildWirePayload(manifest) {
|
|
1459
|
+
function buildWirePayload(manifest, options = {}) {
|
|
1460
|
+
const withPaths = options.installPaths === true;
|
|
1184
1461
|
const seen = /* @__PURE__ */ new Map();
|
|
1185
1462
|
const wirePackages = [];
|
|
1463
|
+
const byIdentity = /* @__PURE__ */ new Map();
|
|
1464
|
+
let entriesWithoutPath = 0;
|
|
1186
1465
|
for (const entry of manifest.packages) {
|
|
1466
|
+
const identity = `${entry.name}@${entry.version}`;
|
|
1467
|
+
const location = withPaths && entry.path !== void 0 && entry.path !== "" ? entry.path : void 0;
|
|
1468
|
+
if (location === void 0) entriesWithoutPath++;
|
|
1469
|
+
const existing = byIdentity.get(identity);
|
|
1470
|
+
if (existing) {
|
|
1471
|
+
if (location !== void 0) addPath(existing, location);
|
|
1472
|
+
continue;
|
|
1473
|
+
}
|
|
1187
1474
|
const versions = seen.get(entry.name);
|
|
1188
1475
|
if (versions) {
|
|
1189
|
-
if (versions.has(entry.version)) {
|
|
1190
|
-
continue;
|
|
1191
|
-
}
|
|
1192
1476
|
versions.add(entry.version);
|
|
1193
1477
|
} else {
|
|
1194
1478
|
seen.set(entry.name, /* @__PURE__ */ new Set([entry.version]));
|
|
1195
1479
|
}
|
|
1196
|
-
|
|
1480
|
+
const wire = { name: entry.name, version: entry.version };
|
|
1481
|
+
if (location !== void 0) addPath(wire, location);
|
|
1482
|
+
byIdentity.set(identity, wire);
|
|
1483
|
+
wirePackages.push(wire);
|
|
1197
1484
|
}
|
|
1485
|
+
for (const wire of wirePackages) wire.paths?.sort();
|
|
1198
1486
|
wirePackages.sort((a, b) => {
|
|
1199
1487
|
if (a.name === b.name) {
|
|
1200
1488
|
return compareVersions(a.version, b.version);
|
|
@@ -1208,7 +1496,18 @@ function buildWirePayload(manifest) {
|
|
|
1208
1496
|
}
|
|
1209
1497
|
}
|
|
1210
1498
|
return {
|
|
1211
|
-
payload: {
|
|
1499
|
+
payload: {
|
|
1500
|
+
ecosystem: manifest.ecosystem,
|
|
1501
|
+
packages: wirePackages,
|
|
1502
|
+
// Every entry had a location, so a missing path anywhere means the package is not installed there.
|
|
1503
|
+
// One entry without one forfeits that for the whole payload: a consumer reading a short `paths` has
|
|
1504
|
+
// no way to tell which entry was the incomplete one.
|
|
1505
|
+
//
|
|
1506
|
+
// No separate check for "locations were not requested": with them off every entry counts as without
|
|
1507
|
+
// one, so this is already false — which is the default, and the safe direction. An explicit
|
|
1508
|
+
// `withPaths &&` here was a guard no test could distinguish from its absence.
|
|
1509
|
+
installPathsComplete: entriesWithoutPath === 0 && manifest.packages.length > 0
|
|
1510
|
+
},
|
|
1212
1511
|
stats: {
|
|
1213
1512
|
uniqueNames: seen.size,
|
|
1214
1513
|
duplicateNames,
|
|
@@ -1216,6 +1515,10 @@ function buildWirePayload(manifest) {
|
|
|
1216
1515
|
}
|
|
1217
1516
|
};
|
|
1218
1517
|
}
|
|
1518
|
+
function addPath(wire, installPath) {
|
|
1519
|
+
if (wire.paths === void 0) wire.paths = [installPath];
|
|
1520
|
+
else if (!wire.paths.includes(installPath)) wire.paths.push(installPath);
|
|
1521
|
+
}
|
|
1219
1522
|
function compareVersions(a, b) {
|
|
1220
1523
|
if (a === b) {
|
|
1221
1524
|
return 0;
|
|
@@ -1450,6 +1753,45 @@ function base64DecodeUtf8(value) {
|
|
|
1450
1753
|
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
|
1451
1754
|
return new TextDecoder().decode(bytes);
|
|
1452
1755
|
}
|
|
1756
|
+
var NAMED_ENTITIES = {
|
|
1757
|
+
lt: "<",
|
|
1758
|
+
gt: ">",
|
|
1759
|
+
amp: "&",
|
|
1760
|
+
quot: '"',
|
|
1761
|
+
apos: "'",
|
|
1762
|
+
"#39": "'",
|
|
1763
|
+
nbsp: "\xA0",
|
|
1764
|
+
sol: "/",
|
|
1765
|
+
bsol: "\\",
|
|
1766
|
+
colon: ":",
|
|
1767
|
+
lpar: "(",
|
|
1768
|
+
rpar: ")",
|
|
1769
|
+
equals: "=",
|
|
1770
|
+
grave: "`",
|
|
1771
|
+
Tab: " ",
|
|
1772
|
+
NewLine: "\n",
|
|
1773
|
+
semi: ";",
|
|
1774
|
+
excl: "!",
|
|
1775
|
+
num: "#",
|
|
1776
|
+
dollar: "$",
|
|
1777
|
+
percnt: "%",
|
|
1778
|
+
ast: "*"
|
|
1779
|
+
};
|
|
1780
|
+
function decodeHtmlEntities(input) {
|
|
1781
|
+
return input.replace(/&(#[xX]?[0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);?/g, (whole, body) => {
|
|
1782
|
+
if (body[0] === "#") {
|
|
1783
|
+
const hex = body[1] === "x" || body[1] === "X";
|
|
1784
|
+
const code = Number.parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
|
|
1785
|
+
if (!Number.isFinite(code) || code < 0 || code > 1114111) return whole;
|
|
1786
|
+
try {
|
|
1787
|
+
return String.fromCodePoint(code);
|
|
1788
|
+
} catch {
|
|
1789
|
+
return whole;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, body) ? NAMED_ENTITIES[body] : whole;
|
|
1793
|
+
});
|
|
1794
|
+
}
|
|
1453
1795
|
var RequestResolver = class {
|
|
1454
1796
|
#req;
|
|
1455
1797
|
#cookies;
|
|
@@ -1572,6 +1914,8 @@ var RequestResolver = class {
|
|
|
1572
1914
|
} catch {
|
|
1573
1915
|
return value;
|
|
1574
1916
|
}
|
|
1917
|
+
case "htmlentitydecode":
|
|
1918
|
+
return decodeHtmlEntities(String(value));
|
|
1575
1919
|
case "intval":
|
|
1576
1920
|
return parseInt(String(value), 10) || 0;
|
|
1577
1921
|
case "getArrayValues":
|
|
@@ -2239,6 +2583,30 @@ function expandIPv6(host) {
|
|
|
2239
2583
|
}
|
|
2240
2584
|
return out;
|
|
2241
2585
|
}
|
|
2586
|
+
var JWT_CANDIDATE = /(?<![A-Za-z0-9_-])(?<![A-Za-z0-9_-]\.)eyJ[A-Za-z0-9_-]{2,2048}\.[A-Za-z0-9_-]{2,8192}\.[A-Za-z0-9_-]{0,2048}(?![A-Za-z0-9_-])(?!\.[A-Za-z0-9_-])/g;
|
|
2587
|
+
function base64UrlToBase64(segment) {
|
|
2588
|
+
const padded = segment.length % 4 === 0 ? segment : segment + "=".repeat(4 - segment.length % 4);
|
|
2589
|
+
return padded.replace(/-/g, "+").replace(/_/g, "/");
|
|
2590
|
+
}
|
|
2591
|
+
function jwtClaimSpans(text, claim2, value) {
|
|
2592
|
+
const found = /* @__PURE__ */ new Set();
|
|
2593
|
+
if (typeof text !== "string" || typeof claim2 !== "string" || claim2 === "") return [];
|
|
2594
|
+
const wanted = String(value);
|
|
2595
|
+
for (const [token] of text.matchAll(JWT_CANDIDATE)) {
|
|
2596
|
+
const parts = token.split(".");
|
|
2597
|
+
let payload;
|
|
2598
|
+
try {
|
|
2599
|
+
payload = JSON.parse(base64DecodeUtf8(base64UrlToBase64(parts[1])));
|
|
2600
|
+
} catch {
|
|
2601
|
+
continue;
|
|
2602
|
+
}
|
|
2603
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) continue;
|
|
2604
|
+
if (!Object.prototype.hasOwnProperty.call(payload, claim2)) continue;
|
|
2605
|
+
const actual = payload[claim2];
|
|
2606
|
+
if (typeof actual === "string" && actual === wanted) found.add(token);
|
|
2607
|
+
}
|
|
2608
|
+
return [...found];
|
|
2609
|
+
}
|
|
2242
2610
|
var warnedScopes = /* @__PURE__ */ new Set();
|
|
2243
2611
|
function warnUnrecognisedScope(when) {
|
|
2244
2612
|
const key = Object.keys(when).sort().join(",");
|
|
@@ -2267,16 +2635,30 @@ function ruleAppliesTo(when, resolver) {
|
|
|
2267
2635
|
return true;
|
|
2268
2636
|
}
|
|
2269
2637
|
}
|
|
2638
|
+
function foldPathForScope(value) {
|
|
2639
|
+
const out = String(value).toLowerCase();
|
|
2640
|
+
return out.endsWith("/") ? out.slice(0, -1) : out;
|
|
2641
|
+
}
|
|
2642
|
+
var REGEX_SYNTAX = /[\\^$*+?()[\]{}|]/;
|
|
2643
|
+
var REGEX_FORM = /^\/(.+?)\/([gimsuy]*)$/s;
|
|
2644
|
+
function isRegexForm(pattern) {
|
|
2645
|
+
const parts = REGEX_FORM.exec(pattern);
|
|
2646
|
+
return parts !== null && REGEX_SYNTAX.test(parts[1]);
|
|
2647
|
+
}
|
|
2270
2648
|
function pathMatches(pattern, path7) {
|
|
2271
|
-
if (
|
|
2649
|
+
if (isRegexForm(pattern)) {
|
|
2272
2650
|
const re = safeRegExp(pattern);
|
|
2273
2651
|
return re ? re.test(path7) : false;
|
|
2274
2652
|
}
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2653
|
+
const target = foldPathForScope(path7);
|
|
2654
|
+
const folded = foldPathForScope(pattern);
|
|
2655
|
+
if (folded.includes("*")) {
|
|
2656
|
+
const trailing = folded.endsWith("/*");
|
|
2657
|
+
const body = (trailing ? folded.slice(0, -2) : folded).replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^?]*");
|
|
2658
|
+
const re = safeRegExp("/^" + body + (trailing ? "(?:/[^?]*)?" : "") + "$/");
|
|
2659
|
+
return re ? re.test(target) : false;
|
|
2278
2660
|
}
|
|
2279
|
-
return
|
|
2661
|
+
return target === folded;
|
|
2280
2662
|
}
|
|
2281
2663
|
function normalizeDefaultPort(host) {
|
|
2282
2664
|
return String(host).toLowerCase().replace(/:(?:80|443)$/, "");
|
|
@@ -2429,6 +2811,8 @@ function matchValue(type, value, matchVal, matchObj) {
|
|
|
2429
2811
|
const stripped = strValue.replace(/[ _\-,]/g, "");
|
|
2430
2812
|
return ctypeResult(strValue, /^[\w$-]*$/.test(stripped), matchVal);
|
|
2431
2813
|
}
|
|
2814
|
+
case "jwt_claim_equals":
|
|
2815
|
+
return jwtClaimSpans(strValue, matchObj?.claim, matchVal).length > 0;
|
|
2432
2816
|
case "array_key_value":
|
|
2433
2817
|
return arrayKeyValue(value, matchObj);
|
|
2434
2818
|
// file_contains (uploaded-file content scanning) is NOT WordPress-specific, but is
|
|
@@ -3195,13 +3579,43 @@ var DEFAULT_RESPONSE_RULES = [
|
|
|
3195
3579
|
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])/" } }]
|
|
3196
3580
|
},
|
|
3197
3581
|
{
|
|
3198
|
-
id: "resp-
|
|
3199
|
-
title: "
|
|
3582
|
+
id: "resp-supabase-secret-key",
|
|
3583
|
+
title: "Supabase secret key in response body",
|
|
3584
|
+
phase: "response",
|
|
3585
|
+
category: "secret-exposure",
|
|
3586
|
+
action: "redact",
|
|
3587
|
+
// Supabase's opaque key format, which is not a JWT and so is not covered by the JWT rule below.
|
|
3588
|
+
// `sb_secret_` is the elevated one — documented as full access, bypassing Row Level Security,
|
|
3589
|
+
// backend-only — while `sb_publishable_` is meant for public clients and must pass untouched.
|
|
3590
|
+
// Both key systems are live at once: new keys sit alongside the legacy `anon` / `service_role`
|
|
3591
|
+
// JWTs rather than replacing them, so this rule and the JWT rule cover different halves.
|
|
3592
|
+
//
|
|
3593
|
+
// The documented grammar is the prefix, 22 base64url characters, `_`, then an 8-character
|
|
3594
|
+
// base64url checksum. Matching it exactly is what keeps the mask on the key and off the response
|
|
3595
|
+
// around it: a variable-length suffix would either run past the key into adjacent syntax or stop
|
|
3596
|
+
// short and leave the tail of a real key visible. The trailing guard rejects a longer run of key
|
|
3597
|
+
// characters, which is not this format.
|
|
3598
|
+
prefilter: ["sb_secret_"],
|
|
3599
|
+
rule_v2: [{ parameter: "response.body", match: { type: "regex", value: "/\\bsb_secret_[A-Za-z0-9_-]{22}_[A-Za-z0-9_-]{8}(?![A-Za-z0-9_-])/" } }]
|
|
3600
|
+
},
|
|
3601
|
+
{
|
|
3602
|
+
id: "resp-supabase-service-role-key",
|
|
3603
|
+
title: "Supabase service_role key in response body",
|
|
3200
3604
|
phase: "response",
|
|
3201
3605
|
category: "secret-exposure",
|
|
3202
3606
|
action: "redact",
|
|
3607
|
+
// Scoped to the one role that must never leave: `service_role` bypasses Row Level Security
|
|
3608
|
+
// entirely. A JWT in a response body is not inherently a leak — the Supabase `anon` key is public
|
|
3609
|
+
// by design and a login response carries the user's own access token — so this decides on the
|
|
3610
|
+
// decoded payload rather than on the shape of the token.
|
|
3611
|
+
//
|
|
3612
|
+
// `jwt_claim_equals` also yields the matching token spans, which is what lets `redact` mask those
|
|
3613
|
+
// tokens and serve the rest of the response.
|
|
3614
|
+
//
|
|
3615
|
+
// A broad any-JWT rule is a reasonable thing to want; it belongs in the managed hardening bundle as
|
|
3616
|
+
// an opt-in, observe-only rule rather than an on-by-default mask.
|
|
3203
3617
|
prefilter: ["eyJ"],
|
|
3204
|
-
rule_v2: [{ parameter: "response.body", match: { type: "
|
|
3618
|
+
rule_v2: [{ parameter: "response.body", match: { type: "jwt_claim_equals", claim: "role", value: "service_role" } }]
|
|
3205
3619
|
},
|
|
3206
3620
|
{
|
|
3207
3621
|
id: "resp-db-connection-string",
|
|
@@ -3426,25 +3840,468 @@ function toEnvelope(value) {
|
|
|
3426
3840
|
return null;
|
|
3427
3841
|
}
|
|
3428
3842
|
|
|
3429
|
-
// src/protect/rules/
|
|
3430
|
-
var
|
|
3843
|
+
// src/protect/rules/contract.js
|
|
3844
|
+
var SOURCES = Object.freeze({
|
|
3845
|
+
raw: Object.freeze({ keyed: false }),
|
|
3846
|
+
all: Object.freeze({ keyed: false }),
|
|
3847
|
+
false: Object.freeze({ keyed: false }),
|
|
3848
|
+
rules: Object.freeze({ keyed: false, group: true }),
|
|
3849
|
+
get: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
3850
|
+
post: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
3851
|
+
request: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
3852
|
+
cookie: Object.freeze({ keyed: true, keys: "any", wildcard: true }),
|
|
3853
|
+
// A bare `files.<name>` resolves to the filename; an optional trailing attribute selects another part
|
|
3854
|
+
// of the upload. Optional, not required — filename-scoped rules use the bare form.
|
|
3855
|
+
files: Object.freeze({
|
|
3856
|
+
keyed: true,
|
|
3857
|
+
keys: "any",
|
|
3858
|
+
wildcard: true,
|
|
3859
|
+
optional_key_suffixes: Object.freeze(["content", "filename", "type"])
|
|
3860
|
+
}),
|
|
3861
|
+
server: Object.freeze({
|
|
3862
|
+
keyed: true,
|
|
3863
|
+
keys: Object.freeze([
|
|
3864
|
+
"REQUEST_URI",
|
|
3865
|
+
"REQUEST_METHOD",
|
|
3866
|
+
"HTTP_USER_AGENT",
|
|
3867
|
+
"HTTP_REFERER",
|
|
3868
|
+
"HTTP_HOST",
|
|
3869
|
+
"REMOTE_ADDR",
|
|
3870
|
+
"ip",
|
|
3871
|
+
"CONTENT_TYPE",
|
|
3872
|
+
"CONTENT_LENGTH"
|
|
3873
|
+
]),
|
|
3874
|
+
key_prefixes: Object.freeze(["HTTP_"])
|
|
3875
|
+
}),
|
|
3876
|
+
response: Object.freeze({
|
|
3877
|
+
keyed: true,
|
|
3878
|
+
keys: Object.freeze(["status", "body", "headers"]),
|
|
3879
|
+
key_prefixes: Object.freeze(["header."])
|
|
3880
|
+
}),
|
|
3881
|
+
egress: Object.freeze({ keyed: true, keys: Object.freeze(["url", "host", "method"]) })
|
|
3882
|
+
});
|
|
3883
|
+
var MATCH_TYPES = Object.freeze([
|
|
3884
|
+
"array_in_array",
|
|
3885
|
+
"array_key_value",
|
|
3886
|
+
"contains",
|
|
3887
|
+
"cors_reflected",
|
|
3888
|
+
"cross_origin",
|
|
3889
|
+
"ctype_alnum",
|
|
3890
|
+
"ctype_digit",
|
|
3891
|
+
"ctype_special",
|
|
3892
|
+
"equals",
|
|
3893
|
+
"equals_strict",
|
|
3894
|
+
"hostname",
|
|
3895
|
+
"in_array",
|
|
3896
|
+
"inline_js_xss",
|
|
3897
|
+
"inline_xss",
|
|
3898
|
+
"internal_host",
|
|
3899
|
+
"is_numeric",
|
|
3900
|
+
"isset",
|
|
3901
|
+
"jwt_claim_equals",
|
|
3902
|
+
"less_than",
|
|
3903
|
+
"more_than",
|
|
3904
|
+
"not_contains",
|
|
3905
|
+
"not_in_array",
|
|
3906
|
+
"off_origin",
|
|
3907
|
+
"quotes",
|
|
3908
|
+
"regex",
|
|
3909
|
+
"stripos"
|
|
3910
|
+
]);
|
|
3911
|
+
var PARAMETERLESS_MATCH_TYPES = Object.freeze(["cross_origin", "off_origin", "cors_reflected"]);
|
|
3912
|
+
var OPERANDLESS_MATCH_TYPES = Object.freeze([
|
|
3913
|
+
"isset",
|
|
3914
|
+
"quotes",
|
|
3915
|
+
"is_numeric",
|
|
3916
|
+
"ctype_alnum",
|
|
3917
|
+
"ctype_digit",
|
|
3918
|
+
"ctype_special",
|
|
3919
|
+
"inline_xss",
|
|
3920
|
+
"inline_js_xss",
|
|
3921
|
+
"internal_host",
|
|
3922
|
+
"cross_origin",
|
|
3923
|
+
"off_origin",
|
|
3924
|
+
"cors_reflected"
|
|
3925
|
+
]);
|
|
3926
|
+
var OPERAND_SHAPES = Object.freeze([
|
|
3927
|
+
"positive-number",
|
|
3928
|
+
"boolean",
|
|
3929
|
+
"non-empty-string",
|
|
3930
|
+
"regex-literal",
|
|
3931
|
+
"string-or-string-list",
|
|
3932
|
+
"scalar",
|
|
3933
|
+
"string",
|
|
3934
|
+
"numeric",
|
|
3935
|
+
"scalar-or-non-empty-list",
|
|
3936
|
+
"non-empty-list",
|
|
3937
|
+
"object",
|
|
3938
|
+
"non-empty-object",
|
|
3939
|
+
"non-empty-string-list",
|
|
3940
|
+
"header-name-map",
|
|
3941
|
+
"header-name-list",
|
|
3942
|
+
"cookie-flags"
|
|
3943
|
+
]);
|
|
3944
|
+
var COOKIE_FLAGS = Object.freeze(["httpOnly", "secure", "sameSite"]);
|
|
3945
|
+
var SAME_SITE_VALUES = Object.freeze(["Strict", "Lax", "None"]);
|
|
3946
|
+
var HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
3947
|
+
var REGEX_LITERAL = /^\/(.+?)\/([gimsuy]*)$/s;
|
|
3948
|
+
var MATCH_OPERANDS = Object.freeze({
|
|
3949
|
+
array_key_value: Object.freeze({
|
|
3950
|
+
required: Object.freeze(["key", "match"]),
|
|
3951
|
+
shapes: Object.freeze({ key: "string-or-string-list", match: "object" })
|
|
3952
|
+
}),
|
|
3953
|
+
// Decides on a JWT's DECODED payload rather than its shape, and yields the matching token spans so
|
|
3954
|
+
// `redact` masks only those tokens. `claim` names an own, top-level claim; `value` is compared with
|
|
3955
|
+
// exact string equality. Anything not positively identified — undecodable, not JSON, not an object,
|
|
3956
|
+
// oversized, claim missing, claim not a string, or a different value — is a no-match.
|
|
3957
|
+
jwt_claim_equals: Object.freeze({
|
|
3958
|
+
required: Object.freeze(["claim", "value"]),
|
|
3959
|
+
shapes: Object.freeze({ claim: "non-empty-string", value: "non-empty-string" })
|
|
3960
|
+
}),
|
|
3961
|
+
regex: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "regex-literal" }) }),
|
|
3962
|
+
contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
3963
|
+
stripos: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
3964
|
+
not_contains: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
3965
|
+
// A hostname is compared to `new URL(value).hostname`, so an empty one only ever equals the empty
|
|
3966
|
+
// hostname of an opaque-scheme URL (`file:`, `mailto:`, `data:`) — never a host, and a false positive
|
|
3967
|
+
// on those. The other three are the list-membership family: an empty list makes `in_array` and
|
|
3968
|
+
// `array_in_array` inert and `not_in_array` true for every value.
|
|
3969
|
+
hostname: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-string" }) }),
|
|
3970
|
+
in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
|
|
3971
|
+
not_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar-or-non-empty-list" }) }),
|
|
3972
|
+
array_in_array: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "non-empty-list" }) }),
|
|
3973
|
+
// The comparison family. `equals` coerces, so any scalar can match. `equals_strict` does not, and its
|
|
3974
|
+
// subject is always a string by the time it is compared — so a number operand is unconditionally
|
|
3975
|
+
// inert, which is the trap the engine's own note about `'1' !== 1` describes.
|
|
3976
|
+
equals: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "scalar" }) }),
|
|
3977
|
+
equals_strict: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "string" }) }),
|
|
3978
|
+
less_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) }),
|
|
3979
|
+
more_than: Object.freeze({ required: Object.freeze(["value"]), shapes: Object.freeze({ value: "numeric" }) })
|
|
3980
|
+
});
|
|
3981
|
+
function operandShapeProblem(shape, value) {
|
|
3982
|
+
const isFilledString = (v) => typeof v === "string" && v.trim() !== "";
|
|
3983
|
+
const isScalar = (v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
|
|
3984
|
+
const isHeaderName = (v) => typeof v === "string" && HEADER_NAME.test(v);
|
|
3985
|
+
switch (shape) {
|
|
3986
|
+
case "positive-number":
|
|
3987
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? null : "must be a finite positive number";
|
|
3988
|
+
case "boolean":
|
|
3989
|
+
return typeof value === "boolean" ? null : "must be true or false";
|
|
3990
|
+
case "non-empty-string":
|
|
3991
|
+
return isFilledString(value) ? null : "must be a non-empty string";
|
|
3992
|
+
case "regex-literal":
|
|
3993
|
+
if (!isFilledString(value)) return "must be a non-empty string";
|
|
3994
|
+
return REGEX_LITERAL.test(value) ? null : "must be a delimited pattern, as in /pattern/i";
|
|
3995
|
+
case "string-or-string-list":
|
|
3996
|
+
if (isFilledString(value)) return null;
|
|
3997
|
+
if (Array.isArray(value) && value.length > 0 && value.every(isFilledString)) return null;
|
|
3998
|
+
return "must be a non-empty string, or a non-empty list of them";
|
|
3999
|
+
case "scalar":
|
|
4000
|
+
return isScalar(value) ? null : "must be a string, number or boolean";
|
|
4001
|
+
case "string":
|
|
4002
|
+
return typeof value === "string" ? null : "must be a string (this comparison is type-strict)";
|
|
4003
|
+
case "numeric":
|
|
4004
|
+
return isScalar(value) && Number.isFinite(Number(value)) ? null : "must be a number";
|
|
4005
|
+
case "scalar-or-non-empty-list":
|
|
4006
|
+
if (isScalar(value)) return null;
|
|
4007
|
+
if (Array.isArray(value) && value.length > 0 && value.every(isScalar)) return null;
|
|
4008
|
+
return "must be a scalar, or a non-empty list of them";
|
|
4009
|
+
case "non-empty-list":
|
|
4010
|
+
return Array.isArray(value) && value.length > 0 && value.every(isScalar) ? null : "must be a non-empty list of scalars";
|
|
4011
|
+
case "header-name-map": {
|
|
4012
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
|
|
4013
|
+
const names = Object.keys(value);
|
|
4014
|
+
if (names.length === 0) return "must be an object with at least one entry";
|
|
4015
|
+
const bad = names.find((name) => !isHeaderName(name));
|
|
4016
|
+
return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
|
|
4017
|
+
}
|
|
4018
|
+
case "header-name-list": {
|
|
4019
|
+
if (!Array.isArray(value) || value.length === 0) return "must be a non-empty list of HTTP header names";
|
|
4020
|
+
const bad = value.find((name) => !isHeaderName(name));
|
|
4021
|
+
return bad === void 0 ? null : `names an invalid HTTP header, ${JSON.stringify(bad)}`;
|
|
4022
|
+
}
|
|
4023
|
+
case "cookie-flags": {
|
|
4024
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return "must be an object";
|
|
4025
|
+
const unknown = Object.keys(value).filter((key) => !COOKIE_FLAGS.includes(key));
|
|
4026
|
+
if (unknown.length > 0) return `has no flag named ${unknown.map((k) => JSON.stringify(k)).join(", ")}`;
|
|
4027
|
+
for (const flag of ["httpOnly", "secure"]) {
|
|
4028
|
+
if (value[flag] !== void 0 && typeof value[flag] !== "boolean") {
|
|
4029
|
+
return `flag "${flag}" must be true or false`;
|
|
4030
|
+
}
|
|
4031
|
+
}
|
|
4032
|
+
const sameSite = value.sameSite;
|
|
4033
|
+
const sameSiteOff = sameSite === false;
|
|
4034
|
+
if (sameSite !== void 0 && !sameSiteOff) {
|
|
4035
|
+
if (typeof sameSite !== "string" || !SAME_SITE_VALUES.some((v) => v.toLowerCase() === sameSite.toLowerCase())) {
|
|
4036
|
+
return `flag "sameSite" must be false, or one of ${SAME_SITE_VALUES.join(", ")}`;
|
|
4037
|
+
}
|
|
4038
|
+
}
|
|
4039
|
+
if (value.httpOnly === false && value.secure === false && sameSiteOff) {
|
|
4040
|
+
return "turns off every flag, so it would harden nothing";
|
|
4041
|
+
}
|
|
4042
|
+
return null;
|
|
4043
|
+
}
|
|
4044
|
+
case "object":
|
|
4045
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? null : "must be an object";
|
|
4046
|
+
case "non-empty-object":
|
|
4047
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0 ? null : "must be an object with at least one entry";
|
|
4048
|
+
case "non-empty-string-list":
|
|
4049
|
+
return Array.isArray(value) && value.length > 0 && value.every(isFilledString) ? null : "must be a non-empty list of non-empty strings";
|
|
4050
|
+
default:
|
|
4051
|
+
return null;
|
|
4052
|
+
}
|
|
4053
|
+
}
|
|
4054
|
+
var MUTATIONS = Object.freeze([
|
|
4055
|
+
"base64_decode",
|
|
4056
|
+
"getArrayValues",
|
|
4057
|
+
"htmlentitydecode",
|
|
4058
|
+
"intval",
|
|
4059
|
+
"json_decode",
|
|
4060
|
+
"json_encode",
|
|
4061
|
+
"urldecode"
|
|
4062
|
+
]);
|
|
4063
|
+
var PHASES = Object.freeze(["request", "response", "egress"]);
|
|
4064
|
+
var ACTIONS = Object.freeze([
|
|
4065
|
+
"block",
|
|
4066
|
+
"redact",
|
|
4067
|
+
"encode",
|
|
4068
|
+
"set-header",
|
|
4069
|
+
"remove-header",
|
|
4070
|
+
"harden-cookie"
|
|
4071
|
+
]);
|
|
4072
|
+
var RULE_PROPERTY_SHAPES = Object.freeze({
|
|
4073
|
+
max_bytes: "positive-number",
|
|
4074
|
+
bypass_limit: "boolean",
|
|
4075
|
+
ensure: "boolean",
|
|
4076
|
+
prefilter: "non-empty-string-list"
|
|
4077
|
+
});
|
|
4078
|
+
var ENFORCEMENT_VALUES = Object.freeze(["dry-run"]);
|
|
4079
|
+
var WHEN_KEYS = Object.freeze(["path", "method"]);
|
|
4080
|
+
var RULE_PROPERTIES = Object.freeze([
|
|
4081
|
+
"id",
|
|
4082
|
+
"rule_id",
|
|
4083
|
+
"title",
|
|
4084
|
+
"category",
|
|
4085
|
+
"phase",
|
|
4086
|
+
"action",
|
|
4087
|
+
"rule_v2",
|
|
4088
|
+
"when",
|
|
4089
|
+
"message",
|
|
4090
|
+
"enforcement",
|
|
4091
|
+
"source_revision",
|
|
4092
|
+
"prefilter",
|
|
4093
|
+
"max_bytes",
|
|
4094
|
+
"bypass_limit",
|
|
4095
|
+
"set_headers",
|
|
4096
|
+
"remove_headers",
|
|
4097
|
+
"cookie_flags",
|
|
4098
|
+
"ensure"
|
|
4099
|
+
]);
|
|
4100
|
+
var ACTION_PROPERTIES = Object.freeze({
|
|
4101
|
+
block: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["request", "response", "egress"]) }),
|
|
4102
|
+
redact: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
|
|
4103
|
+
encode: Object.freeze({ required: Object.freeze([]), defaulted: Object.freeze([]), phases: Object.freeze(["response"]) }),
|
|
4104
|
+
"set-header": Object.freeze({
|
|
4105
|
+
required: Object.freeze(["set_headers"]),
|
|
4106
|
+
defaulted: Object.freeze(["ensure"]),
|
|
4107
|
+
phases: Object.freeze(["response"]),
|
|
4108
|
+
shapes: Object.freeze({ set_headers: "header-name-map" })
|
|
4109
|
+
}),
|
|
4110
|
+
"remove-header": Object.freeze({
|
|
4111
|
+
required: Object.freeze(["remove_headers"]),
|
|
4112
|
+
defaulted: Object.freeze([]),
|
|
4113
|
+
phases: Object.freeze(["response"]),
|
|
4114
|
+
shapes: Object.freeze({ remove_headers: "header-name-list" })
|
|
4115
|
+
}),
|
|
4116
|
+
"harden-cookie": Object.freeze({
|
|
4117
|
+
required: Object.freeze([]),
|
|
4118
|
+
defaulted: Object.freeze(["cookie_flags"]),
|
|
4119
|
+
phases: Object.freeze(["response"]),
|
|
4120
|
+
shapes: Object.freeze({ cookie_flags: "cookie-flags" })
|
|
4121
|
+
})
|
|
4122
|
+
});
|
|
4123
|
+
var LIMITS = Object.freeze({
|
|
3431
4124
|
maxRules: 5e3,
|
|
3432
4125
|
maxWhitelists: 2e3,
|
|
3433
4126
|
maxConditionsPerRule: 250,
|
|
3434
4127
|
maxNestingDepth: 12,
|
|
3435
4128
|
maxRegexLength: 1e3,
|
|
3436
4129
|
maxValueLength: 8192
|
|
3437
|
-
};
|
|
3438
|
-
var
|
|
3439
|
-
|
|
4130
|
+
});
|
|
4131
|
+
var GROUP_PARAMETER = "rules";
|
|
4132
|
+
function isGroup(condition) {
|
|
4133
|
+
return condition?.parameter === GROUP_PARAMETER && Array.isArray(condition?.rules);
|
|
4134
|
+
}
|
|
4135
|
+
function conditionShapeProblem(condition) {
|
|
4136
|
+
if (!condition || typeof condition !== "object") return "condition is not an object";
|
|
4137
|
+
const named = condition.parameter === GROUP_PARAMETER;
|
|
4138
|
+
const nested = Array.isArray(condition.rules);
|
|
4139
|
+
if (named && !nested) {
|
|
4140
|
+
return `parameter "${GROUP_PARAMETER}" is a group and needs a "rules" array`;
|
|
4141
|
+
}
|
|
4142
|
+
if (nested && !named) {
|
|
4143
|
+
return `condition carries nested rules but its parameter is ${JSON.stringify(condition.parameter)}; a group must be {"parameter":"${GROUP_PARAMETER}"}`;
|
|
4144
|
+
}
|
|
4145
|
+
if (named && nested) {
|
|
4146
|
+
if (condition.match !== void 0) return "a group carries no match of its own; the engine ignores it";
|
|
4147
|
+
if (condition.mutations !== void 0) return "a group carries no mutations of its own; the engine ignores them";
|
|
4148
|
+
if (condition.rules.length === 0) return "group has no conditions (would never match)";
|
|
4149
|
+
}
|
|
4150
|
+
return null;
|
|
4151
|
+
}
|
|
4152
|
+
function parameterProblem(parameter) {
|
|
4153
|
+
if (parameter === void 0 || parameter === null) return null;
|
|
4154
|
+
if (Array.isArray(parameter)) {
|
|
4155
|
+
if (parameter.length === 0) return "parameter list is empty";
|
|
4156
|
+
for (const member of parameter) {
|
|
4157
|
+
const problem = parameterProblem(member);
|
|
4158
|
+
if (problem !== null) return problem;
|
|
4159
|
+
}
|
|
4160
|
+
return null;
|
|
4161
|
+
}
|
|
4162
|
+
if (typeof parameter !== "string" || parameter === "") return "parameter must be a non-empty string";
|
|
4163
|
+
if (parameter === GROUP_PARAMETER) {
|
|
4164
|
+
return `"${GROUP_PARAMETER}" is the group source and is only valid as a whole group, not as a parameter`;
|
|
4165
|
+
}
|
|
4166
|
+
const dot = parameter.indexOf(".");
|
|
4167
|
+
const name = dot === -1 ? parameter : parameter.slice(0, dot);
|
|
4168
|
+
const source = SOURCES[name];
|
|
4169
|
+
if (source === void 0) return `unknown parameter source "${name}"`;
|
|
4170
|
+
if (dot === -1) {
|
|
4171
|
+
return source.keyed === true ? `source "${name}" needs a key, e.g. "${name}.fieldname"` : null;
|
|
4172
|
+
}
|
|
4173
|
+
if (source.keyed !== true) return `source "${name}" takes no key`;
|
|
4174
|
+
const key = parameter.slice(dot + 1);
|
|
4175
|
+
if (key === "") return `source "${name}" needs a key after the dot`;
|
|
4176
|
+
return keyProblem(name, source, key);
|
|
4177
|
+
}
|
|
4178
|
+
function keyProblem(name, source, key) {
|
|
4179
|
+
if (key.endsWith("*") && source.wildcard !== true) {
|
|
4180
|
+
return `"${name}" does not fan out over a wildcard key`;
|
|
4181
|
+
}
|
|
4182
|
+
const bare = key.endsWith("*") ? key.slice(0, -1) : key;
|
|
4183
|
+
if (source.keys === "any") return null;
|
|
4184
|
+
if (Array.isArray(source.keys) && source.keys.includes(bare)) return null;
|
|
4185
|
+
for (const prefix of source.key_prefixes ?? []) {
|
|
4186
|
+
if (bare.startsWith(prefix) && bare.length > prefix.length) return null;
|
|
4187
|
+
}
|
|
4188
|
+
const accepted = [
|
|
4189
|
+
...Array.isArray(source.keys) ? source.keys : [],
|
|
4190
|
+
...(source.key_prefixes ?? []).map((p) => `${p}\u2026`)
|
|
4191
|
+
].join(", ");
|
|
4192
|
+
return `"${name}" has no key "${key}" (expected one of: ${accepted})`;
|
|
4193
|
+
}
|
|
4194
|
+
function matchProblem(match, parameter) {
|
|
4195
|
+
if (!match || typeof match !== "object") return "condition has no match object";
|
|
4196
|
+
if (typeof match.type !== "string" || match.type === "") return "match.type must be a non-empty string";
|
|
4197
|
+
if (!MATCH_TYPES.includes(match.type)) return `unknown match type "${match.type}"`;
|
|
4198
|
+
const parameterless = PARAMETERLESS_MATCH_TYPES.includes(match.type);
|
|
4199
|
+
if (!parameterless && (parameter === void 0 || parameter === null)) {
|
|
4200
|
+
return `match type "${match.type}" needs a parameter`;
|
|
4201
|
+
}
|
|
4202
|
+
if (parameterless && parameter !== void 0 && parameter !== null) {
|
|
4203
|
+
return `match type "${match.type}" reads the whole request and takes no parameter`;
|
|
4204
|
+
}
|
|
4205
|
+
const operands = MATCH_OPERANDS[match.type];
|
|
4206
|
+
if (operands) {
|
|
4207
|
+
for (const required of operands.required) {
|
|
4208
|
+
if (match[required] === void 0 || match[required] === null) {
|
|
4209
|
+
return `match type "${match.type}" needs "${required}"`;
|
|
4210
|
+
}
|
|
4211
|
+
}
|
|
4212
|
+
for (const [operand, shape] of Object.entries(operands.shapes ?? {})) {
|
|
4213
|
+
if (match[operand] === void 0) continue;
|
|
4214
|
+
const problem = operandShapeProblem(shape, match[operand]);
|
|
4215
|
+
if (problem) return `match type "${match.type}" operand "${operand}" ${problem}`;
|
|
4216
|
+
}
|
|
4217
|
+
} else if (!OPERANDLESS_MATCH_TYPES.includes(match.type) && match.value === void 0) {
|
|
4218
|
+
return `match type "${match.type}" needs a value`;
|
|
4219
|
+
}
|
|
4220
|
+
return null;
|
|
4221
|
+
}
|
|
4222
|
+
function mutationsProblem(mutations) {
|
|
4223
|
+
if (mutations === void 0 || mutations === null) return null;
|
|
4224
|
+
if (!Array.isArray(mutations)) return "mutations must be an array";
|
|
4225
|
+
for (const mutation of mutations) {
|
|
4226
|
+
if (typeof mutation !== "string" || !MUTATIONS.includes(mutation)) {
|
|
4227
|
+
return `unknown mutation "${String(mutation)}" \u2014 applied as a no-op, so the condition would test the untransformed value`;
|
|
4228
|
+
}
|
|
4229
|
+
}
|
|
4230
|
+
return null;
|
|
4231
|
+
}
|
|
4232
|
+
function whenProblem(when) {
|
|
4233
|
+
if (when === void 0 || when === null) return null;
|
|
4234
|
+
if (typeof when !== "object" || Array.isArray(when)) return "when must be an object";
|
|
4235
|
+
const keys = Object.keys(when);
|
|
4236
|
+
if (keys.length === 0) return "when is empty \u2014 remove it, or name a path or method";
|
|
4237
|
+
const unknown = keys.filter((key) => !WHEN_KEYS.includes(key));
|
|
4238
|
+
if (unknown.length > 0) {
|
|
4239
|
+
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`;
|
|
4240
|
+
}
|
|
4241
|
+
if ("path" in when && !isNonEmptyString(when.path)) {
|
|
4242
|
+
return "when.path must be a non-empty string";
|
|
4243
|
+
}
|
|
4244
|
+
if ("method" in when) {
|
|
4245
|
+
const methods = Array.isArray(when.method) ? when.method : [when.method];
|
|
4246
|
+
if (methods.length === 0) return "when.method is an empty list, so the rule would match no method";
|
|
4247
|
+
if (!methods.every(isNonEmptyString)) return "when.method must be a non-empty string, or a list of them";
|
|
4248
|
+
}
|
|
4249
|
+
return null;
|
|
4250
|
+
}
|
|
4251
|
+
function isNonEmptyString(value) {
|
|
4252
|
+
return typeof value === "string" && value.trim() !== "";
|
|
4253
|
+
}
|
|
4254
|
+
function nullPropertyProblem(rule) {
|
|
4255
|
+
if (!rule || typeof rule !== "object") return null;
|
|
4256
|
+
for (const property of RULE_PROPERTIES) {
|
|
4257
|
+
if (property in rule && rule[property] === null) {
|
|
4258
|
+
return `"${property}" is present but null; omit it to mean the default, since a null is a value that was meant to be something and is not`;
|
|
4259
|
+
}
|
|
4260
|
+
}
|
|
4261
|
+
return null;
|
|
4262
|
+
}
|
|
4263
|
+
function rulePropertyProblem(rule) {
|
|
4264
|
+
if (!rule || typeof rule !== "object") return null;
|
|
4265
|
+
for (const [property, shape] of Object.entries(RULE_PROPERTY_SHAPES)) {
|
|
4266
|
+
if (rule[property] === void 0 || rule[property] === null) continue;
|
|
4267
|
+
const problem = operandShapeProblem(shape, rule[property]);
|
|
4268
|
+
if (problem) return `"${property}" ${problem}`;
|
|
4269
|
+
}
|
|
4270
|
+
return null;
|
|
4271
|
+
}
|
|
4272
|
+
function actionProblem(action, rule) {
|
|
4273
|
+
if (action === void 0 || action === null) return null;
|
|
4274
|
+
if (!ACTIONS.includes(action)) return `unknown action "${action}"`;
|
|
4275
|
+
const spec = ACTION_PROPERTIES[action];
|
|
4276
|
+
for (const required of spec?.required ?? []) {
|
|
4277
|
+
if (rule?.[required] === void 0 || rule?.[required] === null) {
|
|
4278
|
+
return `action "${action}" needs "${required}"`;
|
|
4279
|
+
}
|
|
4280
|
+
}
|
|
4281
|
+
for (const [property, shape] of Object.entries(spec?.shapes ?? {})) {
|
|
4282
|
+
if (rule?.[property] === void 0 || rule?.[property] === null) continue;
|
|
4283
|
+
const problem = operandShapeProblem(shape, rule[property]);
|
|
4284
|
+
if (problem) return `action "${action}" property "${property}" ${problem}`;
|
|
4285
|
+
}
|
|
4286
|
+
const phase = rule?.phase ?? "request";
|
|
4287
|
+
if (spec !== void 0 && !spec.phases.includes(phase)) {
|
|
4288
|
+
return `action "${action}" is carried out on the ${spec.phases.join("/")} phase; on "${phase}" the rule blocks instead`;
|
|
4289
|
+
}
|
|
4290
|
+
return null;
|
|
4291
|
+
}
|
|
4292
|
+
|
|
4293
|
+
// src/protect/rules/validate.js
|
|
4294
|
+
var LIMITS2 = LIMITS;
|
|
4295
|
+
var PHASES2 = new Set(PHASES);
|
|
4296
|
+
var ACTIONS2 = new Set(ACTIONS);
|
|
3440
4297
|
function validateBundle(bundle, opts = {}) {
|
|
3441
4298
|
const rejected = [];
|
|
3442
4299
|
const inFirewall = Array.isArray(bundle?.firewall) ? bundle.firewall : [];
|
|
3443
4300
|
const inWhitelists = Array.isArray(bundle?.whitelists) ? bundle.whitelists : [];
|
|
3444
4301
|
const firewall = [];
|
|
3445
4302
|
for (const rule of inFirewall) {
|
|
3446
|
-
if (firewall.length >=
|
|
3447
|
-
rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${
|
|
4303
|
+
if (firewall.length >= LIMITS2.maxRules) {
|
|
4304
|
+
rejected.push({ id: idOf(rule), reason: `bundle exceeds maxRules (${LIMITS2.maxRules})` });
|
|
3448
4305
|
continue;
|
|
3449
4306
|
}
|
|
3450
4307
|
const reason = ruleProblem(rule);
|
|
@@ -3453,8 +4310,8 @@ function validateBundle(bundle, opts = {}) {
|
|
|
3453
4310
|
}
|
|
3454
4311
|
const whitelists = [];
|
|
3455
4312
|
for (const wl of inWhitelists) {
|
|
3456
|
-
if (whitelists.length >=
|
|
3457
|
-
rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${
|
|
4313
|
+
if (whitelists.length >= LIMITS2.maxWhitelists) {
|
|
4314
|
+
rejected.push({ id: idOf(wl), reason: `bundle exceeds maxWhitelists (${LIMITS2.maxWhitelists})` });
|
|
3458
4315
|
continue;
|
|
3459
4316
|
}
|
|
3460
4317
|
if (!opts.allowGlobalWhitelists && wl && Array.isArray(wl.rule_v2) && !wl.rule_id) {
|
|
@@ -3476,37 +4333,47 @@ function idOf(rule) {
|
|
|
3476
4333
|
}
|
|
3477
4334
|
function ruleProblem(rule) {
|
|
3478
4335
|
if (!rule || typeof rule !== "object") return "not an object";
|
|
3479
|
-
|
|
3480
|
-
if (
|
|
3481
|
-
|
|
3482
|
-
|
|
4336
|
+
const nullReason = nullPropertyProblem(rule);
|
|
4337
|
+
if (nullReason) return nullReason;
|
|
4338
|
+
if (rule.phase !== void 0 && !PHASES2.has(rule.phase)) return `unknown phase "${rule.phase}"`;
|
|
4339
|
+
const actionReason = actionProblem(rule.action, rule);
|
|
4340
|
+
if (actionReason) return actionReason;
|
|
4341
|
+
const scopeReason = whenProblem(rule.when);
|
|
4342
|
+
if (scopeReason) return scopeReason;
|
|
4343
|
+
const propertyReason = rulePropertyProblem(rule);
|
|
4344
|
+
if (propertyReason) return propertyReason;
|
|
3483
4345
|
return conditionsProblem(rule.rule_v2);
|
|
3484
4346
|
}
|
|
3485
4347
|
function conditionsProblem(conditions, depth = 0) {
|
|
3486
4348
|
if (!Array.isArray(conditions)) return "rule_v2 must be an array of conditions";
|
|
3487
4349
|
if (conditions.length === 0) return "rule_v2 is empty (would never match)";
|
|
3488
|
-
if (depth >
|
|
3489
|
-
if (conditions.length >
|
|
3490
|
-
return `more than ${
|
|
4350
|
+
if (depth > LIMITS2.maxNestingDepth) return `nesting deeper than ${LIMITS2.maxNestingDepth}`;
|
|
4351
|
+
if (conditions.length > LIMITS2.maxConditionsPerRule) {
|
|
4352
|
+
return `more than ${LIMITS2.maxConditionsPerRule} conditions`;
|
|
3491
4353
|
}
|
|
3492
4354
|
for (const c of conditions) {
|
|
3493
|
-
|
|
3494
|
-
if (
|
|
4355
|
+
const shapeReason = conditionShapeProblem(c);
|
|
4356
|
+
if (shapeReason) return shapeReason;
|
|
4357
|
+
if (isGroup(c)) {
|
|
3495
4358
|
const nested = conditionsProblem(c.rules, depth + 1);
|
|
3496
4359
|
if (nested) return nested;
|
|
3497
4360
|
continue;
|
|
3498
4361
|
}
|
|
4362
|
+
const parameterReason = parameterProblem(c.parameter);
|
|
4363
|
+
if (parameterReason) return parameterReason;
|
|
4364
|
+
const mutationReason = mutationsProblem(c.mutations);
|
|
4365
|
+
if (mutationReason) return mutationReason;
|
|
4366
|
+
const matchReason = matchProblem(c.match, c.parameter);
|
|
4367
|
+
if (matchReason) return matchReason;
|
|
3499
4368
|
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
4369
|
if (m.type === "regex") {
|
|
3503
4370
|
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 ${
|
|
4371
|
+
if (m.value.length > LIMITS2.maxRegexLength) return `regex longer than ${LIMITS2.maxRegexLength} chars`;
|
|
4372
|
+
} else if (typeof m.value === "string" && m.value.length > LIMITS2.maxValueLength) {
|
|
4373
|
+
return `match.value longer than ${LIMITS2.maxValueLength} chars`;
|
|
3507
4374
|
}
|
|
3508
4375
|
if (m.match) {
|
|
3509
|
-
const nested = conditionsProblem([{ match: m.match }], depth + 1);
|
|
4376
|
+
const nested = conditionsProblem([{ parameter: c.parameter, match: m.match }], depth + 1);
|
|
3510
4377
|
if (nested) return nested;
|
|
3511
4378
|
}
|
|
3512
4379
|
}
|
|
@@ -3534,64 +4401,67 @@ function reportRejections(rejected, options, label) {
|
|
|
3534
4401
|
`${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
4402
|
), "onError");
|
|
3536
4403
|
}
|
|
4404
|
+
function fromSource(bundle, reason) {
|
|
4405
|
+
return reason === void 0 ? { ...bundle, source: { ok: true } } : { ...bundle, source: { ok: false, reason } };
|
|
4406
|
+
}
|
|
3537
4407
|
async function resolveRules(options, store, ctx = {}) {
|
|
3538
4408
|
const timeoutMs = ctx.timeoutMs;
|
|
3539
4409
|
if (options.siteUuid) {
|
|
3540
4410
|
const prior = await store.read();
|
|
3541
4411
|
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, reportsDetections: options.reportDetections === true });
|
|
3542
4412
|
const res = await client.getRules();
|
|
3543
|
-
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
4413
|
+
if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
|
|
3544
4414
|
if (res.success && !res.notModified) {
|
|
3545
4415
|
const rejected = liveUpdateRejections(res, options);
|
|
3546
4416
|
if (rejected.length > 0) {
|
|
3547
4417
|
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();
|
|
4418
|
+
if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
|
|
4419
|
+
if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
|
|
4420
|
+
return fromSource(emptyBundle(), "update rejected");
|
|
3551
4421
|
}
|
|
3552
4422
|
const bundle = normalizeBundle(res, options);
|
|
3553
4423
|
await store.write({ bundle, etag: res.etag ?? null });
|
|
3554
|
-
return bundle;
|
|
4424
|
+
return fromSource(bundle);
|
|
3555
4425
|
}
|
|
3556
4426
|
if (prior?.bundle) {
|
|
3557
4427
|
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
|
|
3558
|
-
return normalizeBundle(prior.bundle, options);
|
|
4428
|
+
return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
|
|
3559
4429
|
}
|
|
3560
4430
|
if (options.rules) {
|
|
3561
4431
|
notify(options.onError, new Error(`pulse rule fetch failed (${res.error ?? "no usable response"}); using bundled fallback`), "onError");
|
|
3562
|
-
return normalizeBundle(options.rules, options);
|
|
4432
|
+
return fromSource(normalizeBundle(options.rules, options), res.error ?? "no usable response");
|
|
3563
4433
|
}
|
|
3564
4434
|
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();
|
|
4435
|
+
return fromSource(emptyBundle(), res.error ?? "no usable response");
|
|
3566
4436
|
}
|
|
3567
4437
|
if (options.token) {
|
|
3568
4438
|
const prior = await store.read();
|
|
3569
4439
|
const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs });
|
|
3570
4440
|
const res = await client.getRules();
|
|
3571
|
-
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
|
|
4441
|
+
if (res.success && res.notModified && prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options));
|
|
3572
4442
|
if (res.success && !res.notModified) {
|
|
3573
4443
|
const rejected = liveUpdateRejections(res, options);
|
|
3574
4444
|
if (rejected.length > 0) {
|
|
3575
4445
|
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();
|
|
4446
|
+
if (prior?.bundle) return fromSource(normalizeBundle(prior.bundle, options), "update rejected");
|
|
4447
|
+
if (options.rules) return fromSource(normalizeBundle(options.rules, options), "update rejected");
|
|
4448
|
+
return fromSource(emptyBundle(), "update rejected");
|
|
3579
4449
|
}
|
|
3580
4450
|
const bundle = normalizeBundle(res, options);
|
|
3581
4451
|
await store.write({ bundle, etag: res.etag ?? null });
|
|
3582
|
-
return bundle;
|
|
4452
|
+
return fromSource(bundle);
|
|
3583
4453
|
}
|
|
3584
4454
|
if (prior?.bundle) {
|
|
3585
4455
|
notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); using cached bundle`), "onError");
|
|
3586
|
-
return normalizeBundle(prior.bundle, options);
|
|
4456
|
+
return fromSource(normalizeBundle(prior.bundle, options), res.error ?? "no usable response");
|
|
3587
4457
|
}
|
|
3588
4458
|
notify(options.onError, new Error(`rule fetch failed (${res.error ?? "no usable response"}); no cache \u2014 running with no rules`), "onError");
|
|
3589
|
-
return emptyBundle();
|
|
4459
|
+
return fromSource(emptyBundle(), res.error ?? "no usable response");
|
|
3590
4460
|
}
|
|
3591
4461
|
if (options.rules) {
|
|
3592
|
-
return normalizeBundle(options.rules, options);
|
|
4462
|
+
return fromSource(normalizeBundle(options.rules, options));
|
|
3593
4463
|
}
|
|
3594
|
-
return emptyBundle();
|
|
4464
|
+
return fromSource(emptyBundle());
|
|
3595
4465
|
}
|
|
3596
4466
|
function normalizeBundle(b, options = {}) {
|
|
3597
4467
|
const enforcement = b?.enforcement ?? b?.mode;
|
|
@@ -3644,8 +4514,9 @@ function startRefresh(tick, { refreshMs, onError } = {}) {
|
|
|
3644
4514
|
const run = async () => {
|
|
3645
4515
|
if (stopped) return;
|
|
3646
4516
|
try {
|
|
3647
|
-
await tick();
|
|
3648
|
-
|
|
4517
|
+
const status = await tick();
|
|
4518
|
+
if (status && status.ok === false) failures++;
|
|
4519
|
+
else failures = 0;
|
|
3649
4520
|
} catch (err) {
|
|
3650
4521
|
failures++;
|
|
3651
4522
|
notify(onError, err, "onError");
|
|
@@ -3672,7 +4543,8 @@ function makeRefreshHandler(tick, secret) {
|
|
|
3672
4543
|
if (provided !== secret) return new Response("forbidden", { status: 403 });
|
|
3673
4544
|
let refreshed = true;
|
|
3674
4545
|
try {
|
|
3675
|
-
await tick();
|
|
4546
|
+
const status = await tick();
|
|
4547
|
+
if (status && status.ok === false) refreshed = false;
|
|
3676
4548
|
} catch {
|
|
3677
4549
|
refreshed = false;
|
|
3678
4550
|
}
|
|
@@ -3701,6 +4573,12 @@ function ruleParameters(rule) {
|
|
|
3701
4573
|
walk2(rule?.rule_v2);
|
|
3702
4574
|
return [...out];
|
|
3703
4575
|
}
|
|
4576
|
+
function revisionOf(rule) {
|
|
4577
|
+
const revision = rule?.source_revision;
|
|
4578
|
+
if (typeof revision === "string" && revision !== "") return revision;
|
|
4579
|
+
if (typeof revision === "number" && Number.isFinite(revision)) return String(revision);
|
|
4580
|
+
return null;
|
|
4581
|
+
}
|
|
3704
4582
|
function routeOf(path7) {
|
|
3705
4583
|
if (typeof path7 !== "string" || path7 === "") return null;
|
|
3706
4584
|
const cut = path7.search(/[?#]/);
|
|
@@ -3709,16 +4587,30 @@ function routeOf(path7) {
|
|
|
3709
4587
|
function createDetectionReporter(opts) {
|
|
3710
4588
|
const siteUuid = opts.siteUuid ?? process.env?.PATCHSTACK_SITE_UUID;
|
|
3711
4589
|
if (!siteUuid) {
|
|
3712
|
-
return {
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
4590
|
+
return {
|
|
4591
|
+
record() {
|
|
4592
|
+
},
|
|
4593
|
+
flush() {
|
|
4594
|
+
},
|
|
4595
|
+
stop() {
|
|
4596
|
+
},
|
|
4597
|
+
setRulesEtag() {
|
|
4598
|
+
},
|
|
4599
|
+
dropped: () => 0,
|
|
4600
|
+
health: () => ({ sent: 0, delivered: 0, failed: 0, dropped: 0, lastDeliveredAt: null })
|
|
4601
|
+
};
|
|
3716
4602
|
}
|
|
3717
4603
|
const configured = opts.baseUrl ?? process.env?.PATCHSTACK_PULSE_RULES_URL;
|
|
3718
4604
|
const baseUrl = typeof configured === "string" && isSafeOrigin(configured) ? configured.replace(/\/$/, "") : DEFAULT_BASE_URL3;
|
|
3719
4605
|
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
3720
4606
|
const flushMs = Number.isFinite(opts.flushMs) && opts.flushMs > 0 ? opts.flushMs : DEFAULT_FLUSH_MS;
|
|
3721
4607
|
const maxQueue = Number.isFinite(opts.maxQueue) && opts.maxQueue > 0 ? opts.maxQueue : MAX_QUEUE;
|
|
4608
|
+
let rulesEtag = opts.rulesEtag ?? null;
|
|
4609
|
+
let sent = 0;
|
|
4610
|
+
let delivered = 0;
|
|
4611
|
+
let failed = 0;
|
|
4612
|
+
let droppedTotal = 0;
|
|
4613
|
+
let lastDeliveredAt = null;
|
|
3722
4614
|
let queue = [];
|
|
3723
4615
|
let timer = null;
|
|
3724
4616
|
let stopped = false;
|
|
@@ -3732,6 +4624,8 @@ function createDetectionReporter(opts) {
|
|
|
3732
4624
|
const batch = queue.splice(0, MAX_BATCH);
|
|
3733
4625
|
const droppedWith = dropped;
|
|
3734
4626
|
dropped = 0;
|
|
4627
|
+
droppedTotal += droppedWith;
|
|
4628
|
+
sent += batch.length;
|
|
3735
4629
|
void (async () => {
|
|
3736
4630
|
try {
|
|
3737
4631
|
const res = await fetchImpl(`${baseUrl}/detections/${encodeURIComponent(siteUuid)}`, {
|
|
@@ -3740,15 +4634,22 @@ function createDetectionReporter(opts) {
|
|
|
3740
4634
|
"Content-Type": "application/json",
|
|
3741
4635
|
Accept: "application/json",
|
|
3742
4636
|
"User-Agent": "@patchstack/connect",
|
|
3743
|
-
// Same credential path as the rules fetch
|
|
3744
|
-
//
|
|
4637
|
+
// Same credential path as the rules fetch. The detections endpoint is site-addressed and
|
|
4638
|
+
// requires a verified, site-bound token, so a batch sent without one is refused — which is
|
|
4639
|
+
// why the runtime does not build a reporter when no credential resolves, rather than
|
|
4640
|
+
// posting into a 401.
|
|
3745
4641
|
...await pulseAuthHeader({ pulseAuth: opts.pulseAuth, endpoint: baseUrl }, fetchImpl)
|
|
3746
4642
|
},
|
|
3747
4643
|
body: JSON.stringify({ detections: batch, dropped: droppedWith })
|
|
3748
4644
|
});
|
|
3749
|
-
if (res &&
|
|
3750
|
-
|
|
4645
|
+
if (res && res.ok) {
|
|
4646
|
+
delivered += batch.length;
|
|
4647
|
+
lastDeliveredAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4648
|
+
} else {
|
|
4649
|
+
failed += batch.length;
|
|
4650
|
+
}
|
|
3751
4651
|
} catch {
|
|
4652
|
+
failed += batch.length;
|
|
3752
4653
|
}
|
|
3753
4654
|
})();
|
|
3754
4655
|
};
|
|
@@ -3777,7 +4678,12 @@ function createDetectionReporter(opts) {
|
|
|
3777
4678
|
// The state this detection was handled under, which is the whole point: `false` is a rule that
|
|
3778
4679
|
// saw traffic it would have stopped.
|
|
3779
4680
|
enforced: detection.mode === "block",
|
|
3780
|
-
rules_etag:
|
|
4681
|
+
rules_etag: rulesEtag,
|
|
4682
|
+
// The revision of THIS rule, as the bundle delivered it. The bundle identity above answers "which
|
|
4683
|
+
// bundle", which changes whenever anything in it changes — so it cannot say whether the counts for
|
|
4684
|
+
// one rule describe the document that rule has now. Passed through untouched, and null when the
|
|
4685
|
+
// bundle carried none.
|
|
4686
|
+
rule_revision: revisionOf(detection.rule),
|
|
3781
4687
|
detected_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
3782
4688
|
});
|
|
3783
4689
|
if (queue.length >= MAX_BATCH) {
|
|
@@ -3791,7 +4697,24 @@ function createDetectionReporter(opts) {
|
|
|
3791
4697
|
stopped = true;
|
|
3792
4698
|
flush();
|
|
3793
4699
|
},
|
|
3794
|
-
|
|
4700
|
+
/**
|
|
4701
|
+
* Point later events at the bundle now running. Called after an ACCEPTED swap only — a rejected
|
|
4702
|
+
* or failed refresh keeps the previous rules, so it must keep the previous identity too.
|
|
4703
|
+
*
|
|
4704
|
+
* Already-queued events are not rewritten: the stamp is taken when an event is recorded, which is
|
|
4705
|
+
* the only moment the two are known to agree.
|
|
4706
|
+
*
|
|
4707
|
+
* @param {string | null | undefined} next
|
|
4708
|
+
*/
|
|
4709
|
+
setRulesEtag(next) {
|
|
4710
|
+
rulesEtag = next ?? null;
|
|
4711
|
+
},
|
|
4712
|
+
dropped: () => dropped,
|
|
4713
|
+
/**
|
|
4714
|
+
* Delivery health, counted in events: attempted, acknowledged, refused or unreachable, and dropped
|
|
4715
|
+
* for queue pressure — plus when a batch was last acknowledged. No request data of any kind.
|
|
4716
|
+
*/
|
|
4717
|
+
health: () => ({ sent, delivered, failed, dropped: droppedTotal + dropped, lastDeliveredAt })
|
|
3795
4718
|
};
|
|
3796
4719
|
}
|
|
3797
4720
|
|
|
@@ -4056,22 +4979,32 @@ async function createProtection(options = {}) {
|
|
|
4056
4979
|
const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5e3;
|
|
4057
4980
|
const pulseAuth = await resolvePulseAuth(options);
|
|
4058
4981
|
if (options.siteUuid && !pulseAuth) {
|
|
4059
|
-
const message = "Patchstack: no API credential resolved for site " + options.siteUuid + ". Rule updates
|
|
4982
|
+
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
4983
|
notify(onError, new Error(message), "onError");
|
|
4061
4984
|
console.warn(message);
|
|
4062
4985
|
}
|
|
4063
4986
|
const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth });
|
|
4987
|
+
let detectionReporting = "off";
|
|
4064
4988
|
if (options.reportDetections === true && options.siteUuid && telemetryEnabled()) {
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4989
|
+
if (!pulseAuth) {
|
|
4990
|
+
detectionReporting = "unavailable-no-credential";
|
|
4991
|
+
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.";
|
|
4992
|
+
notify(onError, new Error(message), "onError");
|
|
4993
|
+
console.warn(message);
|
|
4994
|
+
} else {
|
|
4995
|
+
detectionReporting = "on";
|
|
4996
|
+
detections = createDetectionReporter({
|
|
4997
|
+
siteUuid: options.siteUuid,
|
|
4998
|
+
baseUrl: options.pulseRulesUrl,
|
|
4999
|
+
pulseAuth,
|
|
5000
|
+
// The bundle the guard is actually running, so a hit can be attributed to the rules that produced
|
|
5001
|
+
// it rather than to whatever is current when the report is read. Kept current across refreshes —
|
|
5002
|
+
// see the refresh tick below.
|
|
5003
|
+
rulesEtag: (await store.read())?.etag ?? null,
|
|
5004
|
+
fetchImpl: options.fetchImpl,
|
|
5005
|
+
flushMs: options.detectionFlushMs
|
|
5006
|
+
});
|
|
5007
|
+
}
|
|
4075
5008
|
}
|
|
4076
5009
|
let mode = resolveMode(options, bundle);
|
|
4077
5010
|
let requestRules;
|
|
@@ -4405,11 +5338,17 @@ async function createProtection(options = {}) {
|
|
|
4405
5338
|
);
|
|
4406
5339
|
};
|
|
4407
5340
|
},
|
|
4408
|
-
// Node / Connect middleware — buffers the body itself (request phase).
|
|
5341
|
+
// Node / Connect middleware — buffers the body itself (request phase). Register it BEFORE any body
|
|
5342
|
+
// parser: it reads the request stream, and exposes what it read as `req.body` so a parser is not
|
|
5343
|
+
// also needed. (`.express()` is the other way round — it reads a body somebody else parsed.)
|
|
4409
5344
|
// Pass { screenResponses: true } to also screen the outgoing response (buffers it).
|
|
4410
5345
|
node(nodeOptions = {}) {
|
|
4411
5346
|
const maxBytes = nodeOptions.maxBodyBytes ?? 1024 * 1024;
|
|
4412
5347
|
return (req, res, next) => {
|
|
5348
|
+
if (req.readableEnded || req.body !== void 0) {
|
|
5349
|
+
screenNodeRequest(req, res, next, "", req.body);
|
|
5350
|
+
return;
|
|
5351
|
+
}
|
|
4413
5352
|
const chunks = [];
|
|
4414
5353
|
let size = 0;
|
|
4415
5354
|
let overflow = false;
|
|
@@ -4427,38 +5366,41 @@ async function createProtection(options = {}) {
|
|
|
4427
5366
|
});
|
|
4428
5367
|
req.on("end", () => {
|
|
4429
5368
|
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
|
-
);
|
|
5369
|
+
screenNodeRequest(req, res, next, overflow ? "" : Buffer.concat(chunks).toString("utf8"));
|
|
4460
5370
|
});
|
|
4461
5371
|
};
|
|
5372
|
+
function screenNodeRequest(req, res, next, rawBody, parsedBody) {
|
|
5373
|
+
let shaped;
|
|
5374
|
+
let result;
|
|
5375
|
+
try {
|
|
5376
|
+
shaped = fromNodeRequest(req, rawBody);
|
|
5377
|
+
if (parsedBody !== void 0 && parsedBody !== null) shaped.body = parsedBody;
|
|
5378
|
+
result = engine.evaluate(shaped);
|
|
5379
|
+
} catch (err) {
|
|
5380
|
+
notify(onError, err, "onError");
|
|
5381
|
+
return next();
|
|
5382
|
+
}
|
|
5383
|
+
decide(
|
|
5384
|
+
"request",
|
|
5385
|
+
result,
|
|
5386
|
+
() => {
|
|
5387
|
+
res.statusCode = 403;
|
|
5388
|
+
if (isDocumentNavigation((n) => req.headers?.[n])) {
|
|
5389
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
5390
|
+
res.end(renderBlockPage({ url: req.url || "/", code: result?.rule?.id }));
|
|
5391
|
+
} else {
|
|
5392
|
+
res.setHeader("content-type", "application/json");
|
|
5393
|
+
res.end(JSON.stringify(blockBody(result)));
|
|
5394
|
+
}
|
|
5395
|
+
},
|
|
5396
|
+
() => {
|
|
5397
|
+
if (req.body === void 0) req.body = shaped.body;
|
|
5398
|
+
if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
|
|
5399
|
+
next();
|
|
5400
|
+
},
|
|
5401
|
+
nodeRequestMeta(req)
|
|
5402
|
+
);
|
|
5403
|
+
}
|
|
4462
5404
|
}
|
|
4463
5405
|
};
|
|
4464
5406
|
if (options.egress) {
|
|
@@ -4495,24 +5437,22 @@ async function createProtection(options = {}) {
|
|
|
4495
5437
|
const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs, pulseAuth });
|
|
4496
5438
|
mode = resolveMode(options, next);
|
|
4497
5439
|
applyBundle(next);
|
|
5440
|
+
if (detections) detections.setRulesEtag((await store.read())?.etag ?? null);
|
|
5441
|
+
return next.source ?? { ok: true };
|
|
4498
5442
|
};
|
|
4499
5443
|
if (live) {
|
|
4500
5444
|
protection.refresh = () => runRefreshTick();
|
|
4501
5445
|
protection.refreshHandler = () => makeRefreshHandler(runRefreshTick, refreshSecret);
|
|
4502
5446
|
}
|
|
4503
|
-
|
|
4504
|
-
|
|
4505
|
-
|
|
4506
|
-
|
|
4507
|
-
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
|
|
4512
|
-
firewallLog.stop();
|
|
4513
|
-
detections?.stop();
|
|
4514
|
-
};
|
|
4515
|
-
}
|
|
5447
|
+
const loop = options.refreshMs > 0 && live ? startRefresh(runRefreshTick, { refreshMs: options.refreshMs, onError }) : null;
|
|
5448
|
+
protection.stop = () => {
|
|
5449
|
+
loop?.stop();
|
|
5450
|
+
firewallLog?.stop();
|
|
5451
|
+
detections?.stop();
|
|
5452
|
+
};
|
|
5453
|
+
protection.stopRefresh = protection.stop;
|
|
5454
|
+
protection.detectionReporting = detectionReporting;
|
|
5455
|
+
if (detections) protection.detectionHealth = () => detections.health();
|
|
4516
5456
|
return protection;
|
|
4517
5457
|
}
|
|
4518
5458
|
function resolveMode(options, bundle) {
|
|
@@ -4523,22 +5463,35 @@ function resolveMode(options, bundle) {
|
|
|
4523
5463
|
if (options?.mode === "dry-run") return "dry-run";
|
|
4524
5464
|
return "dry-run";
|
|
4525
5465
|
}
|
|
5466
|
+
var CREDENTIAL_FILES = [".patchstackrc.local.json", ".patchstackrc.json"];
|
|
5467
|
+
async function readCredentialField(options, field) {
|
|
5468
|
+
if (typeof process === "undefined" || typeof process.cwd !== "function") return void 0;
|
|
5469
|
+
let readFileSync;
|
|
5470
|
+
let join;
|
|
5471
|
+
try {
|
|
5472
|
+
const [fs, pathMod] = await Promise.all([import("fs"), import("path")]);
|
|
5473
|
+
readFileSync = fs.readFileSync;
|
|
5474
|
+
join = pathMod.join;
|
|
5475
|
+
} catch {
|
|
5476
|
+
return void 0;
|
|
5477
|
+
}
|
|
5478
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
5479
|
+
for (const filename of CREDENTIAL_FILES) {
|
|
5480
|
+
try {
|
|
5481
|
+
const value = JSON.parse(readFileSync(join(cwd, filename), "utf8"))?.[field];
|
|
5482
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
5483
|
+
} catch {
|
|
5484
|
+
}
|
|
5485
|
+
}
|
|
5486
|
+
return void 0;
|
|
5487
|
+
}
|
|
4526
5488
|
async function resolveApiKey(options) {
|
|
4527
5489
|
if (typeof options?.apiKey === "string" && options.apiKey.length > 0) return options.apiKey;
|
|
4528
5490
|
if (typeof process !== "undefined") {
|
|
4529
5491
|
const fromEnv = process.env?.PATCHSTACK_API_KEY;
|
|
4530
5492
|
if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
|
|
4531
5493
|
}
|
|
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;
|
|
5494
|
+
return readCredentialField(options, "apiKey");
|
|
4542
5495
|
}
|
|
4543
5496
|
async function resolvePulseAuth(options) {
|
|
4544
5497
|
if (typeof options?.pulseAuth === "string" && options.pulseAuth.length > 0) return options.pulseAuth;
|
|
@@ -4546,15 +5499,8 @@ async function resolvePulseAuth(options) {
|
|
|
4546
5499
|
const fromEnv = process.env?.PATCHSTACK_PULSE_AUTH;
|
|
4547
5500
|
if (typeof fromEnv === "string" && fromEnv.length > 0) return fromEnv;
|
|
4548
5501
|
}
|
|
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
|
-
}
|
|
5502
|
+
const fromFile = await readCredentialField(options, "pulseAuth");
|
|
5503
|
+
if (fromFile !== void 0) return fromFile;
|
|
4558
5504
|
return resolveApiKey(options);
|
|
4559
5505
|
}
|
|
4560
5506
|
function byPhase(rules, phase) {
|
|
@@ -4687,6 +5633,8 @@ function extractRedactors(rule) {
|
|
|
4687
5633
|
}
|
|
4688
5634
|
} else if ((m.type === "contains" || m.type === "stripos") && m.value != null) {
|
|
4689
5635
|
out.push({ literal: String(m.value) });
|
|
5636
|
+
} else if (m.type === "jwt_claim_equals" && typeof m.claim === "string") {
|
|
5637
|
+
out.push({ jwtClaim: { claim: m.claim, value: String(m.value ?? "") } });
|
|
4690
5638
|
} else if (m.type === "array_key_value" && m.match && isBodyParam(c.parameter)) {
|
|
4691
5639
|
const keys = Array.isArray(m.key) ? m.key : [m.key];
|
|
4692
5640
|
for (const key of keys) {
|
|
@@ -4705,7 +5653,11 @@ function applyRedactors(body, redactors, mask, transform) {
|
|
|
4705
5653
|
let out = body;
|
|
4706
5654
|
for (const r of redactors) {
|
|
4707
5655
|
if (r.re) out = out.replace(r.re, transform ? (m) => transform(m) : mask);
|
|
4708
|
-
else if (r.
|
|
5656
|
+
else if (r.jwtClaim) {
|
|
5657
|
+
for (const token of jwtClaimSpans(out, r.jwtClaim.claim, r.jwtClaim.value)) {
|
|
5658
|
+
out = out.split(token).join(transform ? transform(token) : mask);
|
|
5659
|
+
}
|
|
5660
|
+
} else if (r.literal) {
|
|
4709
5661
|
const re = new RegExp(r.literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
|
|
4710
5662
|
out = out.replace(re, (m) => transform ? transform(m) : mask);
|
|
4711
5663
|
}
|
|
@@ -4839,11 +5791,13 @@ function applyHeaderMutation(headers, rule) {
|
|
|
4839
5791
|
}
|
|
4840
5792
|
}
|
|
4841
5793
|
}
|
|
5794
|
+
var SAME_SITE = ["Strict", "Lax", "None"];
|
|
4842
5795
|
function hardenCookie(cookie, { httpOnly = true, secure = true, sameSite = "Lax" } = {}) {
|
|
4843
5796
|
let out = String(cookie);
|
|
4844
5797
|
if (httpOnly && !/;\s*httponly/i.test(out)) out += "; HttpOnly";
|
|
4845
5798
|
if (secure && !/;\s*secure/i.test(out)) out += "; Secure";
|
|
4846
|
-
|
|
5799
|
+
const canonical = SAME_SITE.find((v) => typeof sameSite === "string" && v.toLowerCase() === sameSite.toLowerCase());
|
|
5800
|
+
if (canonical && !/;\s*samesite\s*=/i.test(out)) out += `; SameSite=${canonical}`;
|
|
4847
5801
|
return out;
|
|
4848
5802
|
}
|
|
4849
5803
|
function rebuildResponse(response, body, redactedHeaders) {
|
|
@@ -4857,11 +5811,9 @@ function rebuildResponse(response, body, redactedHeaders) {
|
|
|
4857
5811
|
} catch {
|
|
4858
5812
|
}
|
|
4859
5813
|
} else if (typeof value === "string") {
|
|
4860
|
-
|
|
4861
|
-
|
|
4862
|
-
|
|
4863
|
-
} catch {
|
|
4864
|
-
}
|
|
5814
|
+
try {
|
|
5815
|
+
if (headers.get(name) !== value) headers.set(name, value);
|
|
5816
|
+
} catch {
|
|
4865
5817
|
}
|
|
4866
5818
|
} else if (Array.isArray(value)) {
|
|
4867
5819
|
try {
|