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/CHANGELOG.md +464 -1
- package/README.md +348 -304
- package/index.js +586 -61
- package/package.json +3 -2
- package/schemas/flecto-policy-pack-2.0.json +5 -0
- package/src/alerter.js +20 -3
- package/src/config.js +173 -22
- package/src/differ.js +59 -2
- package/src/documents.js +106 -0
- package/src/encrypted.js +573 -0
- package/src/notifiers.js +430 -0
- package/src/packs/default.json +22 -0
- package/src/packs/kubernetes.json +112 -0
- package/src/packs/sops.json +61 -0
- package/src/packs/strict-prod.json +10 -0
- package/src/packs/terraform.json +120 -0
- package/src/parser.js +189 -20
- package/src/policy.js +498 -11
- package/src/pr-comment.js +480 -0
- package/src/renderer.js +70 -16
- package/src/report.js +653 -0
- package/src/secrets.js +316 -0
- package/src/terraform.js +500 -0
- package/src/watcher.js +9 -7
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', '
|
|
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
|
-
|
|
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
|
-
|
|
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 && !
|
|
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
|
-
|
|
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
|
-
|
|
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';
|