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/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
- .replace(/^match\.path is not a valid regular expression$/, 'match.path must be a valid regular expression');
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
- try {
297
- new RegExp(typedMatch.path, typedMatch.pathFlags ?? '');
298
- } catch {
299
- if (typedMatch.pathFlags !== undefined) {
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(`Invalid policy rule at ${location}: match.path is not a valid regular expression`);
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
- try {
332
- new RegExp(value);
333
- } catch {
334
- throw new Error(`Invalid policy rule at ${location}: ${name} is not a valid regular expression`);
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
- /** @param {PolicyRule | PolicyMatchClause} clause */
758
- function compileClauseRegexes(clause) {
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: new RegExp(clause.match.path, clause.match.pathFlags ?? ''),
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: new RegExp(clause.afterMatches),
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: new RegExp(clause.afterAnyMatches),
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; the
785
- // fallback exists only so a pack built some other way (bypassing loadPack)
786
- // still behaves exactly as it did before regex compilation was hoisted.
787
- return match._pathRegex ?? new RegExp(match.path, match.pathFlags ?? '');
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 ?? new RegExp(clause.afterMatches);
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 ?? new RegExp(clause.afterAnyMatches);
827
+ return clause._afterAnyMatchesRegex ?? compilePattern(clause.afterAnyMatches);
804
828
  }
805
829
 
806
830
  /**