flecto 1.0.2 → 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 +297 -109
- package/index.js +362 -35
- package/package.json +8 -6
- package/schemas/flecto-envelope-2.0.json +65 -0
- package/schemas/flecto-policy-pack-2.0.json +124 -0
- package/src/alerter.js +11 -10
- package/src/config.js +59 -2
- package/src/differ.js +153 -16
- package/src/envelope.js +6 -4
- package/src/packs/compose.json +45 -0
- package/src/packs/default.json +37 -0
- package/src/packs/node-runtime.json +44 -0
- package/src/packs/strict-prod.json +37 -0
- package/src/parser.js +80 -14
- package/src/policy-test.js +124 -0
- package/src/policy.js +541 -34
- package/src/renderer.js +62 -13
- package/src/watcher.js +28 -11
package/src/policy.js
CHANGED
|
@@ -1,57 +1,565 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from 'path';
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
4
|
+
import yaml from 'js-yaml';
|
|
5
|
+
|
|
1
6
|
/**
|
|
2
7
|
* @typedef {'info' | 'warn' | 'error'} PolicySeverity
|
|
3
|
-
* @typedef {{
|
|
8
|
+
* @typedef {{
|
|
9
|
+
* id: string,
|
|
10
|
+
* severity: PolicySeverity,
|
|
11
|
+
* path: string,
|
|
12
|
+
* message: string,
|
|
13
|
+
* pack?: string
|
|
14
|
+
* }} PolicyFinding
|
|
15
|
+
*
|
|
16
|
+
* @typedef {{
|
|
17
|
+
* id: string,
|
|
18
|
+
* severity: PolicySeverity,
|
|
19
|
+
* when?: Array<'added' | 'removed' | 'changed'>,
|
|
20
|
+
* match?: { path?: string, pathFlags?: string, pathEquals?: string, pathPrefix?: string },
|
|
21
|
+
* beforeEquals?: unknown,
|
|
22
|
+
* afterEquals?: unknown,
|
|
23
|
+
* beforeIn?: unknown[],
|
|
24
|
+
* afterIn?: unknown[],
|
|
25
|
+
* beforeTruthy?: true,
|
|
26
|
+
* afterTruthy?: true,
|
|
27
|
+
* afterMatches?: string,
|
|
28
|
+
* numericJump?: { minMultiple: number },
|
|
29
|
+
* numericDelta?: { min: number },
|
|
30
|
+
* allOf?: PolicyMatchClause[],
|
|
31
|
+
* anyOf?: PolicyMatchClause[],
|
|
32
|
+
* message?: string,
|
|
33
|
+
* messageTemplate?: string
|
|
34
|
+
* }} PolicyRule
|
|
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
|
+
*
|
|
49
|
+
* @typedef {{ id: string, rules: PolicyRule[] }} PolicyPack
|
|
50
|
+
*
|
|
51
|
+
* @typedef {{
|
|
52
|
+
* cwd?: string,
|
|
53
|
+
* file?: string,
|
|
54
|
+
* profile?: string | null,
|
|
55
|
+
* source?: 'watch' | 'ci' | 'diff',
|
|
56
|
+
* policies?: string[],
|
|
57
|
+
* plugins?: string[],
|
|
58
|
+
* severityRemap?: Record<string, PolicySeverity | 'off'>
|
|
59
|
+
* }} PolicyEvalOptions
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
const SEVERITY_RANK = { info: 1, warn: 2, error: 3 };
|
|
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
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {string} cwd
|
|
139
|
+
* @param {string} packId
|
|
140
|
+
* @returns {string | null}
|
|
141
|
+
*/
|
|
142
|
+
function resolvePackPath(cwd, packId) {
|
|
143
|
+
const localJson = resolve(cwd, 'policies', `${packId}.json`);
|
|
144
|
+
const localYaml = resolve(cwd, 'policies', `${packId}.yaml`);
|
|
145
|
+
const localYml = resolve(cwd, 'policies', `${packId}.yml`);
|
|
146
|
+
if (existsSync(localJson)) return localJson;
|
|
147
|
+
if (existsSync(localYaml)) return localYaml;
|
|
148
|
+
if (existsSync(localYml)) return localYml;
|
|
149
|
+
|
|
150
|
+
const builtinJson = join(PACKS_DIR, `${packId}.json`);
|
|
151
|
+
if (existsSync(builtinJson)) return builtinJson;
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @param {string} path
|
|
157
|
+
* @param {string} fallbackId
|
|
158
|
+
* @returns {PolicyPack}
|
|
159
|
+
*/
|
|
160
|
+
function readPackFile(path, fallbackId) {
|
|
161
|
+
const raw = readFileSync(path, 'utf8');
|
|
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})`);
|
|
167
|
+
}
|
|
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));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Load a pack by id from policies/ then built-ins.
|
|
305
|
+
* @param {string} packId
|
|
306
|
+
* @param {string} [cwd]
|
|
307
|
+
* @returns {PolicyPack}
|
|
4
308
|
*/
|
|
309
|
+
export function loadPack(packId, cwd = process.cwd()) {
|
|
310
|
+
const id = String(packId ?? '').trim();
|
|
311
|
+
if (!id) throw new Error('Policy pack id is required');
|
|
312
|
+
const path = resolvePackPath(cwd, id);
|
|
313
|
+
if (!path) {
|
|
314
|
+
throw new Error(`Unknown policy pack "${id}". Add policies/${id}.json or use a built-in pack.`);
|
|
315
|
+
}
|
|
316
|
+
return readPackFile(path, id);
|
|
317
|
+
}
|
|
5
318
|
|
|
6
|
-
|
|
7
|
-
|
|
319
|
+
/**
|
|
320
|
+
* List built-in pack ids.
|
|
321
|
+
* @returns {string[]}
|
|
322
|
+
*/
|
|
323
|
+
export function listBuiltinPackIds() {
|
|
324
|
+
if (!existsSync(PACKS_DIR)) return [];
|
|
325
|
+
return readdirSync(PACKS_DIR)
|
|
326
|
+
.filter((f) => f.endsWith('.json'))
|
|
327
|
+
.map((f) => f.replace(/\.json$/, ''));
|
|
328
|
+
}
|
|
8
329
|
|
|
9
330
|
/**
|
|
10
|
-
*
|
|
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
|
+
|
|
371
|
+
/**
|
|
372
|
+
* @param {PolicyRule} rule
|
|
373
|
+
* @param {import('./differ.js').ChangeEvent} change
|
|
374
|
+
* @returns {boolean}
|
|
375
|
+
*/
|
|
376
|
+
function ruleMatches(rule, change) {
|
|
377
|
+
const when = rule.when ?? ['added', 'removed', 'changed'];
|
|
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
|
+
}
|
|
384
|
+
|
|
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;
|
|
404
|
+
|
|
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;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (clause.numericDelta) {
|
|
413
|
+
const before = change.before;
|
|
414
|
+
const after = change.after;
|
|
415
|
+
if (typeof before !== 'number' || typeof after !== 'number') return false;
|
|
416
|
+
if (Math.abs(after - before) < clause.numericDelta.min) return false;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* @param {PolicyRule} rule
|
|
424
|
+
* @param {import('./differ.js').ChangeEvent} change
|
|
425
|
+
* @returns {string}
|
|
426
|
+
*/
|
|
427
|
+
function formatMessage(rule, change) {
|
|
428
|
+
if (rule.messageTemplate) {
|
|
429
|
+
return rule.messageTemplate
|
|
430
|
+
.replaceAll('{before}', String(change.before))
|
|
431
|
+
.replaceAll('{after}', String(change.after))
|
|
432
|
+
.replaceAll('{path}', String(change.path ?? ''));
|
|
433
|
+
}
|
|
434
|
+
return rule.message ?? `Policy ${rule.id} matched`;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* @param {PolicyPack} pack
|
|
11
439
|
* @param {import('./differ.js').ChangeEvent[]} changes
|
|
440
|
+
* @param {Record<string, PolicySeverity | 'off'>} [severityRemap]
|
|
12
441
|
* @returns {PolicyFinding[]}
|
|
13
442
|
*/
|
|
14
|
-
export function
|
|
443
|
+
export function evaluatePack(pack, changes, severityRemap = {}) {
|
|
15
444
|
/** @type {PolicyFinding[]} */
|
|
16
445
|
const findings = [];
|
|
17
|
-
|
|
18
446
|
for (const change of changes) {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
447
|
+
for (const rule of pack.rules ?? []) {
|
|
448
|
+
if (!ruleMatches(rule, change)) continue;
|
|
449
|
+
const severity = severityRemap[String(rule.id)] ?? rule.severity ?? 'warn';
|
|
450
|
+
if (severity === 'off') continue;
|
|
23
451
|
findings.push({
|
|
24
|
-
id:
|
|
25
|
-
severity
|
|
26
|
-
path,
|
|
27
|
-
message: change
|
|
28
|
-
|
|
29
|
-
: 'Sensitive-looking key changed. Confirm secret rotation and access controls.',
|
|
452
|
+
id: String(rule.id),
|
|
453
|
+
severity,
|
|
454
|
+
path: change.path ?? '',
|
|
455
|
+
message: formatMessage(rule, change),
|
|
456
|
+
pack: pack.id,
|
|
30
457
|
});
|
|
31
458
|
}
|
|
459
|
+
}
|
|
460
|
+
return findings;
|
|
461
|
+
}
|
|
32
462
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
463
|
+
/**
|
|
464
|
+
* Merge findings: same id+path keeps highest severity; ties keep first.
|
|
465
|
+
* @param {PolicyFinding[]} findings
|
|
466
|
+
* @returns {PolicyFinding[]}
|
|
467
|
+
*/
|
|
468
|
+
export function mergeFindings(findings) {
|
|
469
|
+
/** @type {Map<string, PolicyFinding>} */
|
|
470
|
+
const byKey = new Map();
|
|
471
|
+
for (const finding of findings) {
|
|
472
|
+
const key = `${finding.id}::${finding.path}`;
|
|
473
|
+
const existing = byKey.get(key);
|
|
474
|
+
if (!existing) {
|
|
475
|
+
byKey.set(key, finding);
|
|
476
|
+
continue;
|
|
40
477
|
}
|
|
478
|
+
const nextRank = SEVERITY_RANK[finding.severity] ?? 0;
|
|
479
|
+
const prevRank = SEVERITY_RANK[existing.severity] ?? 0;
|
|
480
|
+
if (nextRank > prevRank) byKey.set(key, finding);
|
|
481
|
+
}
|
|
482
|
+
return [...byKey.values()];
|
|
483
|
+
}
|
|
41
484
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
485
|
+
/**
|
|
486
|
+
* @param {string} pluginPath
|
|
487
|
+
* @param {string} cwd
|
|
488
|
+
* @returns {string}
|
|
489
|
+
*/
|
|
490
|
+
function resolvePluginPath(pluginPath, cwd) {
|
|
491
|
+
if (/^https?:\/\//i.test(pluginPath)) {
|
|
492
|
+
throw new Error(`Remote plugins are not allowed: ${pluginPath}`);
|
|
493
|
+
}
|
|
494
|
+
return isAbsolute(pluginPath) ? pluginPath : resolve(cwd, pluginPath);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* @param {string} pluginPath
|
|
499
|
+
* @param {import('./differ.js').ChangeEvent[]} changes
|
|
500
|
+
* @param {Required<Pick<PolicyEvalOptions, 'cwd' | 'file' | 'profile' | 'source'>> & { packIds: string[] }} ctx
|
|
501
|
+
* @returns {Promise<PolicyFinding[]>}
|
|
502
|
+
*/
|
|
503
|
+
async function runPlugin(pluginPath, changes, ctx) {
|
|
504
|
+
const abs = resolvePluginPath(pluginPath, ctx.cwd);
|
|
505
|
+
if (!existsSync(abs)) {
|
|
506
|
+
throw new Error(`Policy plugin not found: ${pluginPath}`);
|
|
507
|
+
}
|
|
508
|
+
const mod = await import(pathToFileURL(abs).href);
|
|
509
|
+
if (typeof mod.evaluate !== 'function') {
|
|
510
|
+
throw new Error(`Policy plugin missing export evaluate(): ${pluginPath}`);
|
|
511
|
+
}
|
|
512
|
+
const result = await mod.evaluate(changes, ctx);
|
|
513
|
+
if (!Array.isArray(result)) {
|
|
514
|
+
throw new Error(`Policy plugin must return PolicyFinding[]: ${pluginPath}`);
|
|
515
|
+
}
|
|
516
|
+
return result.map((f) => ({
|
|
517
|
+
id: String(f.id),
|
|
518
|
+
severity: f.severity,
|
|
519
|
+
path: String(f.path ?? ''),
|
|
520
|
+
message: String(f.message ?? ''),
|
|
521
|
+
pack: f.pack ?? `plugin:${pluginPath}`,
|
|
522
|
+
}));
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Evaluate active packs then plugins.
|
|
527
|
+
* @param {import('./differ.js').ChangeEvent[]} changes
|
|
528
|
+
* @param {PolicyEvalOptions} [options]
|
|
529
|
+
* @returns {Promise<PolicyFinding[]>}
|
|
530
|
+
*/
|
|
531
|
+
export async function evaluatePolicies(changes, options = {}) {
|
|
532
|
+
const cwd = options.cwd ?? process.cwd();
|
|
533
|
+
const packIds = options.policies?.length ? options.policies : ['default'];
|
|
534
|
+
const plugins = options.plugins ?? [];
|
|
535
|
+
const severityRemap = options.severityRemap ?? {};
|
|
536
|
+
|
|
537
|
+
/** @type {PolicyFinding[]} */
|
|
538
|
+
const findings = [];
|
|
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}"`);
|
|
51
544
|
}
|
|
52
545
|
}
|
|
546
|
+
for (const pack of packs) {
|
|
547
|
+
findings.push(...evaluatePack(pack, changes, severityRemap));
|
|
548
|
+
}
|
|
53
549
|
|
|
54
|
-
|
|
550
|
+
const ctx = {
|
|
551
|
+
cwd,
|
|
552
|
+
file: options.file ?? '',
|
|
553
|
+
profile: options.profile ?? null,
|
|
554
|
+
source: options.source ?? 'watch',
|
|
555
|
+
packIds,
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
for (const pluginPath of plugins) {
|
|
559
|
+
findings.push(...await runPlugin(pluginPath, changes, ctx));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return mergeFindings(findings);
|
|
55
563
|
}
|
|
56
564
|
|
|
57
565
|
/**
|
|
@@ -64,4 +572,3 @@ export function highestSeverity(findings) {
|
|
|
64
572
|
if (findings.some((f) => f.severity === 'warn')) return 'warn';
|
|
65
573
|
return 'info';
|
|
66
574
|
}
|
|
67
|
-
|