flecto 3.1.0 → 4.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 +262 -1
- package/README.md +46 -3
- package/drift.js +226 -0
- package/index.js +527 -31
- package/package.json +24 -14
- package/src/alerter.js +272 -24
- package/src/config.js +208 -2
- package/src/drift-sources.js +444 -0
- package/src/explain.js +706 -0
- package/src/lsp-analysis.js +397 -0
- package/src/lsp-worker.js +13 -0
- package/src/lsp.js +407 -0
- package/src/mcp.js +487 -0
- package/src/parser.js +24 -14
- package/src/policy.js +71 -47
- package/src/positions.js +1063 -0
- package/src/pr-comment.js +33 -1
- package/src/regex-engine.js +138 -0
- package/src/renderer.js +24 -0
- package/src/snapshot-store.js +6 -2
package/src/policy.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'fs';
|
|
2
|
-
import { dirname, isAbsolute, join, relative, resolve } from 'path';
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'path';
|
|
3
3
|
import { createRequire } from 'module';
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
5
5
|
import yaml from 'js-yaml';
|
|
6
|
+
import { checkPattern, compilePattern, explainPatternFailure } from './regex-engine.js';
|
|
6
7
|
import { containsSecret } from './secrets.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -83,6 +84,20 @@ import { containsSecret } from './secrets.js';
|
|
|
83
84
|
|
|
84
85
|
const SEVERITY_RANK = { info: 1, warn: 2, error: 3 };
|
|
85
86
|
const PACKS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'packs');
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Is this pack one Flecto ships, rather than one the repository supplies?
|
|
90
|
+
*
|
|
91
|
+
* The distinction decides which regex engine its patterns compile on, so it is
|
|
92
|
+
* drawn on the resolved path rather than the pack id: a local
|
|
93
|
+
* `policies/default.json` overrides the built-in `default` and is still a file
|
|
94
|
+
* a pull request can write.
|
|
95
|
+
* @param {string} path
|
|
96
|
+
* @returns {boolean}
|
|
97
|
+
*/
|
|
98
|
+
function isBuiltinPackPath(path) {
|
|
99
|
+
return resolve(path).startsWith(PACKS_DIR + sep);
|
|
100
|
+
}
|
|
86
101
|
const CHANGE_TYPES = new Set(['added', 'removed', 'changed']);
|
|
87
102
|
const RULE_FIELDS = new Set([
|
|
88
103
|
'id', 'severity', 'when', 'match', 'beforeEquals', 'afterEquals',
|
|
@@ -135,9 +150,10 @@ function isTruthyToggle(value) {
|
|
|
135
150
|
* Validate a parsed policy pack and reject typos before evaluation.
|
|
136
151
|
* @param {unknown} pack
|
|
137
152
|
* @param {string} path
|
|
153
|
+
* @param {boolean} [trusted] true only for packs Flecto ships in src/packs/
|
|
138
154
|
* @returns {asserts pack is PolicyPack}
|
|
139
155
|
*/
|
|
140
|
-
function validatePack(pack, path) {
|
|
156
|
+
function validatePack(pack, path, trusted = false) {
|
|
141
157
|
if (!isObject(pack)) invalidPack(path, 'pack must be an object');
|
|
142
158
|
|
|
143
159
|
const packFields = new Set(['id', 'expandSubtrees', 'rules']);
|
|
@@ -154,7 +170,7 @@ function validatePack(pack, path) {
|
|
|
154
170
|
|
|
155
171
|
for (const [index, rule] of pack.rules.entries()) {
|
|
156
172
|
try {
|
|
157
|
-
validateRule(rule, `rules[${index}]
|
|
173
|
+
validateRule(rule, `rules[${index}]`, false, trusted);
|
|
158
174
|
} catch (error) {
|
|
159
175
|
const label = isObject(rule) && typeof rule.id === 'string' && rule.id
|
|
160
176
|
? `rule "${rule.id}"`
|
|
@@ -162,7 +178,9 @@ function validatePack(pack, path) {
|
|
|
162
178
|
const message = error.message.replace(/^Invalid policy rule at [^:]+: /, '')
|
|
163
179
|
.replace(/^unknown field "([^"]+)"$/, '$1 is not allowed (unknown field "$1")')
|
|
164
180
|
.replace(/^unknown match field "([^"]+)"$/, 'match.$1 is not allowed (unknown match field "$1")')
|
|
165
|
-
|
|
181
|
+
// The trailing parenthetical carries the engine's reason, so this is
|
|
182
|
+
// no longer anchored at the end.
|
|
183
|
+
.replace(/^match\.path is not a valid regular expression/, 'match.path must be a valid regular expression');
|
|
166
184
|
invalidPack(path, `${label}.${message}`);
|
|
167
185
|
}
|
|
168
186
|
}
|
|
@@ -216,7 +234,7 @@ function readPackFile(path, fallbackId) {
|
|
|
216
234
|
} catch (error) {
|
|
217
235
|
invalidPack(path, `could not parse file (${error.message})`);
|
|
218
236
|
}
|
|
219
|
-
validatePack(parsed, path);
|
|
237
|
+
validatePack(parsed, path, isBuiltinPackPath(path));
|
|
220
238
|
return { ...parsed, id: parsed.id ?? fallbackId };
|
|
221
239
|
}
|
|
222
240
|
|
|
@@ -226,7 +244,7 @@ function readPackFile(path, fallbackId) {
|
|
|
226
244
|
* @param {string} location
|
|
227
245
|
* @param {boolean} [isClause]
|
|
228
246
|
*/
|
|
229
|
-
function validateRule(candidate, location, isClause = false) {
|
|
247
|
+
function validateRule(candidate, location, isClause = false, trusted = false) {
|
|
230
248
|
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
|
|
231
249
|
throw new Error(`Invalid policy rule at ${location}: expected an object`);
|
|
232
250
|
}
|
|
@@ -252,15 +270,15 @@ function validateRule(candidate, location, isClause = false) {
|
|
|
252
270
|
: -1;
|
|
253
271
|
throw new Error(`Invalid policy rule at ${location}: when${invalidIndex >= 0 ? `[${invalidIndex}]` : ''} must be one of: added, removed, changed`);
|
|
254
272
|
}
|
|
255
|
-
validateMatch(rule.match, location);
|
|
273
|
+
validateMatch(rule.match, location, trusted);
|
|
256
274
|
validateArrayPredicate(rule.beforeIn, 'beforeIn', location);
|
|
257
275
|
validateArrayPredicate(rule.afterIn, 'afterIn', location);
|
|
258
276
|
validateTruthyPredicate(rule.beforeTruthy, 'beforeTruthy', location);
|
|
259
277
|
validateTruthyPredicate(rule.afterTruthy, 'afterTruthy', location);
|
|
260
278
|
validateTruthyPredicate(rule.beforeLooksSecret, 'beforeLooksSecret', location);
|
|
261
279
|
validateTruthyPredicate(rule.afterLooksSecret, 'afterLooksSecret', location);
|
|
262
|
-
validateRegexPredicate(rule.afterMatches, 'afterMatches', location);
|
|
263
|
-
validateRegexPredicate(rule.afterAnyMatches, 'afterAnyMatches', location);
|
|
280
|
+
validateRegexPredicate(rule.afterMatches, 'afterMatches', location, trusted);
|
|
281
|
+
validateRegexPredicate(rule.afterAnyMatches, 'afterAnyMatches', location, trusted);
|
|
264
282
|
validateNumericPredicate(rule.numericJump, 'numericJump', 'minMultiple', location, true);
|
|
265
283
|
validateNumericPredicate(rule.numericDelta, 'numericDelta', 'min', location, false);
|
|
266
284
|
for (const name of ['message', 'messageTemplate']) {
|
|
@@ -270,13 +288,13 @@ function validateRule(candidate, location, isClause = false) {
|
|
|
270
288
|
}
|
|
271
289
|
|
|
272
290
|
if (!isClause) {
|
|
273
|
-
validateComposition(rule.allOf, 'allOf', location);
|
|
274
|
-
validateComposition(rule.anyOf, 'anyOf', location);
|
|
291
|
+
validateComposition(rule.allOf, 'allOf', location, trusted);
|
|
292
|
+
validateComposition(rule.anyOf, 'anyOf', location, trusted);
|
|
275
293
|
}
|
|
276
294
|
}
|
|
277
295
|
|
|
278
296
|
/** @param {unknown} match @param {string} location */
|
|
279
|
-
function validateMatch(match, location) {
|
|
297
|
+
function validateMatch(match, location, trusted = false) {
|
|
280
298
|
if (match === undefined) return;
|
|
281
299
|
if (!match || typeof match !== 'object' || Array.isArray(match)) {
|
|
282
300
|
throw new Error(`Invalid policy rule at ${location}: match must be an object`);
|
|
@@ -293,17 +311,15 @@ function validateMatch(match, location) {
|
|
|
293
311
|
}
|
|
294
312
|
}
|
|
295
313
|
if (typedMatch.path !== undefined) {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
try {
|
|
301
|
-
new RegExp('(?:)', typedMatch.pathFlags);
|
|
302
|
-
} catch {
|
|
303
|
-
throw new Error(`Invalid policy rule at ${location}: match.pathFlags must be valid regular expression flags`);
|
|
304
|
-
}
|
|
314
|
+
const compiled = checkPattern(typedMatch.path, typedMatch.pathFlags ?? '', { trusted });
|
|
315
|
+
if (!compiled.ok) {
|
|
316
|
+
if (typedMatch.pathFlags !== undefined && !checkPattern('(?:)', typedMatch.pathFlags, { trusted }).ok) {
|
|
317
|
+
throw new Error(`Invalid policy rule at ${location}: match.pathFlags must be valid regular expression flags`);
|
|
305
318
|
}
|
|
306
|
-
throw new Error(
|
|
319
|
+
throw new Error(
|
|
320
|
+
`Invalid policy rule at ${location}: match.path is not a valid regular expression`
|
|
321
|
+
+ ` (${explainPatternFailure(compiled.reason)})`,
|
|
322
|
+
);
|
|
307
323
|
}
|
|
308
324
|
}
|
|
309
325
|
}
|
|
@@ -322,16 +338,18 @@ function validateTruthyPredicate(value, name, location) {
|
|
|
322
338
|
}
|
|
323
339
|
}
|
|
324
340
|
|
|
325
|
-
/** @param {unknown} value @param {string} name @param {string} location */
|
|
326
|
-
function validateRegexPredicate(value, name, location) {
|
|
341
|
+
/** @param {unknown} value @param {string} name @param {string} location @param {boolean} [trusted] */
|
|
342
|
+
function validateRegexPredicate(value, name, location, trusted = false) {
|
|
327
343
|
if (value === undefined) return;
|
|
328
344
|
if (typeof value !== 'string') {
|
|
329
345
|
throw new Error(`Invalid policy rule at ${location}: ${name} must be a string`);
|
|
330
346
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
347
|
+
const compiled = checkPattern(value, '', { trusted });
|
|
348
|
+
if (!compiled.ok) {
|
|
349
|
+
throw new Error(
|
|
350
|
+
`Invalid policy rule at ${location}: ${name} is not a valid regular expression`
|
|
351
|
+
+ ` (${explainPatternFailure(compiled.reason)})`,
|
|
352
|
+
);
|
|
335
353
|
}
|
|
336
354
|
}
|
|
337
355
|
|
|
@@ -345,13 +363,13 @@ function validateNumericPredicate(value, name, property, location, positive) {
|
|
|
345
363
|
}
|
|
346
364
|
}
|
|
347
365
|
|
|
348
|
-
/** @param {unknown} clauses @param {string} name @param {string} location */
|
|
349
|
-
function validateComposition(clauses, name, location) {
|
|
366
|
+
/** @param {unknown} clauses @param {string} name @param {string} location @param {boolean} [trusted] */
|
|
367
|
+
function validateComposition(clauses, name, location, trusted = false) {
|
|
350
368
|
if (clauses === undefined) return;
|
|
351
369
|
if (!Array.isArray(clauses) || clauses.length === 0) {
|
|
352
370
|
throw new Error(`Invalid policy rule at ${location}: ${name} must be a non-empty array of match clauses`);
|
|
353
371
|
}
|
|
354
|
-
clauses.forEach((clause, index) => validateRule(clause, `${location}.${name}[${index}]`, true));
|
|
372
|
+
clauses.forEach((clause, index) => validateRule(clause, `${location}.${name}[${index}]`, true, trusted));
|
|
355
373
|
}
|
|
356
374
|
|
|
357
375
|
/**
|
|
@@ -428,7 +446,7 @@ export function loadPack(packId, cwd = process.cwd()) {
|
|
|
428
446
|
if (cached) return cached;
|
|
429
447
|
|
|
430
448
|
const pack = readPackFile(path, id);
|
|
431
|
-
compilePackRegexes(pack);
|
|
449
|
+
compilePackRegexes(pack, isBuiltinPackPath(path));
|
|
432
450
|
packCache.set(cacheKey, pack);
|
|
433
451
|
return pack;
|
|
434
452
|
}
|
|
@@ -746,31 +764,34 @@ export function addPolicyPackFromPackage(name, options = {}) {
|
|
|
746
764
|
* construction here cannot throw a new error the caller hasn't already seen.
|
|
747
765
|
* @param {PolicyPack} pack
|
|
748
766
|
*/
|
|
749
|
-
function compilePackRegexes(pack) {
|
|
767
|
+
function compilePackRegexes(pack, trusted = false) {
|
|
750
768
|
for (const rule of pack.rules ?? []) {
|
|
751
|
-
compileClauseRegexes(rule);
|
|
752
|
-
for (const clause of rule.allOf ?? []) compileClauseRegexes(clause);
|
|
753
|
-
for (const clause of rule.anyOf ?? []) compileClauseRegexes(clause);
|
|
769
|
+
compileClauseRegexes(rule, trusted);
|
|
770
|
+
for (const clause of rule.allOf ?? []) compileClauseRegexes(clause, trusted);
|
|
771
|
+
for (const clause of rule.anyOf ?? []) compileClauseRegexes(clause, trusted);
|
|
754
772
|
}
|
|
755
773
|
}
|
|
756
774
|
|
|
757
|
-
/**
|
|
758
|
-
|
|
775
|
+
/**
|
|
776
|
+
* @param {PolicyRule | PolicyMatchClause} clause
|
|
777
|
+
* @param {boolean} [trusted]
|
|
778
|
+
*/
|
|
779
|
+
function compileClauseRegexes(clause, trusted = false) {
|
|
759
780
|
if (clause.match?.path !== undefined) {
|
|
760
781
|
Object.defineProperty(clause.match, '_pathRegex', {
|
|
761
|
-
value:
|
|
782
|
+
value: compilePattern(clause.match.path, clause.match.pathFlags ?? '', { trusted }),
|
|
762
783
|
enumerable: false,
|
|
763
784
|
});
|
|
764
785
|
}
|
|
765
786
|
if (clause.afterMatches !== undefined) {
|
|
766
787
|
Object.defineProperty(clause, '_afterMatchesRegex', {
|
|
767
|
-
value:
|
|
788
|
+
value: compilePattern(clause.afterMatches, '', { trusted }),
|
|
768
789
|
enumerable: false,
|
|
769
790
|
});
|
|
770
791
|
}
|
|
771
792
|
if (clause.afterAnyMatches !== undefined) {
|
|
772
793
|
Object.defineProperty(clause, '_afterAnyMatchesRegex', {
|
|
773
|
-
value:
|
|
794
|
+
value: compilePattern(clause.afterAnyMatches, '', { trusted }),
|
|
774
795
|
enumerable: false,
|
|
775
796
|
});
|
|
776
797
|
}
|
|
@@ -781,10 +802,13 @@ function compileClauseRegexes(clause) {
|
|
|
781
802
|
* @returns {RegExp}
|
|
782
803
|
*/
|
|
783
804
|
function pathRegexFor(match) {
|
|
784
|
-
// Packs loaded via loadPack() always carry a pre-compiled regex here
|
|
785
|
-
// fallback
|
|
786
|
-
//
|
|
787
|
-
|
|
805
|
+
// Packs loaded via loadPack() always carry a pre-compiled regex here. The
|
|
806
|
+
// fallback covers a pack built some other way (bypassing loadPack), whose
|
|
807
|
+
// provenance is therefore unknown -- so it compiles as UNTRUSTED, which is
|
|
808
|
+
// the fail-safe direction but not what it did before RE2. Such a pack using
|
|
809
|
+
// lookahead now throws during evaluation rather than at load, and each call
|
|
810
|
+
// recompiles. Both are acceptable for a path no shipped caller takes.
|
|
811
|
+
return match._pathRegex ?? compilePattern(match.path, match.pathFlags ?? '');
|
|
788
812
|
}
|
|
789
813
|
|
|
790
814
|
/**
|
|
@@ -792,7 +816,7 @@ function pathRegexFor(match) {
|
|
|
792
816
|
* @returns {RegExp}
|
|
793
817
|
*/
|
|
794
818
|
function afterMatchesRegexFor(clause) {
|
|
795
|
-
return clause._afterMatchesRegex ??
|
|
819
|
+
return clause._afterMatchesRegex ?? compilePattern(clause.afterMatches);
|
|
796
820
|
}
|
|
797
821
|
|
|
798
822
|
/**
|
|
@@ -800,7 +824,7 @@ function afterMatchesRegexFor(clause) {
|
|
|
800
824
|
* @returns {RegExp}
|
|
801
825
|
*/
|
|
802
826
|
function afterAnyMatchesRegexFor(clause) {
|
|
803
|
-
return clause._afterAnyMatchesRegex ??
|
|
827
|
+
return clause._afterAnyMatchesRegex ?? compilePattern(clause.afterAnyMatches);
|
|
804
828
|
}
|
|
805
829
|
|
|
806
830
|
/**
|