flecto 2.0.0 → 2.1.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 +103 -0
- package/README.md +258 -120
- package/index.js +269 -38
- package/package.json +7 -6
- package/schemas/flecto-policy-pack-2.0.json +124 -0
- package/src/alerter.js +4 -3
- package/src/config.js +23 -2
- package/src/differ.js +95 -45
- package/src/packs/compose.json +45 -0
- package/src/packs/default.json +1 -1
- package/src/packs/node-runtime.json +44 -0
- package/src/packs/strict-prod.json +1 -1
- package/src/policy-test.js +124 -0
- package/src/policy.js +325 -27
- package/src/renderer.js +7 -4
- package/src/watcher.js +18 -8
package/src/policy.js
CHANGED
|
@@ -17,13 +17,35 @@ import yaml from 'js-yaml';
|
|
|
17
17
|
* id: string,
|
|
18
18
|
* severity: PolicySeverity,
|
|
19
19
|
* when?: Array<'added' | 'removed' | 'changed'>,
|
|
20
|
-
* match?: { path?: string, pathFlags?: string },
|
|
20
|
+
* match?: { path?: string, pathFlags?: string, pathEquals?: string, pathPrefix?: string },
|
|
21
|
+
* beforeEquals?: unknown,
|
|
21
22
|
* afterEquals?: unknown,
|
|
23
|
+
* beforeIn?: unknown[],
|
|
24
|
+
* afterIn?: unknown[],
|
|
25
|
+
* beforeTruthy?: true,
|
|
26
|
+
* afterTruthy?: true,
|
|
27
|
+
* afterMatches?: string,
|
|
22
28
|
* numericJump?: { minMultiple: number },
|
|
29
|
+
* numericDelta?: { min: number },
|
|
30
|
+
* allOf?: PolicyMatchClause[],
|
|
31
|
+
* anyOf?: PolicyMatchClause[],
|
|
23
32
|
* message?: string,
|
|
24
33
|
* messageTemplate?: string
|
|
25
34
|
* }} PolicyRule
|
|
26
35
|
*
|
|
36
|
+
* @typedef {{
|
|
37
|
+
* match?: { path?: string, pathFlags?: string, pathEquals?: string, pathPrefix?: string },
|
|
38
|
+
* beforeEquals?: unknown,
|
|
39
|
+
* afterEquals?: unknown,
|
|
40
|
+
* beforeIn?: unknown[],
|
|
41
|
+
* afterIn?: unknown[],
|
|
42
|
+
* beforeTruthy?: true,
|
|
43
|
+
* afterTruthy?: true,
|
|
44
|
+
* afterMatches?: string,
|
|
45
|
+
* numericJump?: { minMultiple: number },
|
|
46
|
+
* numericDelta?: { min: number }
|
|
47
|
+
* }} PolicyMatchClause
|
|
48
|
+
*
|
|
27
49
|
* @typedef {{ id: string, rules: PolicyRule[] }} PolicyPack
|
|
28
50
|
*
|
|
29
51
|
* @typedef {{
|
|
@@ -32,12 +54,85 @@ import yaml from 'js-yaml';
|
|
|
32
54
|
* profile?: string | null,
|
|
33
55
|
* source?: 'watch' | 'ci' | 'diff',
|
|
34
56
|
* policies?: string[],
|
|
35
|
-
* plugins?: string[]
|
|
57
|
+
* plugins?: string[],
|
|
58
|
+
* severityRemap?: Record<string, PolicySeverity | 'off'>
|
|
36
59
|
* }} PolicyEvalOptions
|
|
37
60
|
*/
|
|
38
61
|
|
|
39
62
|
const SEVERITY_RANK = { info: 1, warn: 2, error: 3 };
|
|
40
63
|
const PACKS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'packs');
|
|
64
|
+
const CHANGE_TYPES = new Set(['added', 'removed', 'changed']);
|
|
65
|
+
const RULE_FIELDS = new Set([
|
|
66
|
+
'id', 'severity', 'when', 'match', 'beforeEquals', 'afterEquals',
|
|
67
|
+
'beforeIn', 'afterIn', 'beforeTruthy', 'afterTruthy', 'numericJump',
|
|
68
|
+
'afterMatches', 'numericDelta', 'allOf', 'anyOf', 'message', 'messageTemplate',
|
|
69
|
+
]);
|
|
70
|
+
const CLAUSE_FIELDS = new Set([
|
|
71
|
+
'match', 'beforeEquals', 'afterEquals', 'beforeIn', 'afterIn',
|
|
72
|
+
'beforeTruthy', 'afterTruthy', 'afterMatches', 'numericJump', 'numericDelta',
|
|
73
|
+
]);
|
|
74
|
+
const MATCH_FIELDS = new Set(['path', 'pathFlags', 'pathEquals', 'pathPrefix']);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {string} path
|
|
78
|
+
* @param {string} message
|
|
79
|
+
* @returns {never}
|
|
80
|
+
*/
|
|
81
|
+
function invalidPack(path, message) {
|
|
82
|
+
throw new Error(`Invalid policy pack at ${path}: ${message}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {unknown} value
|
|
87
|
+
* @returns {value is Record<string, unknown>}
|
|
88
|
+
*/
|
|
89
|
+
function isObject(value) {
|
|
90
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {unknown} value
|
|
95
|
+
* @returns {boolean}
|
|
96
|
+
*/
|
|
97
|
+
function isTruthyToggle(value) {
|
|
98
|
+
if (value === true) return true;
|
|
99
|
+
if (typeof value !== 'string') return false;
|
|
100
|
+
return ['true', '1', 'yes'].includes(value.trim().toLowerCase());
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Validate a parsed policy pack and reject typos before evaluation.
|
|
105
|
+
* @param {unknown} pack
|
|
106
|
+
* @param {string} path
|
|
107
|
+
* @returns {asserts pack is PolicyPack}
|
|
108
|
+
*/
|
|
109
|
+
function validatePack(pack, path) {
|
|
110
|
+
if (!isObject(pack)) invalidPack(path, 'pack must be an object');
|
|
111
|
+
|
|
112
|
+
const packFields = new Set(['id', 'rules']);
|
|
113
|
+
for (const field of Object.keys(pack)) {
|
|
114
|
+
if (!packFields.has(field)) invalidPack(path, `pack.${field} is not allowed`);
|
|
115
|
+
}
|
|
116
|
+
if (Object.hasOwn(pack, 'id') && (typeof pack.id !== 'string' || !pack.id.trim())) {
|
|
117
|
+
invalidPack(path, 'pack.id must be a non-empty string');
|
|
118
|
+
}
|
|
119
|
+
if (!Array.isArray(pack.rules)) invalidPack(path, 'pack.rules must be an array');
|
|
120
|
+
|
|
121
|
+
for (const [index, rule] of pack.rules.entries()) {
|
|
122
|
+
try {
|
|
123
|
+
validateRule(rule, `rules[${index}]`);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
const label = isObject(rule) && typeof rule.id === 'string' && rule.id
|
|
126
|
+
? `rule "${rule.id}"`
|
|
127
|
+
: `rules[${index}]`;
|
|
128
|
+
const message = error.message.replace(/^Invalid policy rule at [^:]+: /, '')
|
|
129
|
+
.replace(/^unknown field "([^"]+)"$/, '$1 is not allowed (unknown field "$1")')
|
|
130
|
+
.replace(/^unknown match field "([^"]+)"$/, 'match.$1 is not allowed (unknown match field "$1")')
|
|
131
|
+
.replace(/^match\.path is not a valid regular expression$/, 'match.path must be a valid regular expression');
|
|
132
|
+
invalidPack(path, `${label}.${message}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
41
136
|
|
|
42
137
|
/**
|
|
43
138
|
* @param {string} cwd
|
|
@@ -59,18 +154,150 @@ function resolvePackPath(cwd, packId) {
|
|
|
59
154
|
|
|
60
155
|
/**
|
|
61
156
|
* @param {string} path
|
|
157
|
+
* @param {string} fallbackId
|
|
62
158
|
* @returns {PolicyPack}
|
|
63
159
|
*/
|
|
64
|
-
function readPackFile(path) {
|
|
160
|
+
function readPackFile(path, fallbackId) {
|
|
65
161
|
const raw = readFileSync(path, 'utf8');
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
162
|
+
let parsed;
|
|
163
|
+
try {
|
|
164
|
+
parsed = path.endsWith('.json') ? JSON.parse(raw) : yaml.load(raw);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
invalidPack(path, `could not parse file (${error.message})`);
|
|
69
167
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
168
|
+
validatePack(parsed, path);
|
|
169
|
+
return { ...parsed, id: parsed.id ?? fallbackId };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Validate a rule or composition clause so pack typos fail closed at load time.
|
|
174
|
+
* @param {unknown} candidate
|
|
175
|
+
* @param {string} location
|
|
176
|
+
* @param {boolean} [isClause]
|
|
177
|
+
*/
|
|
178
|
+
function validateRule(candidate, location, isClause = false) {
|
|
179
|
+
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
|
|
180
|
+
throw new Error(`Invalid policy rule at ${location}: expected an object`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const allowedFields = isClause ? CLAUSE_FIELDS : RULE_FIELDS;
|
|
184
|
+
for (const key of Object.keys(candidate)) {
|
|
185
|
+
if (!allowedFields.has(key)) {
|
|
186
|
+
throw new Error(`Invalid policy rule at ${location}: unknown field "${key}"`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const rule = /** @type {Record<string, unknown>} */ (candidate);
|
|
191
|
+
if (!isClause && (typeof rule.id !== 'string' || !rule.id)) {
|
|
192
|
+
throw new Error(`Invalid policy rule at ${location}: id is required`);
|
|
193
|
+
}
|
|
194
|
+
if (!isClause && (!Object.hasOwn(rule, 'severity') || !Object.hasOwn(SEVERITY_RANK, rule.severity))) {
|
|
195
|
+
throw new Error(`Invalid policy rule at ${location}: severity must be one of: info, warn, error`);
|
|
196
|
+
}
|
|
197
|
+
if (rule.when !== undefined
|
|
198
|
+
&& (!Array.isArray(rule.when) || rule.when.length === 0 || rule.when.some((type) => !CHANGE_TYPES.has(type)))) {
|
|
199
|
+
const invalidIndex = Array.isArray(rule.when)
|
|
200
|
+
? rule.when.findIndex((type) => !CHANGE_TYPES.has(type))
|
|
201
|
+
: -1;
|
|
202
|
+
throw new Error(`Invalid policy rule at ${location}: when${invalidIndex >= 0 ? `[${invalidIndex}]` : ''} must be one of: added, removed, changed`);
|
|
203
|
+
}
|
|
204
|
+
validateMatch(rule.match, location);
|
|
205
|
+
validateArrayPredicate(rule.beforeIn, 'beforeIn', location);
|
|
206
|
+
validateArrayPredicate(rule.afterIn, 'afterIn', location);
|
|
207
|
+
validateTruthyPredicate(rule.beforeTruthy, 'beforeTruthy', location);
|
|
208
|
+
validateTruthyPredicate(rule.afterTruthy, 'afterTruthy', location);
|
|
209
|
+
validateRegexPredicate(rule.afterMatches, 'afterMatches', location);
|
|
210
|
+
validateNumericPredicate(rule.numericJump, 'numericJump', 'minMultiple', location, true);
|
|
211
|
+
validateNumericPredicate(rule.numericDelta, 'numericDelta', 'min', location, false);
|
|
212
|
+
for (const name of ['message', 'messageTemplate']) {
|
|
213
|
+
if (rule[name] !== undefined && typeof rule[name] !== 'string') {
|
|
214
|
+
throw new Error(`Invalid policy rule at ${location}: ${name} must be a string`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!isClause) {
|
|
219
|
+
validateComposition(rule.allOf, 'allOf', location);
|
|
220
|
+
validateComposition(rule.anyOf, 'anyOf', location);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** @param {unknown} match @param {string} location */
|
|
225
|
+
function validateMatch(match, location) {
|
|
226
|
+
if (match === undefined) return;
|
|
227
|
+
if (!match || typeof match !== 'object' || Array.isArray(match)) {
|
|
228
|
+
throw new Error(`Invalid policy rule at ${location}: match must be an object`);
|
|
229
|
+
}
|
|
230
|
+
for (const key of Object.keys(match)) {
|
|
231
|
+
if (!MATCH_FIELDS.has(key)) {
|
|
232
|
+
throw new Error(`Invalid policy rule at ${location}: unknown match field "${key}"`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const typedMatch = /** @type {Record<string, unknown>} */ (match);
|
|
236
|
+
for (const key of MATCH_FIELDS) {
|
|
237
|
+
if (typedMatch[key] !== undefined && typeof typedMatch[key] !== 'string') {
|
|
238
|
+
throw new Error(`Invalid policy rule at ${location}: match.${key} must be a string`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (typedMatch.path !== undefined) {
|
|
242
|
+
try {
|
|
243
|
+
new RegExp(typedMatch.path, typedMatch.pathFlags ?? '');
|
|
244
|
+
} catch {
|
|
245
|
+
if (typedMatch.pathFlags !== undefined) {
|
|
246
|
+
try {
|
|
247
|
+
new RegExp('(?:)', typedMatch.pathFlags);
|
|
248
|
+
} catch {
|
|
249
|
+
throw new Error(`Invalid policy rule at ${location}: match.pathFlags must be valid regular expression flags`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
throw new Error(`Invalid policy rule at ${location}: match.path is not a valid regular expression`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** @param {unknown} value @param {string} name @param {string} location */
|
|
258
|
+
function validateArrayPredicate(value, name, location) {
|
|
259
|
+
if (value !== undefined && !Array.isArray(value)) {
|
|
260
|
+
throw new Error(`Invalid policy rule at ${location}: ${name} must be an array`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** @param {unknown} value @param {string} name @param {string} location */
|
|
265
|
+
function validateTruthyPredicate(value, name, location) {
|
|
266
|
+
if (value !== undefined && value !== true) {
|
|
267
|
+
throw new Error(`Invalid policy rule at ${location}: ${name} must be true`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** @param {unknown} value @param {string} name @param {string} location */
|
|
272
|
+
function validateRegexPredicate(value, name, location) {
|
|
273
|
+
if (value === undefined) return;
|
|
274
|
+
if (typeof value !== 'string') {
|
|
275
|
+
throw new Error(`Invalid policy rule at ${location}: ${name} must be a string`);
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
new RegExp(value);
|
|
279
|
+
} catch {
|
|
280
|
+
throw new Error(`Invalid policy rule at ${location}: ${name} is not a valid regular expression`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** @param {unknown} value @param {string} name @param {string} property @param {string} location @param {boolean} positive */
|
|
285
|
+
function validateNumericPredicate(value, name, property, location, positive) {
|
|
286
|
+
if (value === undefined) return;
|
|
287
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
288
|
+
|| typeof value[property] !== 'number' || !Number.isFinite(value[property])
|
|
289
|
+
|| (positive ? value[property] <= 0 : value[property] < 0)) {
|
|
290
|
+
throw new Error(`Invalid policy rule at ${location}: ${name}.${property} must be a ${positive ? 'positive' : 'non-negative'} finite number`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** @param {unknown} clauses @param {string} name @param {string} location */
|
|
295
|
+
function validateComposition(clauses, name, location) {
|
|
296
|
+
if (clauses === undefined) return;
|
|
297
|
+
if (!Array.isArray(clauses) || clauses.length === 0) {
|
|
298
|
+
throw new Error(`Invalid policy rule at ${location}: ${name} must be a non-empty array of match clauses`);
|
|
299
|
+
}
|
|
300
|
+
clauses.forEach((clause, index) => validateRule(clause, `${location}.${name}[${index}]`, true));
|
|
74
301
|
}
|
|
75
302
|
|
|
76
303
|
/**
|
|
@@ -86,9 +313,7 @@ export function loadPack(packId, cwd = process.cwd()) {
|
|
|
86
313
|
if (!path) {
|
|
87
314
|
throw new Error(`Unknown policy pack "${id}". Add policies/${id}.json or use a built-in pack.`);
|
|
88
315
|
}
|
|
89
|
-
|
|
90
|
-
if (!pack.id) pack.id = id;
|
|
91
|
-
return pack;
|
|
316
|
+
return readPackFile(path, id);
|
|
92
317
|
}
|
|
93
318
|
|
|
94
319
|
/**
|
|
@@ -102,6 +327,47 @@ export function listBuiltinPackIds() {
|
|
|
102
327
|
.map((f) => f.replace(/\.json$/, ''));
|
|
103
328
|
}
|
|
104
329
|
|
|
330
|
+
/**
|
|
331
|
+
* List every policy pack resolvable from a working directory. Local packs take
|
|
332
|
+
* precedence over built-ins using the same order as loadPack().
|
|
333
|
+
* @param {string} [cwd]
|
|
334
|
+
* @returns {Array<{
|
|
335
|
+
* id: string,
|
|
336
|
+
* sourcePath: string,
|
|
337
|
+
* source: 'builtin' | 'local',
|
|
338
|
+
* ruleCount: number,
|
|
339
|
+
* overridesBuiltin: boolean
|
|
340
|
+
* }>}
|
|
341
|
+
*/
|
|
342
|
+
export function listPolicyPacks(cwd = process.cwd()) {
|
|
343
|
+
const localDir = resolve(cwd, 'policies');
|
|
344
|
+
const localIds = existsSync(localDir)
|
|
345
|
+
? readdirSync(localDir)
|
|
346
|
+
.filter((file) => /\.(json|yaml|yml)$/.test(file))
|
|
347
|
+
.map((file) => file.replace(/\.(json|yaml|yml)$/, ''))
|
|
348
|
+
: [];
|
|
349
|
+
const builtinIds = listBuiltinPackIds();
|
|
350
|
+
const builtinIdSet = new Set(builtinIds);
|
|
351
|
+
|
|
352
|
+
return [...new Set([...builtinIds, ...localIds])]
|
|
353
|
+
.sort()
|
|
354
|
+
.map((id) => {
|
|
355
|
+
const sourcePath = resolvePackPath(cwd, id);
|
|
356
|
+
if (!sourcePath) {
|
|
357
|
+
throw new Error(`Unable to resolve policy pack "${id}"`);
|
|
358
|
+
}
|
|
359
|
+
const pack = readPackFile(sourcePath, id);
|
|
360
|
+
const isLocal = localIds.includes(id);
|
|
361
|
+
return {
|
|
362
|
+
id,
|
|
363
|
+
sourcePath,
|
|
364
|
+
source: isLocal ? 'local' : 'builtin',
|
|
365
|
+
ruleCount: pack.rules.length,
|
|
366
|
+
overridesBuiltin: isLocal && builtinIdSet.has(id),
|
|
367
|
+
};
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
105
371
|
/**
|
|
106
372
|
* @param {PolicyRule} rule
|
|
107
373
|
* @param {import('./differ.js').ChangeEvent} change
|
|
@@ -110,22 +376,44 @@ export function listBuiltinPackIds() {
|
|
|
110
376
|
function ruleMatches(rule, change) {
|
|
111
377
|
const when = rule.when ?? ['added', 'removed', 'changed'];
|
|
112
378
|
if (!when.includes(change.type)) return false;
|
|
379
|
+
if (!matchClause(rule, change)) return false;
|
|
380
|
+
if (rule.allOf?.some((clause) => !matchClause(clause, change))) return false;
|
|
381
|
+
if (rule.anyOf && !rule.anyOf.some((clause) => matchClause(clause, change))) return false;
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
113
384
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
385
|
+
/**
|
|
386
|
+
* @param {PolicyMatchClause} clause
|
|
387
|
+
* @param {import('./differ.js').ChangeEvent} change
|
|
388
|
+
* @returns {boolean}
|
|
389
|
+
*/
|
|
390
|
+
function matchClause(clause, change) {
|
|
391
|
+
const path = change.path ?? '';
|
|
392
|
+
const match = clause.match;
|
|
393
|
+
if (match?.path && !new RegExp(match.path, match.pathFlags ?? '').test(path)) return false;
|
|
394
|
+
if (match?.pathEquals !== undefined && path !== match.pathEquals) return false;
|
|
395
|
+
if (match?.pathPrefix !== undefined && !path.startsWith(match.pathPrefix)) return false;
|
|
396
|
+
|
|
397
|
+
if (Object.prototype.hasOwnProperty.call(clause, 'beforeEquals') && change.before !== clause.beforeEquals) return false;
|
|
398
|
+
if (Object.prototype.hasOwnProperty.call(clause, 'afterEquals') && change.after !== clause.afterEquals) return false;
|
|
399
|
+
if (clause.beforeIn && !clause.beforeIn.includes(change.before)) return false;
|
|
400
|
+
if (clause.afterIn && !clause.afterIn.includes(change.after)) return false;
|
|
401
|
+
if (clause.beforeTruthy && !isTruthyToggle(change.before)) return false;
|
|
402
|
+
if (clause.afterTruthy && !isTruthyToggle(change.after)) return false;
|
|
403
|
+
if (clause.afterMatches && (typeof change.after !== 'string' || !new RegExp(clause.afterMatches).test(change.after))) return false;
|
|
119
404
|
|
|
120
|
-
if (
|
|
121
|
-
|
|
405
|
+
if (clause.numericJump) {
|
|
406
|
+
const before = change.before;
|
|
407
|
+
const after = change.after;
|
|
408
|
+
if (typeof before !== 'number' || typeof after !== 'number') return false;
|
|
409
|
+
if (!(before > 0 && after >= before * clause.numericJump.minMultiple)) return false;
|
|
122
410
|
}
|
|
123
411
|
|
|
124
|
-
if (
|
|
412
|
+
if (clause.numericDelta) {
|
|
125
413
|
const before = change.before;
|
|
126
414
|
const after = change.after;
|
|
127
415
|
if (typeof before !== 'number' || typeof after !== 'number') return false;
|
|
128
|
-
if (
|
|
416
|
+
if (Math.abs(after - before) < clause.numericDelta.min) return false;
|
|
129
417
|
}
|
|
130
418
|
|
|
131
419
|
return true;
|
|
@@ -149,17 +437,20 @@ function formatMessage(rule, change) {
|
|
|
149
437
|
/**
|
|
150
438
|
* @param {PolicyPack} pack
|
|
151
439
|
* @param {import('./differ.js').ChangeEvent[]} changes
|
|
440
|
+
* @param {Record<string, PolicySeverity | 'off'>} [severityRemap]
|
|
152
441
|
* @returns {PolicyFinding[]}
|
|
153
442
|
*/
|
|
154
|
-
export function evaluatePack(pack, changes) {
|
|
443
|
+
export function evaluatePack(pack, changes, severityRemap = {}) {
|
|
155
444
|
/** @type {PolicyFinding[]} */
|
|
156
445
|
const findings = [];
|
|
157
446
|
for (const change of changes) {
|
|
158
447
|
for (const rule of pack.rules ?? []) {
|
|
159
448
|
if (!ruleMatches(rule, change)) continue;
|
|
449
|
+
const severity = severityRemap[String(rule.id)] ?? rule.severity ?? 'warn';
|
|
450
|
+
if (severity === 'off') continue;
|
|
160
451
|
findings.push({
|
|
161
452
|
id: String(rule.id),
|
|
162
|
-
severity
|
|
453
|
+
severity,
|
|
163
454
|
path: change.path ?? '',
|
|
164
455
|
message: formatMessage(rule, change),
|
|
165
456
|
pack: pack.id,
|
|
@@ -241,12 +532,19 @@ export async function evaluatePolicies(changes, options = {}) {
|
|
|
241
532
|
const cwd = options.cwd ?? process.cwd();
|
|
242
533
|
const packIds = options.policies?.length ? options.policies : ['default'];
|
|
243
534
|
const plugins = options.plugins ?? [];
|
|
535
|
+
const severityRemap = options.severityRemap ?? {};
|
|
244
536
|
|
|
245
537
|
/** @type {PolicyFinding[]} */
|
|
246
538
|
const findings = [];
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
539
|
+
const packs = packIds.map((packId) => loadPack(packId, cwd));
|
|
540
|
+
const knownRuleIds = new Set(packs.flatMap((pack) => pack.rules.map((rule) => String(rule.id))));
|
|
541
|
+
for (const ruleId of Object.keys(severityRemap)) {
|
|
542
|
+
if (!knownRuleIds.has(ruleId)) {
|
|
543
|
+
console.warn(`Unknown policy rule id in severityRemap: "${ruleId}"`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
for (const pack of packs) {
|
|
547
|
+
findings.push(...evaluatePack(pack, changes, severityRemap));
|
|
250
548
|
}
|
|
251
549
|
|
|
252
550
|
const ctx = {
|
package/src/renderer.js
CHANGED
|
@@ -154,7 +154,11 @@ export function maskSensitiveValue(value, path = '') {
|
|
|
154
154
|
if (Array.isArray(value)) {
|
|
155
155
|
return value.map((v, i) => maskSensitiveValue(v, `${path}[${i}]`));
|
|
156
156
|
}
|
|
157
|
-
if (
|
|
157
|
+
if (
|
|
158
|
+
value
|
|
159
|
+
&& typeof value === 'object'
|
|
160
|
+
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)
|
|
161
|
+
) {
|
|
158
162
|
/** @type {Record<string, unknown>} */
|
|
159
163
|
const out = {};
|
|
160
164
|
for (const [k, v] of Object.entries(value)) {
|
|
@@ -171,10 +175,9 @@ export function maskSensitiveValue(value, path = '') {
|
|
|
171
175
|
* @returns {import('./differ.js').ChangeEvent}
|
|
172
176
|
*/
|
|
173
177
|
export function maskChangeEvent(event) {
|
|
174
|
-
if (!SECRET_PATH_RE.test(event.path ?? '')) return event;
|
|
175
178
|
return {
|
|
176
179
|
...event,
|
|
177
|
-
before: event.before === undefined ? undefined :
|
|
178
|
-
after: event.after === undefined ? undefined :
|
|
180
|
+
before: event.before === undefined ? undefined : maskSensitiveValue(event.before, event.path),
|
|
181
|
+
after: event.after === undefined ? undefined : maskSensitiveValue(event.after, event.path),
|
|
179
182
|
};
|
|
180
183
|
}
|
package/src/watcher.js
CHANGED
|
@@ -12,6 +12,7 @@ import { renderWarn, renderInfo } from './renderer.js';
|
|
|
12
12
|
* @property {string} [mode] Output mode: 'compact' | 'verbose'
|
|
13
13
|
* @property {string[]} [ignorePaths] Key paths to suppress in diffs
|
|
14
14
|
* @property {string | null} [arrayIdKey]
|
|
15
|
+
* @property {boolean} [arrayIdentity]
|
|
15
16
|
* @property {boolean} [arrayIgnoreOrder]
|
|
16
17
|
*/
|
|
17
18
|
|
|
@@ -20,7 +21,7 @@ import { renderWarn, renderInfo } from './renderer.js';
|
|
|
20
21
|
*
|
|
21
22
|
* @param {string} filepath
|
|
22
23
|
* @param {WatcherOptions} options
|
|
23
|
-
* @param {(event: { kind: 'changes', filepath: string, events: ChangeEvent[] } | { kind: 'lifecycle', filepath: string, lifecycle: { type: string, message: string } }) => void} onEvent
|
|
24
|
+
* @param {(event: { kind: 'changes', filepath: string, events: ChangeEvent[] } | { kind: 'lifecycle', filepath: string, lifecycle: { type: string, message: string } }) => void | Promise<void>} onEvent
|
|
24
25
|
* @returns {import('chokidar').FSWatcher}
|
|
25
26
|
*/
|
|
26
27
|
export function startWatcher(filepath, options = {}, onEvent) {
|
|
@@ -30,6 +31,7 @@ export function startWatcher(filepath, options = {}, onEvent) {
|
|
|
30
31
|
const diffOpts = {
|
|
31
32
|
ignorePaths,
|
|
32
33
|
arrayIdKey: options.arrayIdKey ?? null,
|
|
34
|
+
arrayIdentity: options.arrayIdentity !== false,
|
|
33
35
|
arrayIgnoreOrder: Boolean(options.arrayIgnoreOrder),
|
|
34
36
|
};
|
|
35
37
|
|
|
@@ -42,7 +44,7 @@ export function startWatcher(filepath, options = {}, onEvent) {
|
|
|
42
44
|
} catch (err) {
|
|
43
45
|
renderWarn(`Could not parse initial state of "${filepath}": ${err.message}`);
|
|
44
46
|
renderWarn('Watching anyway — will use first successful parse as baseline.');
|
|
45
|
-
onEvent
|
|
47
|
+
safelyEmit(onEvent, {
|
|
46
48
|
kind: 'lifecycle',
|
|
47
49
|
filepath,
|
|
48
50
|
lifecycle: { type: 'initial-parse-failed', message: err.message },
|
|
@@ -72,12 +74,12 @@ export function startWatcher(filepath, options = {}, onEvent) {
|
|
|
72
74
|
lastGoodState = newState;
|
|
73
75
|
}
|
|
74
76
|
if (lifecycle) {
|
|
75
|
-
onEvent
|
|
77
|
+
safelyEmit(onEvent, { kind: 'lifecycle', filepath, lifecycle });
|
|
76
78
|
}
|
|
77
79
|
if (events.length > 0) {
|
|
78
|
-
onEvent
|
|
80
|
+
safelyEmit(onEvent, { kind: 'changes', filepath, events });
|
|
79
81
|
} else if (reason === 'add') {
|
|
80
|
-
onEvent
|
|
82
|
+
safelyEmit(onEvent, {
|
|
81
83
|
kind: 'lifecycle',
|
|
82
84
|
filepath,
|
|
83
85
|
lifecycle: { type: 'file-restored', message: 'File content reloaded after add event.' },
|
|
@@ -96,7 +98,7 @@ export function startWatcher(filepath, options = {}, onEvent) {
|
|
|
96
98
|
watcher.on('unlink', () => {
|
|
97
99
|
// File temporarily missing; keep last good state and wait for add.
|
|
98
100
|
renderWarn(`File disappeared: "${filepath}" (waiting for it to reappear)`);
|
|
99
|
-
onEvent
|
|
101
|
+
safelyEmit(onEvent, {
|
|
100
102
|
kind: 'lifecycle',
|
|
101
103
|
filepath,
|
|
102
104
|
lifecycle: { type: 'file-missing', message: 'File disappeared; waiting for restore.' },
|
|
@@ -105,7 +107,7 @@ export function startWatcher(filepath, options = {}, onEvent) {
|
|
|
105
107
|
|
|
106
108
|
watcher.on('error', (err) => {
|
|
107
109
|
renderWarn(`Watcher error: ${err.message}`);
|
|
108
|
-
onEvent
|
|
110
|
+
safelyEmit(onEvent, {
|
|
109
111
|
kind: 'lifecycle',
|
|
110
112
|
filepath,
|
|
111
113
|
lifecycle: { type: 'watcher-error', message: err.message },
|
|
@@ -115,10 +117,18 @@ export function startWatcher(filepath, options = {}, onEvent) {
|
|
|
115
117
|
return watcher;
|
|
116
118
|
}
|
|
117
119
|
|
|
120
|
+
function safelyEmit(onEvent, event) {
|
|
121
|
+
Promise.resolve()
|
|
122
|
+
.then(() => onEvent(event))
|
|
123
|
+
.catch((err) => {
|
|
124
|
+
renderWarn(`Watcher event handler error: ${err?.message ?? String(err)}`);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
118
128
|
/**
|
|
119
129
|
* Internal: re-parse the file and diff against the previous state.
|
|
120
130
|
* @param {string} filepath
|
|
121
|
-
* @param {{ ignorePaths?: string[], arrayIdKey?: string | null, arrayIgnoreOrder?: boolean }} diffOpts
|
|
131
|
+
* @param {{ ignorePaths?: string[], arrayIdKey?: string | null, arrayIdentity?: boolean, arrayIgnoreOrder?: boolean }} diffOpts
|
|
122
132
|
* @param {unknown | null} lastGoodState
|
|
123
133
|
* @param {(newState: unknown | null, events: ChangeEvent[], lifecycle: { type: string, message: string } | null) => void} callback
|
|
124
134
|
*/
|