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/CHANGELOG.md +67 -1
- package/README.md +4 -1
- package/index.js +148 -124
- package/package.json +1 -1
- package/src/config.js +161 -1
- package/src/differ.js +76 -12
- package/src/pr-comment.js +42 -0
- package/src/pr-providers.js +8 -1
- package/src/renderer.js +3 -15
- package/src/report.js +4 -2
- package/src/secrets.js +27 -0
- package/src/snapshot-store.js +668 -0
package/src/config.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)}`,
|
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. */
|
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
|
}
|
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.
|