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/CHANGELOG.md +257 -1
- package/README.md +12 -1
- package/drift.js +226 -0
- package/index.js +672 -152
- package/package.json +10 -7
- package/src/alerter.js +272 -24
- package/src/config.js +369 -3
- package/src/differ.js +76 -12
- 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 +1057 -0
- package/src/pr-comment.js +75 -1
- package/src/pr-providers.js +8 -1
- package/src/regex-engine.js +138 -0
- package/src/renderer.js +27 -15
- package/src/report.js +4 -2
- package/src/secrets.js +27 -0
- package/src/snapshot-store.js +672 -0
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');
|
|
@@ -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)}`,
|
package/src/pr-providers.js
CHANGED
|
@@ -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}
|
|
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 &&
|
|
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 (
|
|
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
|
-
|
|
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.
|