flecto 2.1.0 → 3.0.1

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/src/policy.js CHANGED
@@ -1,7 +1,9 @@
1
- import { existsSync, readFileSync, readdirSync } from 'fs';
2
- import { dirname, isAbsolute, join, resolve } from 'path';
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'fs';
2
+ import { dirname, isAbsolute, join, relative, resolve } from 'path';
3
+ import { createRequire } from 'module';
3
4
  import { fileURLToPath, pathToFileURL } from 'url';
4
5
  import yaml from 'js-yaml';
6
+ import { containsSecret } from './secrets.js';
5
7
 
6
8
  /**
7
9
  * @typedef {'info' | 'warn' | 'error'} PolicySeverity
@@ -24,6 +26,8 @@ import yaml from 'js-yaml';
24
26
  * afterIn?: unknown[],
25
27
  * beforeTruthy?: true,
26
28
  * afterTruthy?: true,
29
+ * beforeLooksSecret?: true,
30
+ * afterLooksSecret?: true,
27
31
  * afterMatches?: string,
28
32
  * numericJump?: { minMultiple: number },
29
33
  * numericDelta?: { min: number },
@@ -41,12 +45,27 @@ import yaml from 'js-yaml';
41
45
  * afterIn?: unknown[],
42
46
  * beforeTruthy?: true,
43
47
  * afterTruthy?: true,
48
+ * beforeLooksSecret?: true,
49
+ * afterLooksSecret?: true,
44
50
  * afterMatches?: string,
45
51
  * numericJump?: { minMultiple: number },
46
52
  * numericDelta?: { min: number }
47
53
  * }} PolicyMatchClause
48
54
  *
49
- * @typedef {{ id: string, rules: PolicyRule[] }} PolicyPack
55
+ * @typedef {{ id: string, expandSubtrees?: boolean, rules: PolicyRule[] }} PolicyPack
56
+ *
57
+ * @typedef {{
58
+ * id: string,
59
+ * packageName: string,
60
+ * packageVersion: string | null,
61
+ * packFile: string,
62
+ * targetPath: string,
63
+ * ruleCount: number,
64
+ * overwritten: boolean,
65
+ * overridesBuiltin: boolean,
66
+ * shadowed: string[],
67
+ * shipsCode: boolean
68
+ * }} AddedPolicyPack
50
69
  *
51
70
  * @typedef {{
52
71
  * cwd?: string,
@@ -65,13 +84,22 @@ const CHANGE_TYPES = new Set(['added', 'removed', 'changed']);
65
84
  const RULE_FIELDS = new Set([
66
85
  'id', 'severity', 'when', 'match', 'beforeEquals', 'afterEquals',
67
86
  'beforeIn', 'afterIn', 'beforeTruthy', 'afterTruthy', 'numericJump',
87
+ 'beforeLooksSecret', 'afterLooksSecret',
68
88
  'afterMatches', 'numericDelta', 'allOf', 'anyOf', 'message', 'messageTemplate',
69
89
  ]);
70
90
  const CLAUSE_FIELDS = new Set([
71
91
  'match', 'beforeEquals', 'afterEquals', 'beforeIn', 'afterIn',
72
- 'beforeTruthy', 'afterTruthy', 'afterMatches', 'numericJump', 'numericDelta',
92
+ 'beforeTruthy', 'afterTruthy', 'beforeLooksSecret', 'afterLooksSecret',
93
+ 'afterMatches', 'numericJump', 'numericDelta',
73
94
  ]);
74
95
  const MATCH_FIELDS = new Set(['path', 'pathFlags', 'pathEquals', 'pathPrefix']);
96
+ // Community distribution convention: an npm package named flecto-pack-<id>
97
+ // carrying a declarative pack file. Nothing in such a package is ever imported.
98
+ const PACK_PACKAGE_PREFIX = 'flecto-pack-';
99
+ const PACK_FILE_CANDIDATES = ['flecto-pack.json', 'flecto-pack.yaml', 'flecto-pack.yml'];
100
+ const PACK_EXTENSIONS = ['.json', '.yaml', '.yml'];
101
+ const PACK_MANIFEST_FILE = '.flecto-packs.json';
102
+ const PACK_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
75
103
 
76
104
  /**
77
105
  * @param {string} path
@@ -109,13 +137,16 @@ function isTruthyToggle(value) {
109
137
  function validatePack(pack, path) {
110
138
  if (!isObject(pack)) invalidPack(path, 'pack must be an object');
111
139
 
112
- const packFields = new Set(['id', 'rules']);
140
+ const packFields = new Set(['id', 'expandSubtrees', 'rules']);
113
141
  for (const field of Object.keys(pack)) {
114
142
  if (!packFields.has(field)) invalidPack(path, `pack.${field} is not allowed`);
115
143
  }
116
144
  if (Object.hasOwn(pack, 'id') && (typeof pack.id !== 'string' || !pack.id.trim())) {
117
145
  invalidPack(path, 'pack.id must be a non-empty string');
118
146
  }
147
+ if (Object.hasOwn(pack, 'expandSubtrees') && typeof pack.expandSubtrees !== 'boolean') {
148
+ invalidPack(path, 'pack.expandSubtrees must be a boolean');
149
+ }
119
150
  if (!Array.isArray(pack.rules)) invalidPack(path, 'pack.rules must be an array');
120
151
 
121
152
  for (const [index, rule] of pack.rules.entries()) {
@@ -206,6 +237,8 @@ function validateRule(candidate, location, isClause = false) {
206
237
  validateArrayPredicate(rule.afterIn, 'afterIn', location);
207
238
  validateTruthyPredicate(rule.beforeTruthy, 'beforeTruthy', location);
208
239
  validateTruthyPredicate(rule.afterTruthy, 'afterTruthy', location);
240
+ validateTruthyPredicate(rule.beforeLooksSecret, 'beforeLooksSecret', location);
241
+ validateTruthyPredicate(rule.afterLooksSecret, 'afterLooksSecret', location);
209
242
  validateRegexPredicate(rule.afterMatches, 'afterMatches', location);
210
243
  validateNumericPredicate(rule.numericJump, 'numericJump', 'minMultiple', location, true);
211
244
  validateNumericPredicate(rule.numericDelta, 'numericDelta', 'min', location, false);
@@ -300,8 +333,49 @@ function validateComposition(clauses, name, location) {
300
333
  clauses.forEach((clause, index) => validateRule(clause, `${location}.${name}[${index}]`, true));
301
334
  }
302
335
 
336
+ /**
337
+ * Loaded, validated, and regex-compiled packs, keyed by cwd + resolved file
338
+ * path + that file's mtime. `evaluatePolicies()` calls loadPack() once per
339
+ * file in `ci` and once per change event in `watch`; this cache turns every
340
+ * repeat call for an unchanged pack file into a Map lookup instead of a
341
+ * `readFileSync` + `JSON.parse` + full schema walk.
342
+ *
343
+ * The key deliberately does *not* memoize on packId alone:
344
+ * - Including the resolved path means a local `policies/<id>.json` that
345
+ * starts shadowing a built-in (or stops shadowing it) after this process
346
+ * started is picked up on the very next call, because resolvePackPath()
347
+ * runs fresh every time and produces a different path.
348
+ * - Including cwd keeps two different working directories (e.g. two
349
+ * in-process callers, or tests) from ever sharing a cache slot merely
350
+ * because they happened to load the same-named local pack.
351
+ * - Including mtimeMs means editing policies/<id>.json mid-`watch` changes
352
+ * the key, so the very next change event re-reads and re-validates the
353
+ * file rather than serving the stale in-memory pack. A pack that fails to
354
+ * parse or validate throws exactly as before — nothing here catches or
355
+ * downgrades that error into "serve the last cached pack", which would
356
+ * quietly defeat watch mode's fail-closed behavior on a bad edit.
357
+ * @type {Map<string, PolicyPack>}
358
+ */
359
+ const packCache = new Map();
360
+
361
+ /**
362
+ * @param {string} cwd
363
+ * @param {string} path
364
+ * @param {number} mtimeMs
365
+ * @returns {string}
366
+ */
367
+ function packCacheKey(cwd, path, mtimeMs) {
368
+ return `${resolve(cwd)}\u0000${path}\u0000${mtimeMs}`;
369
+ }
370
+
303
371
  /**
304
372
  * Load a pack by id from policies/ then built-ins.
373
+ *
374
+ * severityRemap is intentionally not part of this function or its cache: it
375
+ * is applied per profile, downstream, in evaluatePack(). Caching happens at
376
+ * this layer — below any remapping — so the object returned here is the same
377
+ * regardless of which profile asked for it, and one profile's remap can
378
+ * never leak into another's findings.
305
379
  * @param {string} packId
306
380
  * @param {string} [cwd]
307
381
  * @returns {PolicyPack}
@@ -313,7 +387,16 @@ export function loadPack(packId, cwd = process.cwd()) {
313
387
  if (!path) {
314
388
  throw new Error(`Unknown policy pack "${id}". Add policies/${id}.json or use a built-in pack.`);
315
389
  }
316
- return readPackFile(path, id);
390
+
391
+ const mtimeMs = statSync(path).mtimeMs;
392
+ const cacheKey = packCacheKey(cwd, path, mtimeMs);
393
+ const cached = packCache.get(cacheKey);
394
+ if (cached) return cached;
395
+
396
+ const pack = readPackFile(path, id);
397
+ compilePackRegexes(pack);
398
+ packCache.set(cacheKey, pack);
399
+ return pack;
317
400
  }
318
401
 
319
402
  /**
@@ -327,6 +410,24 @@ export function listBuiltinPackIds() {
327
410
  .map((f) => f.replace(/\.json$/, ''));
328
411
  }
329
412
 
413
+ /**
414
+ * Read the sidecar record of packs installed by `flecto policies add`. The
415
+ * record is provenance only: pack resolution never consults it.
416
+ * @param {string} cwd
417
+ * @returns {Record<string, { package: string, version: string | null, addedAt: string }>}
418
+ */
419
+ function readPackManifest(cwd) {
420
+ const manifestPath = resolve(cwd, 'policies', PACK_MANIFEST_FILE);
421
+ if (!existsSync(manifestPath)) return {};
422
+ try {
423
+ const parsed = JSON.parse(readFileSync(manifestPath, 'utf8'));
424
+ return isObject(parsed?.packs) ? parsed.packs : {};
425
+ } catch {
426
+ // Provenance is a nicety; a corrupt sidecar must never break listing.
427
+ return {};
428
+ }
429
+ }
430
+
330
431
  /**
331
432
  * List every policy pack resolvable from a working directory. Local packs take
332
433
  * precedence over built-ins using the same order as loadPack().
@@ -336,16 +437,19 @@ export function listBuiltinPackIds() {
336
437
  * sourcePath: string,
337
438
  * source: 'builtin' | 'local',
338
439
  * ruleCount: number,
339
- * overridesBuiltin: boolean
440
+ * overridesBuiltin: boolean,
441
+ * package?: string
340
442
  * }>}
341
443
  */
342
444
  export function listPolicyPacks(cwd = process.cwd()) {
343
445
  const localDir = resolve(cwd, 'policies');
344
446
  const localIds = existsSync(localDir)
345
447
  ? readdirSync(localDir)
346
- .filter((file) => /\.(json|yaml|yml)$/.test(file))
448
+ // Hidden files are never pack ids — this is where the sidecar lives.
449
+ .filter((file) => !file.startsWith('.') && /\.(json|yaml|yml)$/.test(file))
347
450
  .map((file) => file.replace(/\.(json|yaml|yml)$/, ''))
348
451
  : [];
452
+ const manifest = readPackManifest(cwd);
349
453
  const builtinIds = listBuiltinPackIds();
350
454
  const builtinIdSet = new Set(builtinIds);
351
455
 
@@ -358,16 +462,298 @@ export function listPolicyPacks(cwd = process.cwd()) {
358
462
  }
359
463
  const pack = readPackFile(sourcePath, id);
360
464
  const isLocal = localIds.includes(id);
465
+ const packageName = isLocal && typeof manifest[id]?.package === 'string'
466
+ ? manifest[id].package
467
+ : null;
361
468
  return {
362
469
  id,
363
470
  sourcePath,
364
471
  source: isLocal ? 'local' : 'builtin',
365
472
  ruleCount: pack.rules.length,
366
473
  overridesBuiltin: isLocal && builtinIdSet.has(id),
474
+ // Present only for packs installed from npm, so hand-written local
475
+ // packs keep the exact shape they have always had.
476
+ ...(packageName ? { package: packageName } : {}),
367
477
  };
368
478
  });
369
479
  }
370
480
 
481
+ /**
482
+ * Map either form of a pack name onto the other: the short pack id
483
+ * (`deployment-safety`) and the npm package name
484
+ * (`flecto-pack-deployment-safety`, optionally scoped).
485
+ * @param {string} name
486
+ * @returns {{ id: string, packageName: string }}
487
+ */
488
+ export function normalizePackPackageName(name) {
489
+ const raw = String(name ?? '').trim();
490
+ if (!raw) throw new Error('Policy pack name is required');
491
+
492
+ const scopeMatch = /^(@[^/]+\/)(.+)$/.exec(raw);
493
+ const scope = scopeMatch ? scopeMatch[1] : '';
494
+ const rest = scopeMatch ? scopeMatch[2] : raw;
495
+ const id = rest.startsWith(PACK_PACKAGE_PREFIX) ? rest.slice(PACK_PACKAGE_PREFIX.length) : rest;
496
+
497
+ // The id becomes a filename under policies/, so it must be a plain segment.
498
+ if (!PACK_ID_PATTERN.test(id)) {
499
+ throw new Error(
500
+ `Invalid policy pack name "${raw}". Use a pack id such as "deployment-safety" or a package name such as "flecto-pack-deployment-safety".`,
501
+ );
502
+ }
503
+ return { id, packageName: `${scope}${PACK_PACKAGE_PREFIX}${id}` };
504
+ }
505
+
506
+ /**
507
+ * Locate an installed pack package without loading any of its code.
508
+ * @param {string} packageName
509
+ * @param {string} cwd
510
+ * @returns {string | null} Absolute package directory, or null when not installed.
511
+ */
512
+ function resolvePackPackageDir(packageName, cwd) {
513
+ // resolve() only computes a path; it never evaluates the target.
514
+ const require = createRequire(join(resolve(cwd), 'package.json'));
515
+ try {
516
+ return dirname(require.resolve(`${packageName}/package.json`));
517
+ } catch {
518
+ // A package with an "exports" map that omits ./package.json still has one
519
+ // on disk, so fall back to the plain node_modules lookup.
520
+ }
521
+
522
+ let dir = resolve(cwd);
523
+ for (;;) {
524
+ const candidate = join(dir, 'node_modules', ...packageName.split('/'), 'package.json');
525
+ if (existsSync(candidate)) return dirname(candidate);
526
+ const parent = dirname(dir);
527
+ if (parent === dir) return null;
528
+ dir = parent;
529
+ }
530
+ }
531
+
532
+ /**
533
+ * @param {string} packageDir
534
+ * @param {string} packageName
535
+ * @returns {Record<string, unknown>}
536
+ */
537
+ function readPackagePackageJson(packageDir, packageName) {
538
+ try {
539
+ const parsed = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8'));
540
+ return isObject(parsed) ? parsed : {};
541
+ } catch (error) {
542
+ throw new Error(`Could not read package.json for "${packageName}": ${error.message}`);
543
+ }
544
+ }
545
+
546
+ /**
547
+ * Find the declarative pack file inside a pack package: a `flecto-pack.*` file
548
+ * at the package root, or the path named by its package.json "flecto" field.
549
+ * @param {string} packageDir
550
+ * @param {Record<string, unknown>} packageJson
551
+ * @param {string} packageName
552
+ * @returns {string}
553
+ */
554
+ function resolvePackFileInPackage(packageDir, packageJson, packageName) {
555
+ const declared = packageJson.flecto;
556
+ /** @type {string | null} */
557
+ let relPath = null;
558
+ if (typeof declared === 'string') {
559
+ relPath = declared;
560
+ } else if (isObject(declared)) {
561
+ if (typeof declared.pack !== 'string' || !declared.pack.trim()) {
562
+ throw new Error(
563
+ `Invalid "flecto" field in ${packageName}: expected { "pack": "<path to pack file>" }.`,
564
+ );
565
+ }
566
+ relPath = declared.pack;
567
+ } else if (declared !== undefined) {
568
+ throw new Error(
569
+ `Invalid "flecto" field in ${packageName}: expected a pack file path or { "pack": "<path>" }.`,
570
+ );
571
+ }
572
+
573
+ if (relPath) {
574
+ const abs = resolve(packageDir, relPath);
575
+ const inside = relative(packageDir, abs);
576
+ if (isAbsolute(relPath) || inside.startsWith('..') || isAbsolute(inside)) {
577
+ throw new Error(`Invalid "flecto" field in ${packageName}: "${relPath}" escapes the package directory.`);
578
+ }
579
+ if (!PACK_EXTENSIONS.some((ext) => abs.toLowerCase().endsWith(ext))) {
580
+ throw new Error(
581
+ `Invalid "flecto" field in ${packageName}: "${relPath}" must be a .json, .yaml, or .yml pack file. Flecto never loads JavaScript from a pack package.`,
582
+ );
583
+ }
584
+ if (!existsSync(abs)) {
585
+ throw new Error(`Pack file missing in ${packageName}: "${relPath}" does not exist.`);
586
+ }
587
+ return abs;
588
+ }
589
+
590
+ for (const candidate of PACK_FILE_CANDIDATES) {
591
+ const abs = join(packageDir, candidate);
592
+ if (existsSync(abs)) return abs;
593
+ }
594
+ throw new Error(
595
+ `"${packageName}" is not a Flecto policy pack: expected ${PACK_FILE_CANDIDATES.join(', ')} at the package root, or a "flecto" field in its package.json.`,
596
+ );
597
+ }
598
+
599
+ /**
600
+ * @param {string} packageDir
601
+ * @param {Record<string, unknown>} packageJson
602
+ * @returns {boolean}
603
+ */
604
+ function packageShipsCode(packageDir, packageJson) {
605
+ if (packageJson.main || packageJson.exports || packageJson.bin) return true;
606
+ try {
607
+ return readdirSync(packageDir).some((file) => /\.(c|m)?js$/.test(file));
608
+ } catch {
609
+ return false;
610
+ }
611
+ }
612
+
613
+ /**
614
+ * @param {string} cwd
615
+ * @param {string} id
616
+ * @returns {string[]} Existing local pack files for this id, in resolution order.
617
+ */
618
+ function localPackFilesForId(cwd, id) {
619
+ return PACK_EXTENSIONS
620
+ .map((ext) => resolve(cwd, 'policies', `${id}${ext}`))
621
+ .filter((path) => existsSync(path));
622
+ }
623
+
624
+ /**
625
+ * @param {string} cwd
626
+ * @param {string} id
627
+ * @param {{ package: string, version: string | null }} entry
628
+ */
629
+ function writePackManifestEntry(cwd, id, entry) {
630
+ const manifestPath = resolve(cwd, 'policies', PACK_MANIFEST_FILE);
631
+ const packs = readPackManifest(cwd);
632
+ packs[id] = { ...entry, addedAt: new Date().toISOString() };
633
+ writeFileSync(manifestPath, `${JSON.stringify({ packs }, null, 2)}\n`, 'utf8');
634
+ }
635
+
636
+ /**
637
+ * Install a policy pack from an already-installed `flecto-pack-*` npm package
638
+ * into `policies/<id>.json`, so the normal resolution order picks it up.
639
+ *
640
+ * Only declarative JSON/YAML is read — no package code is imported or run.
641
+ * @param {string} name Pack id or npm package name.
642
+ * @param {{ cwd?: string, force?: boolean }} [options]
643
+ * @returns {AddedPolicyPack}
644
+ */
645
+ export function addPolicyPackFromPackage(name, options = {}) {
646
+ const cwd = options.cwd ?? process.cwd();
647
+ const force = Boolean(options.force);
648
+ const { id, packageName } = normalizePackPackageName(name);
649
+
650
+ const packageDir = resolvePackPackageDir(packageName, cwd);
651
+ if (!packageDir) {
652
+ throw new Error(
653
+ `Policy pack package "${packageName}" is not installed. Install it first:\n\n npm install --save-dev ${packageName}\n`,
654
+ );
655
+ }
656
+
657
+ const packageJson = readPackagePackageJson(packageDir, packageName);
658
+ const packFile = resolvePackFileInPackage(packageDir, packageJson, packageName);
659
+ // Full pack validation happens here, before anything is written: a malformed
660
+ // third-party pack fails at add time rather than during evaluation.
661
+ const pack = readPackFile(packFile, id);
662
+ if (pack.id !== id) {
663
+ throw new Error(
664
+ `Policy pack id mismatch: ${packageName} declares id "${pack.id}", but the package name implies "${id}". Rename the pack id or publish under "${PACK_PACKAGE_PREFIX}${pack.id}".`,
665
+ );
666
+ }
667
+
668
+ const existingLocal = localPackFilesForId(cwd, id);
669
+ if (existingLocal.length > 0 && !force) {
670
+ throw new Error(
671
+ `A local policy pack "${id}" already exists: ${existingLocal.join(', ')}. Re-run with --force to overwrite it.`,
672
+ );
673
+ }
674
+
675
+ const targetPath = resolve(cwd, 'policies', `${id}.json`);
676
+ mkdirSync(dirname(targetPath), { recursive: true });
677
+ // Only written when the source pack opts in, so packs that never set it keep
678
+ // byte-identical output.
679
+ const payload = pack.expandSubtrees
680
+ ? { id, expandSubtrees: true, rules: pack.rules }
681
+ : { id, rules: pack.rules };
682
+ writeFileSync(targetPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
683
+ writePackManifestEntry(cwd, id, {
684
+ package: packageName,
685
+ version: typeof packageJson.version === 'string' ? packageJson.version : null,
686
+ });
687
+
688
+ return {
689
+ id,
690
+ packageName,
691
+ packageVersion: typeof packageJson.version === 'string' ? packageJson.version : null,
692
+ packFile,
693
+ targetPath,
694
+ ruleCount: pack.rules.length,
695
+ overwritten: existingLocal.includes(targetPath),
696
+ overridesBuiltin: existsSync(join(PACKS_DIR, `${id}.json`)),
697
+ shadowed: existingLocal.filter((path) => path !== targetPath),
698
+ shipsCode: packageShipsCode(packageDir, packageJson),
699
+ };
700
+ }
701
+
702
+ /**
703
+ * Compile each rule's `match.path` and `afterMatches` regular expressions
704
+ * once, at pack-load time, storing them as non-enumerable properties on the
705
+ * loaded rule/clause objects. matchClause() runs per change event — for a
706
+ * rule with a path match, once per event per rule — so compiling here instead
707
+ * of inside that loop turns a `new RegExp(...)` call into a property read.
708
+ *
709
+ * validatePack() has already proven, by this point, that every `match.path`
710
+ * and `afterMatches` string constructs a valid RegExp, so construction here
711
+ * cannot throw a new error the caller hasn't already seen.
712
+ * @param {PolicyPack} pack
713
+ */
714
+ function compilePackRegexes(pack) {
715
+ for (const rule of pack.rules ?? []) {
716
+ compileClauseRegexes(rule);
717
+ for (const clause of rule.allOf ?? []) compileClauseRegexes(clause);
718
+ for (const clause of rule.anyOf ?? []) compileClauseRegexes(clause);
719
+ }
720
+ }
721
+
722
+ /** @param {PolicyRule | PolicyMatchClause} clause */
723
+ function compileClauseRegexes(clause) {
724
+ if (clause.match?.path !== undefined) {
725
+ Object.defineProperty(clause.match, '_pathRegex', {
726
+ value: new RegExp(clause.match.path, clause.match.pathFlags ?? ''),
727
+ enumerable: false,
728
+ });
729
+ }
730
+ if (clause.afterMatches !== undefined) {
731
+ Object.defineProperty(clause, '_afterMatchesRegex', {
732
+ value: new RegExp(clause.afterMatches),
733
+ enumerable: false,
734
+ });
735
+ }
736
+ }
737
+
738
+ /**
739
+ * @param {{ path?: string, pathFlags?: string, _pathRegex?: RegExp }} match
740
+ * @returns {RegExp}
741
+ */
742
+ function pathRegexFor(match) {
743
+ // Packs loaded via loadPack() always carry a pre-compiled regex here; the
744
+ // fallback exists only so a pack built some other way (bypassing loadPack)
745
+ // still behaves exactly as it did before regex compilation was hoisted.
746
+ return match._pathRegex ?? new RegExp(match.path, match.pathFlags ?? '');
747
+ }
748
+
749
+ /**
750
+ * @param {{ afterMatches?: string, _afterMatchesRegex?: RegExp }} clause
751
+ * @returns {RegExp}
752
+ */
753
+ function afterMatchesRegexFor(clause) {
754
+ return clause._afterMatchesRegex ?? new RegExp(clause.afterMatches);
755
+ }
756
+
371
757
  /**
372
758
  * @param {PolicyRule} rule
373
759
  * @param {import('./differ.js').ChangeEvent} change
@@ -390,7 +776,7 @@ function ruleMatches(rule, change) {
390
776
  function matchClause(clause, change) {
391
777
  const path = change.path ?? '';
392
778
  const match = clause.match;
393
- if (match?.path && !new RegExp(match.path, match.pathFlags ?? '').test(path)) return false;
779
+ if (match?.path && !pathRegexFor(match).test(path)) return false;
394
780
  if (match?.pathEquals !== undefined && path !== match.pathEquals) return false;
395
781
  if (match?.pathPrefix !== undefined && !path.startsWith(match.pathPrefix)) return false;
396
782
 
@@ -400,7 +786,11 @@ function matchClause(clause, change) {
400
786
  if (clause.afterIn && !clause.afterIn.includes(change.after)) return false;
401
787
  if (clause.beforeTruthy && !isTruthyToggle(change.before)) return false;
402
788
  if (clause.afterTruthy && !isTruthyToggle(change.after)) return false;
403
- if (clause.afterMatches && (typeof change.after !== 'string' || !new RegExp(clause.afterMatches).test(change.after))) return false;
789
+ // Value-shaped secret detection, shared with the masking path so a rule and
790
+ // the redaction it triggers never disagree.
791
+ if (clause.beforeLooksSecret && !containsSecret(change.before)) return false;
792
+ if (clause.afterLooksSecret && !containsSecret(change.after)) return false;
793
+ if (clause.afterMatches && (typeof change.after !== 'string' || !afterMatchesRegexFor(clause).test(change.after))) return false;
404
794
 
405
795
  if (clause.numericJump) {
406
796
  const before = change.before;
@@ -434,6 +824,100 @@ function formatMessage(rule, change) {
434
824
  return rule.message ?? `Policy ${rule.id} matched`;
435
825
  }
436
826
 
827
+ /**
828
+ * Bracket segment for one array element, mirroring the differ so a synthesized
829
+ * path is spelled the same way the differ would have spelled it: a quoted
830
+ * identity key when every element carries a unique `id` (then `name`), an index
831
+ * otherwise.
832
+ * @param {unknown[]} items
833
+ * @returns {string[]}
834
+ */
835
+ function subtreeArraySegments(items) {
836
+ for (const idKey of ['id', 'name']) {
837
+ /** @type {string[]} */
838
+ const keys = [];
839
+ let usable = items.length > 0;
840
+ for (const item of items) {
841
+ if (!isObject(item) || !Object.hasOwn(item, idKey)) { usable = false; break; }
842
+ const value = item[idKey];
843
+ if (value == null || typeof value === 'object') { usable = false; break; }
844
+ keys.push(JSON.stringify(String(value)));
845
+ }
846
+ if (usable && new Set(keys).size === keys.length) return keys;
847
+ }
848
+ return items.map((_, index) => String(index));
849
+ }
850
+
851
+ /**
852
+ * Walk a value, collecting every scalar leaf with its full path.
853
+ *
854
+ * `ancestors` holds the containers on the path currently being walked. A
855
+ * recursive YAML anchor (`a: &x\n b: *x`) parses to a genuinely cyclic object,
856
+ * and the differ never recurses into an added or removed subtree, so this walk
857
+ * is the first thing to enter one. Revisiting an ancestor stops the descent;
858
+ * a value merely referenced twice on separate branches is still walked twice.
859
+ * @param {unknown} value
860
+ * @param {string} basePath
861
+ * @param {Array<{ path: string, value: unknown }>} out
862
+ * @param {Set<object>} ancestors
863
+ */
864
+ function collectLeaves(value, basePath, out, ancestors) {
865
+ const isContainer = Array.isArray(value) || isObject(value);
866
+ if (isContainer) {
867
+ if (ancestors.has(/** @type {object} */ (value))) return;
868
+ ancestors.add(/** @type {object} */ (value));
869
+ }
870
+
871
+ if (Array.isArray(value)) {
872
+ const segments = subtreeArraySegments(value);
873
+ value.forEach((item, index) => collectLeaves(item, `${basePath}[${segments[index]}]`, out, ancestors));
874
+ } else if (isObject(value)) {
875
+ for (const key of Object.keys(value)) {
876
+ collectLeaves(value[key], basePath ? `${basePath}.${key}` : key, out, ancestors);
877
+ }
878
+ } else {
879
+ out.push({ path: basePath, value });
880
+ }
881
+
882
+ if (isContainer) ancestors.delete(/** @type {object} */ (value));
883
+ }
884
+
885
+ /**
886
+ * Expand whole-subtree additions and removals into the leaf-level changes they
887
+ * imply, keeping the original event alongside them.
888
+ *
889
+ * The differ reports an added or removed key once, carrying the entire subtree
890
+ * as the value — adding a Kubernetes `Service` document, or a container's whole
891
+ * `securityContext` block, is a single change at the parent path. Path-anchored
892
+ * rules cannot see inside such a value, so a pack that must reason about leaves
893
+ * (`…securityContext.privileged`) would silently miss the exact case that
894
+ * matters most. Expanding restores one uniform shape: a rule anchored at the
895
+ * leaf fires whether the leaf changed in place or arrived with its parent.
896
+ *
897
+ * Subtrees never overlap, so the total work is linear in the size of the input.
898
+ * @param {import('./differ.js').ChangeEvent[]} changes
899
+ * @returns {import('./differ.js').ChangeEvent[]}
900
+ */
901
+ export function expandChangeSubtrees(changes) {
902
+ /** @type {import('./differ.js').ChangeEvent[]} */
903
+ const expanded = [];
904
+ for (const change of changes) {
905
+ expanded.push(change);
906
+ if (change.type !== 'added' && change.type !== 'removed') continue;
907
+ const side = change.type === 'added' ? 'after' : 'before';
908
+ const value = change[side];
909
+ if (!isObject(value) && !Array.isArray(value)) continue;
910
+
911
+ /** @type {Array<{ path: string, value: unknown }>} */
912
+ const leaves = [];
913
+ collectLeaves(value, change.path ?? '', leaves, new Set());
914
+ for (const leaf of leaves) {
915
+ expanded.push({ type: change.type, path: leaf.path, [side]: leaf.value });
916
+ }
917
+ }
918
+ return expanded;
919
+ }
920
+
437
921
  /**
438
922
  * @param {PolicyPack} pack
439
923
  * @param {import('./differ.js').ChangeEvent[]} changes
@@ -443,7 +927,10 @@ function formatMessage(rule, change) {
443
927
  export function evaluatePack(pack, changes, severityRemap = {}) {
444
928
  /** @type {PolicyFinding[]} */
445
929
  const findings = [];
446
- for (const change of changes) {
930
+ // Opt-in per pack, so every existing pack sees exactly the events it always
931
+ // saw and only packs written against leaf paths pay for the expansion.
932
+ const effectiveChanges = pack.expandSubtrees ? expandChangeSubtrees(changes) : changes;
933
+ for (const change of effectiveChanges) {
447
934
  for (const rule of pack.rules ?? []) {
448
935
  if (!ruleMatches(rule, change)) continue;
449
936
  const severity = severityRemap[String(rule.id)] ?? rule.severity ?? 'warn';