flecto 3.0.2 → 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');
@@ -296,6 +328,33 @@ async function errorDetail(response, token) {
296
328
  return safe ? `: ${safe}` : '';
297
329
  }
298
330
 
331
+ /**
332
+ * @param {Response} response
333
+ * @returns {boolean}
334
+ */
335
+ function isRedirect(response) {
336
+ // `redirect: 'manual'` surfaces the 3xx itself in Node; a browser-shaped
337
+ // opaque redirect reports status 0 with `type: 'opaqueredirect'`.
338
+ return (response.status >= 300 && response.status < 400) || response.type === 'opaqueredirect';
339
+ }
340
+
341
+ /**
342
+ * The origin a redirect points at, for the error message. Only the origin: the
343
+ * rest of a `Location` is not worth echoing into a log.
344
+ * @param {Response} response
345
+ * @param {string} url
346
+ * @returns {string}
347
+ */
348
+ function redirectTarget(response, url) {
349
+ const location = response.headers?.get?.('location');
350
+ if (!location) return '';
351
+ try {
352
+ return ` to ${new URL(location, url).origin}`;
353
+ } catch {
354
+ return '';
355
+ }
356
+ }
357
+
299
358
  /**
300
359
  * @param {{ fetchImpl: typeof fetch, provider: object, url: string, method: string, token: string, body?: unknown, timeoutMs: number }} request
301
360
  * @returns {Promise<Response>}
@@ -315,6 +374,13 @@ async function apiRequest({ fetchImpl, provider, url, method, token, body, timeo
315
374
  },
316
375
  body: body === undefined ? undefined : JSON.stringify(body),
317
376
  signal: controller.signal,
377
+ // Never follow a redirect with a credential attached. `fetch` strips
378
+ // `Authorization` when a redirect crosses origins, but it strips *only*
379
+ // that header — GitLab authenticates with `PRIVATE-TOKEN`, which is
380
+ // forwarded to wherever the API host points, verified against a local
381
+ // server. Refusing the redirect is the whole fix: these endpoints do not
382
+ // legitimately redirect, and one that does is worth seeing.
383
+ redirect: 'manual',
318
384
  });
319
385
  } catch (err) {
320
386
  const reason = err?.name === 'AbortError'
@@ -325,6 +391,14 @@ async function apiRequest({ fetchImpl, provider, url, method, token, body, timeo
325
391
  clearTimeout(timer);
326
392
  }
327
393
 
394
+ if (isRedirect(response)) {
395
+ throw new Error(
396
+ `${provider.label} API ${method} was redirected (HTTP ${response.status}`
397
+ + `${redirectTarget(response, url)}), and Flecto does not follow a redirect with an API`
398
+ + ' token attached. Point the API URL at the host that answers directly.',
399
+ );
400
+ }
401
+
328
402
  if (!response.ok) {
329
403
  throw new Error(
330
404
  `${provider.label} API ${method} returned HTTP ${response.status}${await errorDetail(response, token)}`,
@@ -229,8 +229,15 @@ const bitbucket = {
229
229
  readOne: (payload) => ({ url: payload?.links?.html?.href }),
230
230
  };
231
231
 
232
+ /**
233
+ * Comments collection for the pull request. The workspace and slug are encoded
234
+ * for the same reason GitLab's project id is: they are interpolated into a URL
235
+ * path, and a value carrying `/`, `?`, or `#` would otherwise restructure the
236
+ * request rather than name a repository.
237
+ */
232
238
  function bitbucketCommentsBase(c) {
233
- return `${c.apiUrl}/repositories/${c.workspace}/${c.repoSlug}/pullrequests/${c.prNumber}/comments`;
239
+ return `${c.apiUrl}/repositories/${encodeURIComponent(c.workspace)}`
240
+ + `/${encodeURIComponent(c.repoSlug)}/pullrequests/${c.prNumber}/comments`;
234
241
  }
235
242
 
236
243
  /** Detection order. GitHub stays first so its behavior is unchanged. */
@@ -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
@@ -1,20 +1,8 @@
1
1
  import chalk from 'chalk';
2
- import { redactSecretString } from './secrets.js';
2
+ import { looksLikeSecretPath, redactSecretString } from './secrets.js';
3
3
  import { ENCRYPTED_DISPLAY, displayEncrypted, isEncryptedSentinel } from './encrypted.js';
4
4
  import { secretMatchPath } from './differ.js';
5
5
 
6
- /**
7
- * Key names that mean "this value is a credential".
8
- *
9
- * Matched against the *configuration* path only. A multi-document file prefixes
10
- * every path with the document's identity — `Deployment/prod/token-service.…` —
11
- * and that identity is a resource name the user chose, not a key name. Letting
12
- * it match here masked every value in the document, numbers and booleans
13
- * included, so the path reaching this regex is always the one
14
- * {@link secretMatchPath} produced.
15
- */
16
- const SECRET_PATH_RE = /(secret|token|password|api[_-]?key|private[_-]?key|credential)/i;
17
-
18
6
  /**
19
7
  * Format a scalar value for display. Strings get quoted; others are JSON-stringified.
20
8
  * @param {unknown} v
@@ -28,7 +16,7 @@ function fmt(v, opts = {}) {
28
16
  if (isEncryptedSentinel(v)) return chalk.dim(ENCRYPTED_DISPLAY);
29
17
  let value = displayEncrypted(v);
30
18
  if (opts.maskSecrets) {
31
- if (opts.path && SECRET_PATH_RE.test(opts.path)) {
19
+ if (opts.path && looksLikeSecretPath(opts.path)) {
32
20
  return chalk.dim('"***"');
33
21
  }
34
22
  // The changed path itself can look benign while the value carries secrets,
@@ -202,7 +190,7 @@ export function renderPolicyFindings(findings) {
202
190
  * @returns {unknown}
203
191
  */
204
192
  export function maskSensitiveValue(value, path = '') {
205
- if (SECRET_PATH_RE.test(path)) return '***';
193
+ if (looksLikeSecretPath(path)) return '***';
206
194
  if (Array.isArray(value)) {
207
195
  return value.map((v, i) => maskSensitiveValue(v, `${path}[${i}]`));
208
196
  }
@@ -223,6 +211,30 @@ export function maskSensitiveValue(value, path = '') {
223
211
  return value;
224
212
  }
225
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
+
226
238
  /**
227
239
  * @param {import('./differ.js').ChangeEvent} event
228
240
  * @returns {import('./differ.js').ChangeEvent}
package/src/report.js CHANGED
@@ -31,7 +31,8 @@ import { isAbsolute, relative } from 'path';
31
31
  * cwd?: string,
32
32
  * version?: string,
33
33
  * limit?: number,
34
- * maskSecrets?: boolean
34
+ * maskSecrets?: boolean,
35
+ * store?: string
35
36
  * }} ReportData
36
37
  */
37
38
 
@@ -657,7 +658,8 @@ function footer(data) {
657
658
  const limit = Number.isInteger(data.limit)
658
659
  ? ` Limited to the ${escapeHtml(plural(data.limit, 'most recent snapshot'))}.`
659
660
  : '';
660
- return '<footer>Generated by Flecto from <code>.flecto-snapshots/</code>.'
661
+ const store = escapeHtml(String(data.store ?? '.flecto-snapshots/'));
662
+ return `<footer>Generated by Flecto from <code>${store}</code>.`
661
663
  + ' This file is self-contained: no external scripts, fonts, or images, and nothing'
662
664
  + ` is sent anywhere when you open it.${limit}</footer>`;
663
665
  }
package/src/secrets.js CHANGED
@@ -330,6 +330,33 @@ export function redactSecretString(value) {
330
330
  return out + value.slice(cursor);
331
331
  }
332
332
 
333
+ /**
334
+ * Key names that mean "this value is a credential".
335
+ *
336
+ * Matched against the *configuration* path only. A multi-document file prefixes
337
+ * every path with the document's identity — `Deployment/prod/token-service.…` —
338
+ * and that identity is a resource name the user chose, not a key name. Letting
339
+ * it match masks every value in the document, numbers and booleans included, so
340
+ * callers strip the document prefix before matching: see `secretMatchPath` in
341
+ * differ.js, and the document handling in `maskState`.
342
+ */
343
+ export const SECRET_PATH_RE = /(secret|token|password|api[_-]?key|private[_-]?key|credential)/i;
344
+
345
+ /**
346
+ * True when a configuration path names a credential.
347
+ *
348
+ * Lives here rather than beside either caller because there are two of them —
349
+ * the renderer masking a diff for display, and the snapshot store masking a
350
+ * state for a commit — and a store that recognized fewer key names than the
351
+ * terminal did would write into git history exactly the values the terminal
352
+ * thought were too sensitive to print.
353
+ * @param {string} path
354
+ * @returns {boolean}
355
+ */
356
+ export function looksLikeSecretPath(path) {
357
+ return SECRET_PATH_RE.test(path);
358
+ }
359
+
333
360
  /**
334
361
  * True when a value — or any string nested inside a plain object or array —
335
362
  * looks like a secret.