flecto 3.0.2 → 3.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/config.js CHANGED
@@ -1,4 +1,13 @@
1
- import { existsSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'fs';
1
+ import {
2
+ existsSync,
3
+ lstatSync,
4
+ readFileSync,
5
+ readdirSync,
6
+ readlinkSync,
7
+ realpathSync,
8
+ statSync,
9
+ writeFileSync,
10
+ } from 'fs';
2
11
  import { basename, dirname, join, relative, resolve, sep } from 'path';
3
12
  import fg from 'fast-glob';
4
13
  import yaml from 'js-yaml';
@@ -216,6 +225,157 @@ export function assertTargetContained(file, cwd = process.cwd()) {
216
225
  );
217
226
  }
218
227
 
228
+ /**
229
+ * Has the operator explicitly opted in to write destinations that `.flectorc`
230
+ * points outside the project?
231
+ * @returns {boolean}
232
+ */
233
+ function rcWritesAllowed() {
234
+ const raw = process.env.FLECTO_ALLOW_RC_WRITES;
235
+ return raw === '1' || String(raw).toLowerCase() === 'true';
236
+ }
237
+
238
+ /**
239
+ * Refuse a *write* destination that leaves the project.
240
+ *
241
+ * The read rule ({@link assertTargetContained}) is about escape, and treats a
242
+ * path named from outside the project as operator intent. A write cannot be read
243
+ * that way, because the path is not always the operator's: `output` and
244
+ * `baseline` can be declared in `.flectorc`, and on an untrusted pull request
245
+ * `.flectorc` is attacker-controlled. Both destinations carry content the
246
+ * attacker partly controls — the report embeds config values and file names, a
247
+ * baseline embeds rule ids, paths, and messages — so an unconstrained
248
+ * destination is an arbitrary file overwrite with partly chosen content, which
249
+ * on a CI runner is a shell profile, a workflow file, or an SSH config.
250
+ *
251
+ * Two rules, matching the provenance:
252
+ *
253
+ * - **Declared in `.flectorc`** — must resolve inside the project.
254
+ * `FLECTO_ALLOW_RC_WRITES=1` opts out, for a repository that genuinely
255
+ * configures a destination elsewhere.
256
+ * - **Any source** — must not leave the project through a symlink, on the path
257
+ * itself or on the directory it is written into. That is the shape a pull
258
+ * request can author without touching the workflow, and it is refused for the
259
+ * same reason a symlinked *target* is; `FLECTO_ALLOW_SYMLINK_TARGETS=1` opts
260
+ * out of this half, as it does for reads.
261
+ *
262
+ * Refusing loudly rather than falling back to a default path is deliberate: a
263
+ * report that silently went somewhere else is worse than one that was not
264
+ * written.
265
+ * @param {string} file destination path, already resolved against the cwd
266
+ * @param {{ option: string, fromCli?: boolean, cwd?: string }} context
267
+ * @throws {Error} when the destination escapes the project
268
+ */
269
+ export function assertWriteDestinationContained(file, context) {
270
+ const { option, fromCli = false } = context;
271
+ const cwd = context.cwd ?? process.cwd();
272
+ const lexicalRoot = resolve(cwd);
273
+ const root = canonical(lexicalRoot);
274
+ const given = resolve(cwd, file);
275
+ const shown = relative(lexicalRoot, given).split(sep).join('/') || given;
276
+
277
+ // A destination need not exist yet, and neither need the directories above it,
278
+ // so normalize from the nearest ancestor that does: canonical() cannot resolve
279
+ // a path that is not there, and its fallback leaves the spelling as written —
280
+ // which on Windows is an 8.3 short name that compares as a different directory
281
+ // from the long form `root` carries, making an in-project path look external.
282
+ const { base, rest } = nearestExistingDir(dirname(given));
283
+ const nominal = join(canonical(base), ...rest, basename(given));
284
+ // Either spelling counts as inside: the lexical form covers a path derived
285
+ // from the cwd, the canonical form a path named some other way.
286
+ const insideProject = isInside(given, lexicalRoot) || isInside(nominal, root);
287
+
288
+ if (!fromCli && !rcWritesAllowed() && !insideProject) {
289
+ throw new Error(
290
+ `Refusing to write "${option}" to ${given}: .flectorc points it outside the project.\n`
291
+ + '.flectorc is attacker-controlled on an untrusted pull request, and this file carries '
292
+ + 'config values and file names into whatever it overwrites.\n'
293
+ + `Pass ${option} on the command line for a destination outside the project, or set `
294
+ + 'FLECTO_ALLOW_RC_WRITES=1 if this repository genuinely configures one.',
295
+ );
296
+ }
297
+
298
+ if (symlinkTargetsAllowed()) return;
299
+
300
+ // Two ways a write leaves through a link: the destination is itself a link, or
301
+ // the nearest directory that exists on the way to it is. The second covers a
302
+ // path several levels deep whose parents have not been created yet.
303
+ for (const candidate of [given, base]) {
304
+ const real = linkDestination(candidate);
305
+ // Nothing there at all: no link to follow, so nothing to refuse.
306
+ if (real === null) continue;
307
+ // Named from outside the project: nothing escaped, it was never inside.
308
+ if (!isInside(candidate, lexicalRoot)) continue;
309
+ if (isInside(real, root)) continue;
310
+ throw new Error(
311
+ `Refusing to write "${option}" to "${shown}": it is a link out of the project, `
312
+ + `resolving to ${real}.\n`
313
+ + 'File names and links are attacker-controlled on an untrusted pull request, and '
314
+ + 'writing through one would overwrite a file outside the repository.\n'
315
+ + 'Set FLECTO_ALLOW_SYMLINK_TARGETS=1 if this link is intentional.',
316
+ );
317
+ }
318
+ }
319
+
320
+ /**
321
+ * How many links to walk before giving up. The OS gives up around 40; this only
322
+ * needs to outlast any chain a repository would legitimately contain.
323
+ */
324
+ const MAX_LINK_HOPS = 32;
325
+
326
+ /**
327
+ * Where a path actually lands, following links even when the chain ends
328
+ * somewhere that does not exist yet.
329
+ *
330
+ * `existsSync` follows links, so it reports *nothing* for a link whose target is
331
+ * missing — and `realpathSync` fails on one, leaving {@link canonical} to fall
332
+ * back to the path as written, which is still inside the project. So a link to a
333
+ * file the runner does not have yet reads as contained twice over. That is not
334
+ * the weaker half of this attack but the stronger one: overwriting needs the
335
+ * file to already be there, while a link to a file the runner does *not* have
336
+ * yet has Flecto create it. `~/.ssh/authorized_keys` is the one that stings,
337
+ * because sshd skips lines it cannot parse, so HTML wrapped around one
338
+ * attacker-authored line is still a usable key file.
339
+ *
340
+ * So the link is resolved by hand, hop by hop, and judged wherever it ends up.
341
+ * @param {string} path
342
+ * @param {number} [depth]
343
+ * @returns {string | null} null when there is nothing at `path` at all
344
+ */
345
+ function linkDestination(path, depth = 0) {
346
+ const stats = lstatSync(path, { throwIfNoEntry: false });
347
+ if (!stats) return null;
348
+ if (!stats.isSymbolicLink()) return canonical(path);
349
+
350
+ const target = resolve(dirname(path), readlinkSync(path));
351
+ // A cycle, or a chain long enough that the write would fail with ELOOP
352
+ // anyway: judge the hop we have rather than walking forever.
353
+ if (depth >= MAX_LINK_HOPS) return target;
354
+ // The next hop may not exist; normalize what is there and keep the rest as
355
+ // written, the same way an ordinary destination is normalized above.
356
+ const { base, rest } = nearestExistingDir(dirname(target));
357
+ return linkDestination(target, depth + 1) ?? join(canonical(base), ...rest, basename(target));
358
+ }
359
+
360
+ /**
361
+ * The nearest ancestor of a path that exists on disk, plus the segments below it
362
+ * that do not.
363
+ * @param {string} path
364
+ * @returns {{ base: string, rest: string[] }}
365
+ */
366
+ function nearestExistingDir(path) {
367
+ let current = resolve(path);
368
+ /** @type {string[]} */
369
+ const rest = [];
370
+ while (!existsSync(current)) {
371
+ const parent = dirname(current);
372
+ if (parent === current) break;
373
+ rest.unshift(basename(current));
374
+ current = parent;
375
+ }
376
+ return { base: current, rest };
377
+ }
378
+
219
379
  /**
220
380
  * Split a policy list that may arrive as an array or a comma-separated string.
221
381
  * @param {unknown} raw
package/src/differ.js CHANGED
@@ -179,6 +179,49 @@ function matchParts(patternParts, pathParts) {
179
179
  return true;
180
180
  }
181
181
 
182
+ /**
183
+ * The containers on the path currently being compared, one set per side.
184
+ *
185
+ * A cyclic tree makes the walk reach a node it is already inside. Descending
186
+ * again cannot find anything the in-progress comparison will not already report
187
+ * at a shorter path, so the back-edge is dropped rather than followed. Same
188
+ * ancestor-tracking pattern as `normalizeParsedValue` in parser.js and
189
+ * `collectLeaves` in policy.js: added on the way in, removed on the way out, so
190
+ * a value merely reached twice by separate branches (a DAG, not a cycle) is
191
+ * still compared in full on each branch.
192
+ *
193
+ * Either side repeating is enough to stop. Tracking the *pair* instead would be
194
+ * more precise -- it would keep descending where one side cycles and the other
195
+ * does not -- but two cyclic trees have |before| x |after| distinct pairs, so
196
+ * precision there buys a quadratic worst case. This is a guard against a walk
197
+ * that will not end; bounding it by the size of the input is the property worth
198
+ * having.
199
+ *
200
+ * Nothing downstream of the parser holds a cycle -- `normalizeParsedValue`
201
+ * replaces one with its circular sentinel before a tree ever gets here -- so
202
+ * this is a second line rather than the first. Without it the walk recurses
203
+ * until the stack overflows, which costs real time on the way down (the path
204
+ * string is rebuilt at every level) before throwing.
205
+ *
206
+ * This covers the structural walk only. `arraySignature` canonicalizes without
207
+ * it -- that runs per array element, where the same tracking cost ~15% on a
208
+ * 5000-element diff, and its `catch` already contains a cyclic element by
209
+ * falling back to identity comparison.
210
+ *
211
+ * It is not free: the set writes cost ~20% of `diffTrees`, which the benchmark
212
+ * puts at ~17% of a run, so ~3% end to end. Gating it behind a depth threshold
213
+ * removes that, and was tried -- but then a cycle is followed as far as the
214
+ * threshold, and every level of it emits a duplicate event under a
215
+ * `self.self.self...` path. Paying 3% to keep the output right is the better
216
+ * trade for a tool whose output is read on a pull request.
217
+ * @typedef {{ before: Set<object>, after: Set<object> }} Ancestors
218
+ */
219
+
220
+ /** @returns {Ancestors} */
221
+ function newAncestors() {
222
+ return { before: new Set(), after: new Set() };
223
+ }
224
+
182
225
  /**
183
226
  * Recursively diff two values at a given key path.
184
227
  * @param {unknown} before
@@ -186,8 +229,9 @@ function matchParts(patternParts, pathParts) {
186
229
  * @param {string} path
187
230
  * @param {ChangeEvent[]} events accumulator
188
231
  * @param {{ arrayIdKey?: string | null, arrayIdentity?: boolean, arrayIgnoreOrder?: boolean }} [options]
232
+ * @param {Ancestors} ancestors containers on the current path
189
233
  */
190
- function diffValues(before, after, path, events, options = {}) {
234
+ function diffValues(before, after, path, events, options = {}, ancestors = newAncestors()) {
191
235
  const beforeIsObj = isPlainObject(before);
192
236
  const afterIsObj = isPlainObject(after);
193
237
  const beforeIsArr = Array.isArray(before);
@@ -195,13 +239,23 @@ function diffValues(before, after, path, events, options = {}) {
195
239
 
196
240
  // Both plain objects → recurse into keys
197
241
  if (beforeIsObj && afterIsObj) {
198
- diffObjects(before, after, path, events, options);
242
+ if (ancestors.before.has(before) || ancestors.after.has(after)) return;
243
+ ancestors.before.add(before);
244
+ ancestors.after.add(after);
245
+ diffObjects(before, after, path, events, options, ancestors);
246
+ ancestors.before.delete(before);
247
+ ancestors.after.delete(after);
199
248
  return;
200
249
  }
201
250
 
202
251
  // Both arrays → diff by index or identity key
203
252
  if (beforeIsArr && afterIsArr) {
204
- diffArrays(before, after, path, events, options);
253
+ if (ancestors.before.has(before) || ancestors.after.has(after)) return;
254
+ ancestors.before.add(before);
255
+ ancestors.after.add(after);
256
+ diffArrays(before, after, path, events, options, ancestors);
257
+ ancestors.before.delete(before);
258
+ ancestors.after.delete(after);
205
259
  return;
206
260
  }
207
261
 
@@ -247,8 +301,9 @@ function diffValues(before, after, path, events, options = {}) {
247
301
  * @param {string} basePath
248
302
  * @param {ChangeEvent[]} events
249
303
  * @param {{ arrayIdKey?: string | null, arrayIdentity?: boolean, arrayIgnoreOrder?: boolean }} [options]
304
+ * @param {Ancestors} [ancestors]
250
305
  */
251
- function diffObjects(before, after, basePath, events, options = {}) {
306
+ function diffObjects(before, after, basePath, events, options = {}, ancestors = newAncestors()) {
252
307
  const beforeKeys = new Set(Object.keys(before));
253
308
  const afterKeys = new Set(Object.keys(after));
254
309
 
@@ -272,7 +327,7 @@ function diffObjects(before, after, basePath, events, options = {}) {
272
327
  for (const key of beforeKeys) {
273
328
  if (afterKeys.has(key)) {
274
329
  const childPath = basePath ? `${basePath}.${key}` : key;
275
- diffValues(before[key], after[key], childPath, events, options);
330
+ diffValues(before[key], after[key], childPath, events, options, ancestors);
276
331
  }
277
332
  }
278
333
  }
@@ -366,8 +421,9 @@ function arraySignature(value) {
366
421
  * @param {string} basePath
367
422
  * @param {ChangeEvent[]} events
368
423
  * @param {{ arrayIdKey?: string | null, arrayIdentity?: boolean, arrayIgnoreOrder?: boolean }} [options]
424
+ * @param {Ancestors} [ancestors]
369
425
  */
370
- function diffArrays(before, after, basePath, events, options = {}) {
426
+ function diffArrays(before, after, basePath, events, options = {}, ancestors = newAncestors()) {
371
427
  const idKey = resolveArrayIdKey(before, after, options);
372
428
 
373
429
  if (idKey) {
@@ -380,7 +436,7 @@ function diffArrays(before, after, basePath, events, options = {}) {
380
436
  if (!beforeMap.has(key)) {
381
437
  events.push({ type: 'added', path: childPath, after: afterItem.value });
382
438
  } else {
383
- diffValues(beforeMap.get(key).value, afterItem.value, childPath, events, options);
439
+ diffValues(beforeMap.get(key).value, afterItem.value, childPath, events, options, ancestors);
384
440
  }
385
441
  }
386
442
  for (const [key, beforeItem] of beforeMap) {
@@ -424,7 +480,7 @@ function diffArrays(before, after, basePath, events, options = {}) {
424
480
  } else if (i >= after.length) {
425
481
  events.push({ type: 'removed', path: childPath, before: before[i] });
426
482
  } else {
427
- diffValues(before[i], after[i], childPath, events, options);
483
+ diffValues(before[i], after[i], childPath, events, options, ancestors);
428
484
  }
429
485
  }
430
486
  }
@@ -447,14 +503,22 @@ export function diffTrees(before, after, options = {}) {
447
503
  arrayIgnoreOrder: Boolean(options.arrayIgnoreOrder),
448
504
  };
449
505
 
450
- // Root handling: avoid assuming object roots.
506
+ // Root handling: avoid assuming object roots. The roots are recorded here
507
+ // because these two calls bypass `diffValues`, which is where every other
508
+ // container is recorded -- without it a cycle back to the root would be
509
+ // caught one level deeper than it should be.
510
+ const ancestors = newAncestors();
451
511
  if (isPlainObject(before) && isPlainObject(after)) {
452
- diffObjects(before, after, '', events, diffOpts);
512
+ ancestors.before.add(before);
513
+ ancestors.after.add(after);
514
+ diffObjects(before, after, '', events, diffOpts, ancestors);
453
515
  } else if (Array.isArray(before) && Array.isArray(after)) {
454
- diffArrays(before, after, '', events, diffOpts);
516
+ ancestors.before.add(before);
517
+ ancestors.after.add(after);
518
+ diffArrays(before, after, '', events, diffOpts, ancestors);
455
519
  } else {
456
520
  // Compare as a single root value. Use "<root>" so we can still ignore it if desired.
457
- diffValues(before, after, '<root>', events, diffOpts);
521
+ diffValues(before, after, '<root>', events, diffOpts, ancestors);
458
522
  }
459
523
 
460
524
  // Encrypted files carry signals a key-by-key walk cannot express: the file
package/src/pr-comment.js CHANGED
@@ -296,6 +296,33 @@ async function errorDetail(response, token) {
296
296
  return safe ? `: ${safe}` : '';
297
297
  }
298
298
 
299
+ /**
300
+ * @param {Response} response
301
+ * @returns {boolean}
302
+ */
303
+ function isRedirect(response) {
304
+ // `redirect: 'manual'` surfaces the 3xx itself in Node; a browser-shaped
305
+ // opaque redirect reports status 0 with `type: 'opaqueredirect'`.
306
+ return (response.status >= 300 && response.status < 400) || response.type === 'opaqueredirect';
307
+ }
308
+
309
+ /**
310
+ * The origin a redirect points at, for the error message. Only the origin: the
311
+ * rest of a `Location` is not worth echoing into a log.
312
+ * @param {Response} response
313
+ * @param {string} url
314
+ * @returns {string}
315
+ */
316
+ function redirectTarget(response, url) {
317
+ const location = response.headers?.get?.('location');
318
+ if (!location) return '';
319
+ try {
320
+ return ` to ${new URL(location, url).origin}`;
321
+ } catch {
322
+ return '';
323
+ }
324
+ }
325
+
299
326
  /**
300
327
  * @param {{ fetchImpl: typeof fetch, provider: object, url: string, method: string, token: string, body?: unknown, timeoutMs: number }} request
301
328
  * @returns {Promise<Response>}
@@ -315,6 +342,13 @@ async function apiRequest({ fetchImpl, provider, url, method, token, body, timeo
315
342
  },
316
343
  body: body === undefined ? undefined : JSON.stringify(body),
317
344
  signal: controller.signal,
345
+ // Never follow a redirect with a credential attached. `fetch` strips
346
+ // `Authorization` when a redirect crosses origins, but it strips *only*
347
+ // that header — GitLab authenticates with `PRIVATE-TOKEN`, which is
348
+ // forwarded to wherever the API host points, verified against a local
349
+ // server. Refusing the redirect is the whole fix: these endpoints do not
350
+ // legitimately redirect, and one that does is worth seeing.
351
+ redirect: 'manual',
318
352
  });
319
353
  } catch (err) {
320
354
  const reason = err?.name === 'AbortError'
@@ -325,6 +359,14 @@ async function apiRequest({ fetchImpl, provider, url, method, token, body, timeo
325
359
  clearTimeout(timer);
326
360
  }
327
361
 
362
+ if (isRedirect(response)) {
363
+ throw new Error(
364
+ `${provider.label} API ${method} was redirected (HTTP ${response.status}`
365
+ + `${redirectTarget(response, url)}), and Flecto does not follow a redirect with an API`
366
+ + ' token attached. Point the API URL at the host that answers directly.',
367
+ );
368
+ }
369
+
328
370
  if (!response.ok) {
329
371
  throw new Error(
330
372
  `${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. */
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
  }
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.