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/CHANGELOG.md +533 -0
- package/README.md +345 -211
- package/index.js +826 -77
- package/package.json +9 -7
- package/schemas/flecto-policy-pack-2.0.json +129 -0
- package/src/alerter.js +24 -6
- package/src/config.js +135 -9
- package/src/differ.js +154 -47
- package/src/documents.js +106 -0
- package/src/encrypted.js +573 -0
- package/src/notifiers.js +430 -0
- package/src/packs/compose.json +45 -0
- package/src/packs/default.json +23 -1
- package/src/packs/kubernetes.json +112 -0
- package/src/packs/node-runtime.json +44 -0
- package/src/packs/sops.json +61 -0
- package/src/packs/strict-prod.json +11 -1
- package/src/packs/terraform.json +120 -0
- package/src/parser.js +189 -20
- package/src/policy-test.js +124 -0
- package/src/policy.js +815 -30
- package/src/pr-comment.js +480 -0
- package/src/renderer.js +75 -18
- package/src/report.js +653 -0
- package/src/secrets.js +316 -0
- package/src/terraform.js +500 -0
- package/src/watcher.js +27 -15
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 {{
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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(
|
|
121
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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 = {
|