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/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
|
|
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}
|
package/src/snapshot-store.js
CHANGED
|
@@ -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
|
|
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`
|