flecto 2.0.0 → 3.0.0

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
@@ -17,14 +19,53 @@ import yaml from 'js-yaml';
17
19
  * id: string,
18
20
  * severity: PolicySeverity,
19
21
  * when?: Array<'added' | 'removed' | 'changed'>,
20
- * match?: { path?: string, pathFlags?: string },
22
+ * match?: { path?: string, pathFlags?: string, pathEquals?: string, pathPrefix?: string },
23
+ * beforeEquals?: unknown,
21
24
  * afterEquals?: unknown,
25
+ * beforeIn?: unknown[],
26
+ * afterIn?: unknown[],
27
+ * beforeTruthy?: true,
28
+ * afterTruthy?: true,
29
+ * beforeLooksSecret?: true,
30
+ * afterLooksSecret?: true,
31
+ * afterMatches?: string,
22
32
  * numericJump?: { minMultiple: number },
33
+ * numericDelta?: { min: number },
34
+ * allOf?: PolicyMatchClause[],
35
+ * anyOf?: PolicyMatchClause[],
23
36
  * message?: string,
24
37
  * messageTemplate?: string
25
38
  * }} PolicyRule
26
39
  *
27
- * @typedef {{ id: string, rules: PolicyRule[] }} PolicyPack
40
+ * @typedef {{
41
+ * match?: { path?: string, pathFlags?: string, pathEquals?: string, pathPrefix?: string },
42
+ * beforeEquals?: unknown,
43
+ * afterEquals?: unknown,
44
+ * beforeIn?: unknown[],
45
+ * afterIn?: unknown[],
46
+ * beforeTruthy?: true,
47
+ * afterTruthy?: true,
48
+ * beforeLooksSecret?: true,
49
+ * afterLooksSecret?: true,
50
+ * afterMatches?: string,
51
+ * numericJump?: { minMultiple: number },
52
+ * numericDelta?: { min: number }
53
+ * }} PolicyMatchClause
54
+ *
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
28
69
  *
29
70
  * @typedef {{
30
71
  * cwd?: string,
@@ -32,12 +73,97 @@ import yaml from 'js-yaml';
32
73
  * profile?: string | null,
33
74
  * source?: 'watch' | 'ci' | 'diff',
34
75
  * policies?: string[],
35
- * plugins?: string[]
76
+ * plugins?: string[],
77
+ * severityRemap?: Record<string, PolicySeverity | 'off'>
36
78
  * }} PolicyEvalOptions
37
79
  */
38
80
 
39
81
  const SEVERITY_RANK = { info: 1, warn: 2, error: 3 };
40
82
  const PACKS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'packs');
83
+ const CHANGE_TYPES = new Set(['added', 'removed', 'changed']);
84
+ const RULE_FIELDS = new Set([
85
+ 'id', 'severity', 'when', 'match', 'beforeEquals', 'afterEquals',
86
+ 'beforeIn', 'afterIn', 'beforeTruthy', 'afterTruthy', 'numericJump',
87
+ 'beforeLooksSecret', 'afterLooksSecret',
88
+ 'afterMatches', 'numericDelta', 'allOf', 'anyOf', 'message', 'messageTemplate',
89
+ ]);
90
+ const CLAUSE_FIELDS = new Set([
91
+ 'match', 'beforeEquals', 'afterEquals', 'beforeIn', 'afterIn',
92
+ 'beforeTruthy', 'afterTruthy', 'beforeLooksSecret', 'afterLooksSecret',
93
+ 'afterMatches', 'numericJump', 'numericDelta',
94
+ ]);
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;
103
+
104
+ /**
105
+ * @param {string} path
106
+ * @param {string} message
107
+ * @returns {never}
108
+ */
109
+ function invalidPack(path, message) {
110
+ throw new Error(`Invalid policy pack at ${path}: ${message}`);
111
+ }
112
+
113
+ /**
114
+ * @param {unknown} value
115
+ * @returns {value is Record<string, unknown>}
116
+ */
117
+ function isObject(value) {
118
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
119
+ }
120
+
121
+ /**
122
+ * @param {unknown} value
123
+ * @returns {boolean}
124
+ */
125
+ function isTruthyToggle(value) {
126
+ if (value === true) return true;
127
+ if (typeof value !== 'string') return false;
128
+ return ['true', '1', 'yes'].includes(value.trim().toLowerCase());
129
+ }
130
+
131
+ /**
132
+ * Validate a parsed policy pack and reject typos before evaluation.
133
+ * @param {unknown} pack
134
+ * @param {string} path
135
+ * @returns {asserts pack is PolicyPack}
136
+ */
137
+ function validatePack(pack, path) {
138
+ if (!isObject(pack)) invalidPack(path, 'pack must be an object');
139
+
140
+ const packFields = new Set(['id', 'expandSubtrees', 'rules']);
141
+ for (const field of Object.keys(pack)) {
142
+ if (!packFields.has(field)) invalidPack(path, `pack.${field} is not allowed`);
143
+ }
144
+ if (Object.hasOwn(pack, 'id') && (typeof pack.id !== 'string' || !pack.id.trim())) {
145
+ invalidPack(path, 'pack.id must be a non-empty string');
146
+ }
147
+ if (Object.hasOwn(pack, 'expandSubtrees') && typeof pack.expandSubtrees !== 'boolean') {
148
+ invalidPack(path, 'pack.expandSubtrees must be a boolean');
149
+ }
150
+ if (!Array.isArray(pack.rules)) invalidPack(path, 'pack.rules must be an array');
151
+
152
+ for (const [index, rule] of pack.rules.entries()) {
153
+ try {
154
+ validateRule(rule, `rules[${index}]`);
155
+ } catch (error) {
156
+ const label = isObject(rule) && typeof rule.id === 'string' && rule.id
157
+ ? `rule "${rule.id}"`
158
+ : `rules[${index}]`;
159
+ const message = error.message.replace(/^Invalid policy rule at [^:]+: /, '')
160
+ .replace(/^unknown field "([^"]+)"$/, '$1 is not allowed (unknown field "$1")')
161
+ .replace(/^unknown match field "([^"]+)"$/, 'match.$1 is not allowed (unknown match field "$1")')
162
+ .replace(/^match\.path is not a valid regular expression$/, 'match.path must be a valid regular expression');
163
+ invalidPack(path, `${label}.${message}`);
164
+ }
165
+ }
166
+ }
41
167
 
42
168
  /**
43
169
  * @param {string} cwd
@@ -59,22 +185,197 @@ function resolvePackPath(cwd, packId) {
59
185
 
60
186
  /**
61
187
  * @param {string} path
188
+ * @param {string} fallbackId
62
189
  * @returns {PolicyPack}
63
190
  */
64
- function readPackFile(path) {
191
+ function readPackFile(path, fallbackId) {
65
192
  const raw = readFileSync(path, 'utf8');
66
- const parsed = path.endsWith('.json') ? JSON.parse(raw) : yaml.load(raw);
67
- if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.rules)) {
68
- throw new Error(`Invalid policy pack at ${path}: expected { id, rules[] }`);
193
+ let parsed;
194
+ try {
195
+ parsed = path.endsWith('.json') ? JSON.parse(raw) : yaml.load(raw);
196
+ } catch (error) {
197
+ invalidPack(path, `could not parse file (${error.message})`);
69
198
  }
70
- return {
71
- id: String(parsed.id ?? ''),
72
- rules: parsed.rules,
73
- };
199
+ validatePack(parsed, path);
200
+ return { ...parsed, id: parsed.id ?? fallbackId };
201
+ }
202
+
203
+ /**
204
+ * Validate a rule or composition clause so pack typos fail closed at load time.
205
+ * @param {unknown} candidate
206
+ * @param {string} location
207
+ * @param {boolean} [isClause]
208
+ */
209
+ function validateRule(candidate, location, isClause = false) {
210
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
211
+ throw new Error(`Invalid policy rule at ${location}: expected an object`);
212
+ }
213
+
214
+ const allowedFields = isClause ? CLAUSE_FIELDS : RULE_FIELDS;
215
+ for (const key of Object.keys(candidate)) {
216
+ if (!allowedFields.has(key)) {
217
+ throw new Error(`Invalid policy rule at ${location}: unknown field "${key}"`);
218
+ }
219
+ }
220
+
221
+ const rule = /** @type {Record<string, unknown>} */ (candidate);
222
+ if (!isClause && (typeof rule.id !== 'string' || !rule.id)) {
223
+ throw new Error(`Invalid policy rule at ${location}: id is required`);
224
+ }
225
+ if (!isClause && (!Object.hasOwn(rule, 'severity') || !Object.hasOwn(SEVERITY_RANK, rule.severity))) {
226
+ throw new Error(`Invalid policy rule at ${location}: severity must be one of: info, warn, error`);
227
+ }
228
+ if (rule.when !== undefined
229
+ && (!Array.isArray(rule.when) || rule.when.length === 0 || rule.when.some((type) => !CHANGE_TYPES.has(type)))) {
230
+ const invalidIndex = Array.isArray(rule.when)
231
+ ? rule.when.findIndex((type) => !CHANGE_TYPES.has(type))
232
+ : -1;
233
+ throw new Error(`Invalid policy rule at ${location}: when${invalidIndex >= 0 ? `[${invalidIndex}]` : ''} must be one of: added, removed, changed`);
234
+ }
235
+ validateMatch(rule.match, location);
236
+ validateArrayPredicate(rule.beforeIn, 'beforeIn', location);
237
+ validateArrayPredicate(rule.afterIn, 'afterIn', location);
238
+ validateTruthyPredicate(rule.beforeTruthy, 'beforeTruthy', location);
239
+ validateTruthyPredicate(rule.afterTruthy, 'afterTruthy', location);
240
+ validateTruthyPredicate(rule.beforeLooksSecret, 'beforeLooksSecret', location);
241
+ validateTruthyPredicate(rule.afterLooksSecret, 'afterLooksSecret', location);
242
+ validateRegexPredicate(rule.afterMatches, 'afterMatches', location);
243
+ validateNumericPredicate(rule.numericJump, 'numericJump', 'minMultiple', location, true);
244
+ validateNumericPredicate(rule.numericDelta, 'numericDelta', 'min', location, false);
245
+ for (const name of ['message', 'messageTemplate']) {
246
+ if (rule[name] !== undefined && typeof rule[name] !== 'string') {
247
+ throw new Error(`Invalid policy rule at ${location}: ${name} must be a string`);
248
+ }
249
+ }
250
+
251
+ if (!isClause) {
252
+ validateComposition(rule.allOf, 'allOf', location);
253
+ validateComposition(rule.anyOf, 'anyOf', location);
254
+ }
255
+ }
256
+
257
+ /** @param {unknown} match @param {string} location */
258
+ function validateMatch(match, location) {
259
+ if (match === undefined) return;
260
+ if (!match || typeof match !== 'object' || Array.isArray(match)) {
261
+ throw new Error(`Invalid policy rule at ${location}: match must be an object`);
262
+ }
263
+ for (const key of Object.keys(match)) {
264
+ if (!MATCH_FIELDS.has(key)) {
265
+ throw new Error(`Invalid policy rule at ${location}: unknown match field "${key}"`);
266
+ }
267
+ }
268
+ const typedMatch = /** @type {Record<string, unknown>} */ (match);
269
+ for (const key of MATCH_FIELDS) {
270
+ if (typedMatch[key] !== undefined && typeof typedMatch[key] !== 'string') {
271
+ throw new Error(`Invalid policy rule at ${location}: match.${key} must be a string`);
272
+ }
273
+ }
274
+ if (typedMatch.path !== undefined) {
275
+ try {
276
+ new RegExp(typedMatch.path, typedMatch.pathFlags ?? '');
277
+ } catch {
278
+ if (typedMatch.pathFlags !== undefined) {
279
+ try {
280
+ new RegExp('(?:)', typedMatch.pathFlags);
281
+ } catch {
282
+ throw new Error(`Invalid policy rule at ${location}: match.pathFlags must be valid regular expression flags`);
283
+ }
284
+ }
285
+ throw new Error(`Invalid policy rule at ${location}: match.path is not a valid regular expression`);
286
+ }
287
+ }
288
+ }
289
+
290
+ /** @param {unknown} value @param {string} name @param {string} location */
291
+ function validateArrayPredicate(value, name, location) {
292
+ if (value !== undefined && !Array.isArray(value)) {
293
+ throw new Error(`Invalid policy rule at ${location}: ${name} must be an array`);
294
+ }
295
+ }
296
+
297
+ /** @param {unknown} value @param {string} name @param {string} location */
298
+ function validateTruthyPredicate(value, name, location) {
299
+ if (value !== undefined && value !== true) {
300
+ throw new Error(`Invalid policy rule at ${location}: ${name} must be true`);
301
+ }
302
+ }
303
+
304
+ /** @param {unknown} value @param {string} name @param {string} location */
305
+ function validateRegexPredicate(value, name, location) {
306
+ if (value === undefined) return;
307
+ if (typeof value !== 'string') {
308
+ throw new Error(`Invalid policy rule at ${location}: ${name} must be a string`);
309
+ }
310
+ try {
311
+ new RegExp(value);
312
+ } catch {
313
+ throw new Error(`Invalid policy rule at ${location}: ${name} is not a valid regular expression`);
314
+ }
315
+ }
316
+
317
+ /** @param {unknown} value @param {string} name @param {string} property @param {string} location @param {boolean} positive */
318
+ function validateNumericPredicate(value, name, property, location, positive) {
319
+ if (value === undefined) return;
320
+ if (!value || typeof value !== 'object' || Array.isArray(value)
321
+ || typeof value[property] !== 'number' || !Number.isFinite(value[property])
322
+ || (positive ? value[property] <= 0 : value[property] < 0)) {
323
+ throw new Error(`Invalid policy rule at ${location}: ${name}.${property} must be a ${positive ? 'positive' : 'non-negative'} finite number`);
324
+ }
325
+ }
326
+
327
+ /** @param {unknown} clauses @param {string} name @param {string} location */
328
+ function validateComposition(clauses, name, location) {
329
+ if (clauses === undefined) return;
330
+ if (!Array.isArray(clauses) || clauses.length === 0) {
331
+ throw new Error(`Invalid policy rule at ${location}: ${name} must be a non-empty array of match clauses`);
332
+ }
333
+ clauses.forEach((clause, index) => validateRule(clause, `${location}.${name}[${index}]`, true));
334
+ }
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}`;
74
369
  }
75
370
 
76
371
  /**
77
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.
78
379
  * @param {string} packId
79
380
  * @param {string} [cwd]
80
381
  * @returns {PolicyPack}
@@ -86,8 +387,15 @@ export function loadPack(packId, cwd = process.cwd()) {
86
387
  if (!path) {
87
388
  throw new Error(`Unknown policy pack "${id}". Add policies/${id}.json or use a built-in pack.`);
88
389
  }
89
- const pack = readPackFile(path);
90
- if (!pack.id) pack.id = 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);
91
399
  return pack;
92
400
  }
93
401
 
@@ -102,6 +410,350 @@ export function listBuiltinPackIds() {
102
410
  .map((f) => f.replace(/\.json$/, ''));
103
411
  }
104
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
+
431
+ /**
432
+ * List every policy pack resolvable from a working directory. Local packs take
433
+ * precedence over built-ins using the same order as loadPack().
434
+ * @param {string} [cwd]
435
+ * @returns {Array<{
436
+ * id: string,
437
+ * sourcePath: string,
438
+ * source: 'builtin' | 'local',
439
+ * ruleCount: number,
440
+ * overridesBuiltin: boolean,
441
+ * package?: string
442
+ * }>}
443
+ */
444
+ export function listPolicyPacks(cwd = process.cwd()) {
445
+ const localDir = resolve(cwd, 'policies');
446
+ const localIds = existsSync(localDir)
447
+ ? readdirSync(localDir)
448
+ // Hidden files are never pack ids — this is where the sidecar lives.
449
+ .filter((file) => !file.startsWith('.') && /\.(json|yaml|yml)$/.test(file))
450
+ .map((file) => file.replace(/\.(json|yaml|yml)$/, ''))
451
+ : [];
452
+ const manifest = readPackManifest(cwd);
453
+ const builtinIds = listBuiltinPackIds();
454
+ const builtinIdSet = new Set(builtinIds);
455
+
456
+ return [...new Set([...builtinIds, ...localIds])]
457
+ .sort()
458
+ .map((id) => {
459
+ const sourcePath = resolvePackPath(cwd, id);
460
+ if (!sourcePath) {
461
+ throw new Error(`Unable to resolve policy pack "${id}"`);
462
+ }
463
+ const pack = readPackFile(sourcePath, id);
464
+ const isLocal = localIds.includes(id);
465
+ const packageName = isLocal && typeof manifest[id]?.package === 'string'
466
+ ? manifest[id].package
467
+ : null;
468
+ return {
469
+ id,
470
+ sourcePath,
471
+ source: isLocal ? 'local' : 'builtin',
472
+ ruleCount: pack.rules.length,
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 } : {}),
477
+ };
478
+ });
479
+ }
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
+
105
757
  /**
106
758
  * @param {PolicyRule} rule
107
759
  * @param {import('./differ.js').ChangeEvent} change
@@ -110,22 +762,48 @@ export function listBuiltinPackIds() {
110
762
  function ruleMatches(rule, change) {
111
763
  const when = rule.when ?? ['added', 'removed', 'changed'];
112
764
  if (!when.includes(change.type)) return false;
765
+ if (!matchClause(rule, change)) return false;
766
+ if (rule.allOf?.some((clause) => !matchClause(clause, change))) return false;
767
+ if (rule.anyOf && !rule.anyOf.some((clause) => matchClause(clause, change))) return false;
768
+ return true;
769
+ }
113
770
 
114
- if (rule.match?.path) {
115
- const flags = rule.match.pathFlags ?? '';
116
- const re = new RegExp(rule.match.path, flags);
117
- if (!re.test(change.path ?? '')) return false;
118
- }
771
+ /**
772
+ * @param {PolicyMatchClause} clause
773
+ * @param {import('./differ.js').ChangeEvent} change
774
+ * @returns {boolean}
775
+ */
776
+ function matchClause(clause, change) {
777
+ const path = change.path ?? '';
778
+ const match = clause.match;
779
+ if (match?.path && !pathRegexFor(match).test(path)) return false;
780
+ if (match?.pathEquals !== undefined && path !== match.pathEquals) return false;
781
+ if (match?.pathPrefix !== undefined && !path.startsWith(match.pathPrefix)) return false;
119
782
 
120
- if (Object.prototype.hasOwnProperty.call(rule, 'afterEquals')) {
121
- if (change.after !== rule.afterEquals) return false;
783
+ if (Object.prototype.hasOwnProperty.call(clause, 'beforeEquals') && change.before !== clause.beforeEquals) return false;
784
+ if (Object.prototype.hasOwnProperty.call(clause, 'afterEquals') && change.after !== clause.afterEquals) return false;
785
+ if (clause.beforeIn && !clause.beforeIn.includes(change.before)) return false;
786
+ if (clause.afterIn && !clause.afterIn.includes(change.after)) return false;
787
+ if (clause.beforeTruthy && !isTruthyToggle(change.before)) return false;
788
+ if (clause.afterTruthy && !isTruthyToggle(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;
794
+
795
+ if (clause.numericJump) {
796
+ const before = change.before;
797
+ const after = change.after;
798
+ if (typeof before !== 'number' || typeof after !== 'number') return false;
799
+ if (!(before > 0 && after >= before * clause.numericJump.minMultiple)) return false;
122
800
  }
123
801
 
124
- if (rule.numericJump) {
802
+ if (clause.numericDelta) {
125
803
  const before = change.before;
126
804
  const after = change.after;
127
805
  if (typeof before !== 'number' || typeof after !== 'number') return false;
128
- if (!(before > 0 && after >= before * rule.numericJump.minMultiple)) return false;
806
+ if (Math.abs(after - before) < clause.numericDelta.min) return false;
129
807
  }
130
808
 
131
809
  return true;
@@ -146,20 +824,120 @@ function formatMessage(rule, change) {
146
824
  return rule.message ?? `Policy ${rule.id} matched`;
147
825
  }
148
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
+
149
921
  /**
150
922
  * @param {PolicyPack} pack
151
923
  * @param {import('./differ.js').ChangeEvent[]} changes
924
+ * @param {Record<string, PolicySeverity | 'off'>} [severityRemap]
152
925
  * @returns {PolicyFinding[]}
153
926
  */
154
- export function evaluatePack(pack, changes) {
927
+ export function evaluatePack(pack, changes, severityRemap = {}) {
155
928
  /** @type {PolicyFinding[]} */
156
929
  const findings = [];
157
- 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) {
158
934
  for (const rule of pack.rules ?? []) {
159
935
  if (!ruleMatches(rule, change)) continue;
936
+ const severity = severityRemap[String(rule.id)] ?? rule.severity ?? 'warn';
937
+ if (severity === 'off') continue;
160
938
  findings.push({
161
939
  id: String(rule.id),
162
- severity: rule.severity ?? 'warn',
940
+ severity,
163
941
  path: change.path ?? '',
164
942
  message: formatMessage(rule, change),
165
943
  pack: pack.id,
@@ -241,12 +1019,19 @@ export async function evaluatePolicies(changes, options = {}) {
241
1019
  const cwd = options.cwd ?? process.cwd();
242
1020
  const packIds = options.policies?.length ? options.policies : ['default'];
243
1021
  const plugins = options.plugins ?? [];
1022
+ const severityRemap = options.severityRemap ?? {};
244
1023
 
245
1024
  /** @type {PolicyFinding[]} */
246
1025
  const findings = [];
247
- for (const packId of packIds) {
248
- const pack = loadPack(packId, cwd);
249
- findings.push(...evaluatePack(pack, changes));
1026
+ const packs = packIds.map((packId) => loadPack(packId, cwd));
1027
+ const knownRuleIds = new Set(packs.flatMap((pack) => pack.rules.map((rule) => String(rule.id))));
1028
+ for (const ruleId of Object.keys(severityRemap)) {
1029
+ if (!knownRuleIds.has(ruleId)) {
1030
+ console.warn(`Unknown policy rule id in severityRemap: "${ruleId}"`);
1031
+ }
1032
+ }
1033
+ for (const pack of packs) {
1034
+ findings.push(...evaluatePack(pack, changes, severityRemap));
250
1035
  }
251
1036
 
252
1037
  const ctx = {