flecto 3.1.0 → 4.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/src/pr-comment.js CHANGED
@@ -62,6 +62,35 @@ function truncate(text, max) {
62
62
  return text.length > max ? `${text.slice(0, max)}…` : text;
63
63
  }
64
64
 
65
+ /**
66
+ * The `flecto explain` narration (#143), rendered so it cannot pass for Flecto's
67
+ * own output and cannot do anything but be read.
68
+ *
69
+ * The model saw pull-request-authored text, so its output is untrusted. It goes
70
+ * inside a fenced block, fence wider than any backtick run in it, which is the
71
+ * one markdown construct where links, images, `@`-mentions, and HTML all render
72
+ * as literal text.
73
+ * @param {{ text: string, provider: string, model: string, cached?: boolean, truncated?: boolean }} narration
74
+ * @returns {string[]}
75
+ */
76
+ function narrationSection(narration) {
77
+ const text = String(narration.text);
78
+ const runs = text.match(/`+/g) ?? [];
79
+ const fence = '`'.repeat(Math.max(3, ...runs.map((run) => run.length + 1)));
80
+ return [
81
+ '### Model-generated narration (advisory)',
82
+ '',
83
+ `<sub>Written by ${inlineCode(`${narration.provider} ${narration.model}`)}`
84
+ + `${narration.cached ? ' (cached)' : ''} from the masked semantic diff. Not computed by Flecto,`
85
+ + ' not a policy finding, and never part of this check\'s result.</sub>',
86
+ '',
87
+ `${fence}text`,
88
+ text,
89
+ fence,
90
+ ...(narration.truncated ? ['', '_Cut off at the output token limit._'] : []),
91
+ ];
92
+ }
93
+
65
94
  /**
66
95
  * Format a change value for display, or return null when the side is absent.
67
96
  * @param {unknown} value
@@ -119,7 +148,8 @@ function findingsOf(result) {
119
148
  * Pure: it reads nothing but its arguments, so the exact body posted to GitHub
120
149
  * is the body printed to stdout.
121
150
  * @param {CiResult[]} results
122
- * @param {{ cwd?: string, failed?: boolean, marker?: string, maxInlineChanges?: number, maxBodyChars?: number }} [options]
151
+ * @param {{ cwd?: string, failed?: boolean, marker?: string, maxInlineChanges?: number, maxBodyChars?: number,
152
+ * narration?: { text: string, provider: string, model: string, cached?: boolean, truncated?: boolean } | null }} [options]
123
153
  * @returns {string} Markdown, always beginning with the sticky marker
124
154
  */
125
155
  export function renderPrComment(results, options = {}) {
@@ -195,6 +225,8 @@ export function renderPrComment(results, options = {}) {
195
225
  }
196
226
  }
197
227
 
228
+ if (options.narration) lines.push('', ...narrationSection(options.narration));
229
+
198
230
  if (totalChanges > 0) {
199
231
  const collapse = totalChanges > maxInlineChanges;
200
232
  lines.push('', '### Changes');
@@ -0,0 +1,138 @@
1
+ import { RE2JS } from 're2js';
2
+
3
+ /**
4
+ * Regular-expression compilation, split by who wrote the pattern.
5
+ *
6
+ * Policy packs accept user-supplied regexes (`match.path`, `afterMatches`,
7
+ * `afterAnyMatches`), and on an untrusted pull request the pack file is
8
+ * attacker-controlled: a committed `policies/evil.json` plus a `.flectorc`
9
+ * selecting it is all it takes. JavaScript's own engine backtracks, so
10
+ * `^(a+)+$` against a 45-character string is not slow but effectively
11
+ * non-terminating -- measured here at **97 seconds** where RE2 answers in 3ms.
12
+ * A CI job that never finishes is a denial of service against the merge gate
13
+ * itself, and no timeout inside the process helps, because the backtracking
14
+ * happens inside a single uninterruptible call into the engine.
15
+ *
16
+ * So patterns are compiled by provenance:
17
+ *
18
+ * - **Trusted** -- the packs Flecto ships in `src/packs/`. These are reviewed,
19
+ * change only in a release, and are not reachable by a pull request. They
20
+ * keep the native engine, which costs nothing and leaves their existing
21
+ * syntax (including the negative lookahead in `github-actions.json`) working.
22
+ * - **Untrusted** -- anything loaded from a repository's `policies/` directory
23
+ * or added by `flecto policies add`. These compile with RE2, whose matching
24
+ * is linear in the length of the input by construction.
25
+ *
26
+ * The split is provenance, not content: a local pack that *overrides* a
27
+ * built-in id is still local, and still untrusted.
28
+ *
29
+ * RE2 deliberately omits lookaround and backreferences, because neither is a
30
+ * regular operation and both are what make backtracking unbounded. A pack
31
+ * using them now fails to *load*, with a message naming the rule, rather than
32
+ * hanging at match time. That is the breaking part of this change, and it is
33
+ * why it ships in a major version.
34
+ */
35
+
36
+ /**
37
+ * A compiled pattern, with the only operation the policy engine performs.
38
+ * @typedef {{ test: (value: string) => boolean, source: string, engine: 'native' | 're2' }} CompiledPattern
39
+ */
40
+
41
+ /**
42
+ * Translate JavaScript regex flags into RE2 flags.
43
+ *
44
+ * `g` and `y` are accepted and dropped: both only mean anything to a stateful
45
+ * `lastIndex`, which `test()`-style matching does not use, and RE2's matcher
46
+ * searches the whole input anyway. `u` and `v` are accepted and dropped
47
+ * because RE2 is Unicode-aware natively. Anything else is refused rather than
48
+ * silently ignored, so a pack asking for behaviour it will not get finds out.
49
+ * @param {string} flags
50
+ * @returns {number}
51
+ */
52
+ function re2Flags(flags) {
53
+ let out = 0;
54
+ for (const flag of flags) {
55
+ if (flag === 'i') out |= RE2JS.CASE_INSENSITIVE;
56
+ else if (flag === 'm') out |= RE2JS.MULTILINE;
57
+ else if (flag === 's') out |= RE2JS.DOTALL;
58
+ else if (flag === 'g' || flag === 'y' || flag === 'u' || flag === 'v') continue;
59
+ else throw new Error(`unsupported regular expression flag "${flag}"`);
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * Compile a pattern written by whoever controls the pack file.
66
+ *
67
+ * @param {string} pattern
68
+ * @param {string} [flags]
69
+ * @param {{ trusted?: boolean }} [options] `trusted` only for packs Flecto ships
70
+ * @returns {CompiledPattern}
71
+ * @throws {Error} when the pattern does not compile on the chosen engine
72
+ */
73
+ export function compilePattern(pattern, flags = '', options = {}) {
74
+ if (options.trusted) {
75
+ const re = new RegExp(pattern, flags);
76
+ // A `g`/`y` pattern carries a mutable lastIndex, and .test() advances it,
77
+ // so the same regex reused across values would skip matches. Reset per
78
+ // call rather than per pack: packs are cached and shared between files.
79
+ return {
80
+ source: pattern,
81
+ engine: 'native',
82
+ test: (value) => {
83
+ re.lastIndex = 0;
84
+ return re.test(value);
85
+ },
86
+ };
87
+ }
88
+ const compiled = RE2JS.compile(pattern, re2Flags(flags));
89
+ return {
90
+ source: pattern,
91
+ engine: 're2',
92
+ test: (value) => compiled.matcher(value).find(),
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Whether a pattern compiles, without throwing.
98
+ *
99
+ * Used by pack validation so a bad pattern is reported with its rule location
100
+ * rather than as a bare engine error.
101
+ * @param {string} pattern
102
+ * @param {string} [flags]
103
+ * @param {{ trusted?: boolean }} [options]
104
+ * @returns {{ ok: true } | { ok: false, reason: string }}
105
+ */
106
+ export function checkPattern(pattern, flags = '', options = {}) {
107
+ try {
108
+ compilePattern(pattern, flags, options);
109
+ return { ok: true };
110
+ } catch (error) {
111
+ return { ok: false, reason: error?.message ?? String(error) };
112
+ }
113
+ }
114
+
115
+ /**
116
+ * The advice appended when an untrusted pack uses syntax RE2 does not support.
117
+ *
118
+ * Worth being specific: "invalid regular expression" sends someone hunting for
119
+ * a typo in a pattern that is perfectly valid JavaScript.
120
+ * @param {string} reason
121
+ * @returns {string}
122
+ */
123
+ export function explainPatternFailure(reason) {
124
+ // Order matters: an escape failure also contains "invalid escape sequence",
125
+ // and telling someone to remove lookahead from a pattern that has none is
126
+ // worse than saying nothing.
127
+ if (/invalid escape sequence: `\\[ucC]/.test(reason)) {
128
+ return `${reason}. RE2 spells a unicode escape \`\\x{41}\`, not \`\\u0041\`, and has no`
129
+ + ' control-character escape. Policy packs outside src/packs/ are matched with RE2.';
130
+ }
131
+ if (/Perl syntax|invalid escape sequence|lookbehind|invalid named capture/i.test(reason)) {
132
+ return `${reason}. Policy packs outside src/packs/ are matched with RE2, which does not`
133
+ + ' support lookahead, lookbehind, or backreferences -- they are what make backtracking'
134
+ + ' unbounded, and a pack is attacker-controlled on an untrusted pull request. Rewrite the'
135
+ + ' pattern without them.';
136
+ }
137
+ return reason;
138
+ }
package/src/renderer.js CHANGED
@@ -211,6 +211,30 @@ export function maskSensitiveValue(value, path = '') {
211
211
  return value;
212
212
  }
213
213
 
214
+ /**
215
+ * Redact secret-shaped text from policy messages. A rule using
216
+ * `messageTemplate` can interpolate `{before}` / `{after}`, so a finding can
217
+ * carry a credential even when the change events beside it are masked. Replace
218
+ * exact interpolated values using the same path-aware masking as change events,
219
+ * then catch any other recognizable secret fragments in free-form messages.
220
+ * @param {import('./policy.js').PolicyFinding[]} findings
221
+ * @param {import('./differ.js').ChangeEvent[]} changes the unmasked events
222
+ * @returns {import('./policy.js').PolicyFinding[]}
223
+ */
224
+ export function maskFindings(findings, changes) {
225
+ return findings.map((finding) => {
226
+ let message = String(finding.message ?? '');
227
+ for (const change of changes.filter((event) => event.path === finding.path)) {
228
+ for (const value of [change.before, change.after]) {
229
+ const original = String(value);
230
+ const masked = String(maskSensitiveValue(value, secretMatchPath(change)));
231
+ if (original && original !== masked) message = message.replaceAll(original, masked);
232
+ }
233
+ }
234
+ return { ...finding, message: redactSecretString(message) };
235
+ });
236
+ }
237
+
214
238
  /**
215
239
  * @param {import('./differ.js').ChangeEvent} event
216
240
  * @returns {import('./differ.js').ChangeEvent}
@@ -9,7 +9,7 @@ import {
9
9
  } from 'fs';
10
10
  import { createHash } from 'crypto';
11
11
  import { execFileSync } from 'child_process';
12
- import { dirname, join, relative, resolve, sep } from 'path';
12
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'path';
13
13
 
14
14
  import { containsSecret, looksLikeSecretPath } from './secrets.js';
15
15
  import { documentKeysOf, withDocumentKeys } from './documents.js';
@@ -374,12 +374,16 @@ function createSharedStore({ root, projectRoot, retention, maskMode }) {
374
374
  * slashed. A file outside the project has no such key, and inventing one (a
375
375
  * hash, an absolute path) would produce a store entry that is meaningless on
376
376
  * any other checkout — so it is refused rather than written somewhere useless.
377
+ *
378
+ * "Outside" includes a file `relative` cannot reach at all: on Windows a target
379
+ * on another drive or a UNC share comes back *absolute* (`D:\etc\hosts`), not
380
+ * `..`-prefixed, and would otherwise be accepted as a key.
377
381
  * @param {string} absFile
378
382
  * @returns {string}
379
383
  */
380
384
  const keyFor = (absFile) => {
381
385
  const rel = relative(projectRoot, resolve(absFile));
382
- if (!rel || rel.startsWith('..') || rel.startsWith(`..${sep}`)) {
386
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) {
383
387
  throw new Error(
384
388
  `The shared snapshot store keys snapshots by repo-relative path, and "${absFile}" is`
385
389
  + ` outside ${projectRoot}. Run Flecto from the repository that holds the file, or use`