flecto 3.0.1 → 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 +567 -1
- package/README.md +15 -2
- package/index.js +544 -154
- package/package.json +4 -1
- package/schemas/flecto-policy-pack-2.0.json +2 -0
- package/src/baseline.js +193 -0
- package/src/config.js +564 -6
- package/src/differ.js +76 -12
- package/src/encrypted.js +16 -13
- package/src/packs/github-actions.json +92 -0
- package/src/parser.js +212 -22
- package/src/policy-test.js +5 -1
- package/src/policy.js +96 -23
- package/src/pr-comment.js +95 -87
- package/src/pr-providers.js +268 -0
- package/src/renderer.js +10 -22
- package/src/report.js +43 -3
- package/src/sarif.js +144 -0
- package/src/secrets.js +68 -7
- package/src/snapshot-store.js +668 -0
- package/src/suppressions.js +431 -0
- package/src/terraform.js +28 -0
package/index.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { program } from 'commander';
|
|
4
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync,
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'fs';
|
|
5
5
|
import { resolve, relative, dirname, join } from 'path';
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
|
-
import { createHash } from 'crypto';
|
|
8
7
|
import { execFileSync } from 'child_process';
|
|
9
8
|
import chalk from 'chalk';
|
|
10
9
|
|
|
@@ -24,6 +23,7 @@ import {
|
|
|
24
23
|
maskSensitiveValue,
|
|
25
24
|
} from './src/renderer.js';
|
|
26
25
|
import { deliverPrComment, renderPrComment } from './src/pr-comment.js';
|
|
26
|
+
import { PR_PROVIDER_IDS } from './src/pr-providers.js';
|
|
27
27
|
import {
|
|
28
28
|
diffTerraformPlan,
|
|
29
29
|
formatPlanSummary,
|
|
@@ -34,6 +34,25 @@ import { redactSecretString } from './src/secrets.js';
|
|
|
34
34
|
import { fireAlerts } from './src/alerter.js';
|
|
35
35
|
import { resolveWebhookFormat, WEBHOOK_FORMAT_CHOICES } from './src/notifiers.js';
|
|
36
36
|
import { createEnvelope } from './src/envelope.js';
|
|
37
|
+
import { buildSarif } from './src/sarif.js';
|
|
38
|
+
import {
|
|
39
|
+
maskState,
|
|
40
|
+
resolveSnapshotStore,
|
|
41
|
+
SNAPSHOT_MASK_MODES,
|
|
42
|
+
SNAPSHOT_STORE_IDS,
|
|
43
|
+
} from './src/snapshot-store.js';
|
|
44
|
+
import {
|
|
45
|
+
loadBaseline,
|
|
46
|
+
applyBaseline,
|
|
47
|
+
buildBaselineFile,
|
|
48
|
+
writeBaselineFile,
|
|
49
|
+
baselineRelativePath,
|
|
50
|
+
} from './src/baseline.js';
|
|
51
|
+
import {
|
|
52
|
+
suppressionFormat,
|
|
53
|
+
parseSuppressions,
|
|
54
|
+
applySuppressions,
|
|
55
|
+
} from './src/suppressions.js';
|
|
37
56
|
import {
|
|
38
57
|
evaluatePolicies,
|
|
39
58
|
highestSeverity,
|
|
@@ -48,13 +67,14 @@ import {
|
|
|
48
67
|
initRcFile,
|
|
49
68
|
resolveProfileName,
|
|
50
69
|
resolvePolicyOptions,
|
|
70
|
+
assertTargetContained,
|
|
71
|
+
assertWriteDestinationContained,
|
|
51
72
|
} from './src/config.js';
|
|
52
73
|
|
|
53
74
|
const PKG = JSON.parse(
|
|
54
75
|
readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8'),
|
|
55
76
|
);
|
|
56
77
|
|
|
57
|
-
const SNAPSHOT_DIR = '.flecto-snapshots';
|
|
58
78
|
const FAIL_ON_CHOICES = ['changed', 'added', 'removed', 'policy', 'error', 'warn'];
|
|
59
79
|
|
|
60
80
|
/**
|
|
@@ -67,85 +87,42 @@ const FAIL_ON_CHOICES = ['changed', 'added', 'removed', 'policy', 'error', 'warn
|
|
|
67
87
|
const PLAN_DEFAULT_FAIL_ON = 'error';
|
|
68
88
|
const PLAN_DEFAULT_POLICIES = 'terraform';
|
|
69
89
|
|
|
70
|
-
function snapshotIdForPath(absPath) {
|
|
71
|
-
const normalized = absPath.replaceAll('\\', '/');
|
|
72
|
-
return createHash('sha256').update(normalized).digest('hex').slice(0, 16);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function snapshotPathForFile(absPath) {
|
|
76
|
-
const id = snapshotIdForPath(absPath);
|
|
77
|
-
return resolve(`${SNAPSHOT_DIR}/${id}.json`);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function snapshotHistoryPathForFile(absPath) {
|
|
81
|
-
const id = snapshotIdForPath(absPath);
|
|
82
|
-
let timestamp = Date.now();
|
|
83
|
-
let path = resolve(`${SNAPSHOT_DIR}/${id}.${timestamp}.json`);
|
|
84
|
-
while (existsSync(path)) {
|
|
85
|
-
timestamp += 1;
|
|
86
|
-
path = resolve(`${SNAPSHOT_DIR}/${id}.${timestamp}.json`);
|
|
87
|
-
}
|
|
88
|
-
return path;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
90
|
/**
|
|
92
|
-
*
|
|
91
|
+
* Put the live side of a diff in the same form the store recorded the baseline
|
|
92
|
+
* in.
|
|
93
93
|
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
94
|
+
* A masked store holds `flecto:sha256:…` where a secret was. Diffing that
|
|
95
|
+
* against the plaintext on disk would report every secret in the file as changed
|
|
96
|
+
* on every single run — noise that would train a team to ignore the tool, from a
|
|
97
|
+
* store whose whole purpose is to be trusted. Masking both sides compares digest
|
|
98
|
+
* to digest, so a rotated credential still reports as changed and an untouched
|
|
99
|
+
* one reports nothing.
|
|
100
|
+
* @template T
|
|
101
|
+
* @param {T} state
|
|
102
|
+
* @param {import('./src/snapshot-store.js').SnapshotStore} store
|
|
103
|
+
* @returns {T}
|
|
98
104
|
*/
|
|
99
|
-
function
|
|
100
|
-
/** @type {
|
|
101
|
-
const ids = new Set();
|
|
102
|
-
if (!existsSync(SNAPSHOT_DIR)) return ids;
|
|
103
|
-
for (const name of readdirSync(SNAPSHOT_DIR)) {
|
|
104
|
-
const match = /^([a-f0-9]{16})\.\d+\.json$/.exec(name);
|
|
105
|
-
if (match) ids.add(match[1]);
|
|
106
|
-
}
|
|
107
|
-
return ids;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function preserveLegacySnapshotForHistory(absPath, snapshotPath, idsWithHistory) {
|
|
111
|
-
if (!existsSync(snapshotPath) || idsWithHistory.has(snapshotIdForPath(absPath))) return;
|
|
112
|
-
|
|
113
|
-
const legacy = JSON.parse(readFileSync(snapshotPath, 'utf8'));
|
|
114
|
-
writeFileSync(
|
|
115
|
-
snapshotHistoryPathForFile(absPath),
|
|
116
|
-
JSON.stringify({
|
|
117
|
-
file: legacy.file ?? absPath,
|
|
118
|
-
state: legacy.state ?? legacy,
|
|
119
|
-
...(Array.isArray(legacy.documents) ? { documents: legacy.documents } : {}),
|
|
120
|
-
createdAt: legacy.createdAt ?? statSync(snapshotPath).mtime.toISOString(),
|
|
121
|
-
}, null, 2),
|
|
122
|
-
'utf8',
|
|
123
|
-
);
|
|
105
|
+
function alignStateWithStore(state, store) {
|
|
106
|
+
return store.maskMode === 'hash' ? /** @type {T} */ (maskState(state)) : state;
|
|
124
107
|
}
|
|
125
108
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
return
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
return {
|
|
145
|
-
file: snapshot.file,
|
|
146
|
-
state,
|
|
147
|
-
createdAt: snapshot.createdAt ?? statSync(path).mtime.toISOString(),
|
|
148
|
-
};
|
|
109
|
+
/**
|
|
110
|
+
* Resolve the snapshot store a command should read and write (#141).
|
|
111
|
+
*
|
|
112
|
+
* Every snapshot consumer goes through this, so `--snapshot-store shared` in
|
|
113
|
+
* `.flectorc` means the same thing to `watch`, `ci`, `history`, and `report` —
|
|
114
|
+
* a store the editor of the config and the runner gating it disagree about
|
|
115
|
+
* would be worse than having only the local one.
|
|
116
|
+
* @param {Record<string, unknown>} effective
|
|
117
|
+
* @returns {import('./src/snapshot-store.js').SnapshotStore}
|
|
118
|
+
*/
|
|
119
|
+
function snapshotStoreFromEffective(effective) {
|
|
120
|
+
return resolveSnapshotStore({
|
|
121
|
+
store: effective.snapshotStore,
|
|
122
|
+
dir: effective.snapshotDir,
|
|
123
|
+
mask: effective.snapshotMask,
|
|
124
|
+
retention: effective.snapshotRetention,
|
|
125
|
+
cwd: process.cwd(),
|
|
149
126
|
});
|
|
150
127
|
}
|
|
151
128
|
|
|
@@ -275,6 +252,18 @@ function maybeMaskFindings(findings, changes, maskSecrets) {
|
|
|
275
252
|
});
|
|
276
253
|
}
|
|
277
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Every command's targets pass through here, which makes it the one place the
|
|
257
|
+
* symlink-escape check has to run: a target that leaves the project through a
|
|
258
|
+
* link is refused before anything reads it.
|
|
259
|
+
* @param {string[]} files
|
|
260
|
+
* @returns {string[]} the same list
|
|
261
|
+
*/
|
|
262
|
+
function assertTargetsContained(files) {
|
|
263
|
+
for (const file of files) assertTargetContained(file, process.cwd());
|
|
264
|
+
return files;
|
|
265
|
+
}
|
|
266
|
+
|
|
278
267
|
async function resolveTargetFiles(cliFiles, rcConfig) {
|
|
279
268
|
if (cliFiles && cliFiles.length > 0) {
|
|
280
269
|
const direct = [];
|
|
@@ -294,15 +283,15 @@ async function resolveTargetFiles(cliFiles, rcConfig) {
|
|
|
294
283
|
exclude: rcConfig?.exclude ?? [],
|
|
295
284
|
});
|
|
296
285
|
}
|
|
297
|
-
return [...new Set([...direct, ...expanded])];
|
|
286
|
+
return assertTargetsContained([...new Set([...direct, ...expanded])]);
|
|
298
287
|
}
|
|
299
288
|
|
|
300
|
-
return resolveFiles({
|
|
289
|
+
return assertTargetsContained(await resolveFiles({
|
|
301
290
|
cwd: process.cwd(),
|
|
302
291
|
files: rcConfig?.files ?? [],
|
|
303
292
|
include: rcConfig?.include ?? [],
|
|
304
293
|
exclude: rcConfig?.exclude ?? [],
|
|
305
|
-
});
|
|
294
|
+
}));
|
|
306
295
|
}
|
|
307
296
|
|
|
308
297
|
/**
|
|
@@ -346,10 +335,28 @@ function gitRepoRelativePath(filePath) {
|
|
|
346
335
|
/**
|
|
347
336
|
* Resolve symlinks where possible, falling back to the input when the path does
|
|
348
337
|
* not exist on disk.
|
|
338
|
+
*
|
|
339
|
+
* `realpathSync.native` is tried first because on Windows it asks the OS for the
|
|
340
|
+
* final path, which resolves 8.3 short names and normalizes case. Those are not
|
|
341
|
+
* cosmetic here: `git rev-parse --show-toplevel` reports the long form, while
|
|
342
|
+
* `os.tmpdir()` and many shells hand Flecto the short one
|
|
343
|
+
* (`C:\Users\RUNNER~1\...`). The JS `realpathSync` leaves both as written, so
|
|
344
|
+
* the two spellings of one directory compare as different and `relative()`
|
|
345
|
+
* produces a path that climbs out of the repository -- making
|
|
346
|
+
* `--snapshot-ref <git-ref>` fail on a file that is plainly tracked.
|
|
347
|
+
*
|
|
348
|
+
* On Linux and macOS the two agree for any path that exists, so this only ever
|
|
349
|
+
* changes the Windows result.
|
|
349
350
|
* @param {string} path
|
|
350
351
|
* @returns {string}
|
|
351
352
|
*/
|
|
352
353
|
function canonicalPath(path) {
|
|
354
|
+
try {
|
|
355
|
+
return realpathSync.native(path);
|
|
356
|
+
} catch {
|
|
357
|
+
// Falls back for a path that does not exist yet, and for the rare platform
|
|
358
|
+
// where the native call is unavailable.
|
|
359
|
+
}
|
|
353
360
|
try {
|
|
354
361
|
return realpathSync(path);
|
|
355
362
|
} catch {
|
|
@@ -357,8 +364,19 @@ function canonicalPath(path) {
|
|
|
357
364
|
}
|
|
358
365
|
}
|
|
359
366
|
|
|
360
|
-
function readSnapshotStateFromRef(filePath, snapshotRef) {
|
|
361
|
-
if (!snapshotRef)
|
|
367
|
+
function readSnapshotStateFromRef(filePath, snapshotRef, store) {
|
|
368
|
+
if (!snapshotRef) {
|
|
369
|
+
// Failing closed here is right — a diff with no baseline is not a clean
|
|
370
|
+
// diff — but an ENOENT on a hashed filename explains nothing. The default
|
|
371
|
+
// store is local to the working directory, so this is what an ephemeral CI
|
|
372
|
+
// runner hits on every run; the message names the store it looked in and
|
|
373
|
+
// the two ways to give it one (#141).
|
|
374
|
+
const record = store.readLatest(filePath);
|
|
375
|
+
if (!record) {
|
|
376
|
+
throw new Error(`no snapshot has been saved for this file (${store.emptyHint})`);
|
|
377
|
+
}
|
|
378
|
+
return record.state;
|
|
379
|
+
}
|
|
362
380
|
const maybePath = resolve(snapshotRef);
|
|
363
381
|
if (existsSync(maybePath)) {
|
|
364
382
|
return readSnapshotStateFromFile(maybePath);
|
|
@@ -376,6 +394,40 @@ function shouldFailFromPolicy(findings, failOn) {
|
|
|
376
394
|
return false;
|
|
377
395
|
}
|
|
378
396
|
|
|
397
|
+
/**
|
|
398
|
+
* Parse a file's inline suppressions and split its findings into active and
|
|
399
|
+
* suppressed. A directive missing its mandatory reason is a hard error: applying
|
|
400
|
+
* it would hide a finding with no justification, and skipping it silently would
|
|
401
|
+
* fail the build confusingly.
|
|
402
|
+
*
|
|
403
|
+
* A directive that resolves to nothing — an array element, or a file type with
|
|
404
|
+
* no comment syntax — is a warning instead of an error. It already fails closed,
|
|
405
|
+
* because the finding it meant to accept still fires and still gates, so failing
|
|
406
|
+
* the build a second time adds nothing; what was missing was any signal at all
|
|
407
|
+
* that the directive did not take effect.
|
|
408
|
+
* @param {string} filepath
|
|
409
|
+
* @param {import('./src/policy.js').PolicyFinding[]} findings
|
|
410
|
+
* @returns {{ active: any[], suppressed: Array<{ finding: any, reason: string }> }}
|
|
411
|
+
*/
|
|
412
|
+
function resolveSuppressed(filepath, findings) {
|
|
413
|
+
const format = suppressionFormat(filepath);
|
|
414
|
+
|
|
415
|
+
let raw;
|
|
416
|
+
try {
|
|
417
|
+
raw = readFileSync(filepath, 'utf8');
|
|
418
|
+
} catch {
|
|
419
|
+
return { active: findings, suppressed: [] };
|
|
420
|
+
}
|
|
421
|
+
const { suppressions, errors, warnings } = parseSuppressions(raw, format);
|
|
422
|
+
const rel = relative(process.cwd(), filepath) || filepath;
|
|
423
|
+
if (errors.length > 0) {
|
|
424
|
+
const detail = errors.map((e) => ` ${rel}:${e.line}: ${e.message}`).join('\n');
|
|
425
|
+
throw new Error(`Inline suppression is missing a required reason:\n${detail}`);
|
|
426
|
+
}
|
|
427
|
+
for (const warning of warnings) renderNote(`${rel}:${warning.line}: ${warning.message}`);
|
|
428
|
+
return applySuppressions(findings, suppressions);
|
|
429
|
+
}
|
|
430
|
+
|
|
379
431
|
function shouldFailFromChanges(events, failOn) {
|
|
380
432
|
if (events.length === 0) return false;
|
|
381
433
|
if (failOn.has('changed') && events.some((e) => e.type === 'changed')) return true;
|
|
@@ -397,32 +449,143 @@ function escapeWorkflowCommandProperty(value) {
|
|
|
397
449
|
.replaceAll(',', '%2C');
|
|
398
450
|
}
|
|
399
451
|
|
|
400
|
-
|
|
452
|
+
/**
|
|
453
|
+
* Write to stdout and resolve only once the bytes have actually left.
|
|
454
|
+
*
|
|
455
|
+
* `process.exit()` does not flush a pending stdout write, and Node writes to a
|
|
456
|
+
* pipe asynchronously. So `flecto ci --format json | jq`, or any CI harness
|
|
457
|
+
* capturing stdout, silently lost everything past the 64 KB pipe buffer -- and
|
|
458
|
+
* still saw exit 0. A truncated envelope stream that reports success is the
|
|
459
|
+
* worst shape a machine consumer can be handed: it does not look like a
|
|
460
|
+
* failure, it looks like a clean run over fewer files.
|
|
461
|
+
*
|
|
462
|
+
* Redirecting to a file hid this, because Node writes to a file descriptor
|
|
463
|
+
* synchronously. It only appeared through a pipe, which is how every consumer
|
|
464
|
+
* that matters reads it.
|
|
465
|
+
*
|
|
466
|
+
* The callback form fires when that specific chunk drains, and stream writes
|
|
467
|
+
* are ordered, so awaiting the last one means every earlier one is out too.
|
|
468
|
+
*
|
|
469
|
+
* The trailing newline is appended unconditionally, which is exactly what
|
|
470
|
+
* `console.log` did. Adding it only when one is missing would silently drop a
|
|
471
|
+
* byte from any payload that already ends in a newline -- `--format pr-comment`
|
|
472
|
+
* does -- and the point of this change is that the rendered output is identical.
|
|
473
|
+
* @param {string} text
|
|
474
|
+
* @returns {Promise<void>}
|
|
475
|
+
*/
|
|
476
|
+
function writeStdout(text) {
|
|
477
|
+
return new Promise((resolveWrite) => {
|
|
478
|
+
process.stdout.write(`${text}\n`, () => resolveWrite());
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Collapse the envelopes for files that were scanned and had nothing to report
|
|
484
|
+
* into a single manifest entry.
|
|
485
|
+
*
|
|
486
|
+
* `ci` emits one envelope per *scanned* file rather than per *changed* file, so
|
|
487
|
+
* the output grows with the size of the repository instead of the size of the
|
|
488
|
+
* change -- measured on 250 service configs with one file edited, 113.4 KB of
|
|
489
|
+
* output for 0.2 KB of semantic content. For a human that is invisible, because
|
|
490
|
+
* the renderer already prints only what changed; it is the machine consumers
|
|
491
|
+
* (webhooks, NDJSON sinks, and any agent handed the JSON) that pay for it.
|
|
492
|
+
*
|
|
493
|
+
* Dropping those files outright is not safe. An envelope for a scanned but
|
|
494
|
+
* unchanged file is *evidence Flecto looked*, and a consumer diffing two runs
|
|
495
|
+
* can tell "checked and clean" from "not checked at all" -- silently removing
|
|
496
|
+
* that distinction would weaken a gate someone relies on, in the same way a
|
|
497
|
+
* silently skipped plugin would. So the evidence is kept, in the one place it
|
|
498
|
+
* costs almost nothing: a single `lifecycle` envelope carrying the list of
|
|
499
|
+
* paths, instead of a full envelope with its own pair of UUIDs, timestamp, and
|
|
500
|
+
* absolute path for every file.
|
|
501
|
+
*
|
|
502
|
+
* The path list rides on the result wrapper rather than the envelope, which is
|
|
503
|
+
* closed by schemas/flecto-envelope-2.0.json -- the same arrangement `baseline`
|
|
504
|
+
* already uses. Nothing about schema 2.0 changes, and the default output is
|
|
505
|
+
* untouched, so this is opt-in rather than a reshaping of a documented contract.
|
|
506
|
+
*
|
|
507
|
+
* Each envelope keeps its own `batch_id`: that field is documented as grouping
|
|
508
|
+
* the events from one file change, not one run.
|
|
509
|
+
* @param {any[]} results
|
|
510
|
+
* @returns {any[]}
|
|
511
|
+
*/
|
|
512
|
+
function collapseUnchangedResults(results) {
|
|
513
|
+
const reported = [];
|
|
514
|
+
/** @type {string[]} */
|
|
515
|
+
const scanned = [];
|
|
516
|
+
|
|
517
|
+
for (const result of results) {
|
|
518
|
+
const hasChanges = (result.envelope.changes?.length ?? 0) > 0;
|
|
519
|
+
const hasFindings = (result.envelope.policies?.length ?? 0) > 0;
|
|
520
|
+
if (hasChanges || hasFindings) reported.push(result);
|
|
521
|
+
else scanned.push(result.file);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (scanned.length === 0) return reported;
|
|
525
|
+
return [
|
|
526
|
+
...reported,
|
|
527
|
+
{
|
|
528
|
+
// No `file`: this entry is not about one file. Consumers discriminate on
|
|
529
|
+
// `envelope.event_type === "lifecycle"`, which schema 2.0 already carries.
|
|
530
|
+
scanned,
|
|
531
|
+
envelope: createEnvelope({
|
|
532
|
+
source: 'ci',
|
|
533
|
+
file: '',
|
|
534
|
+
lifecycle: {
|
|
535
|
+
type: 'scanned',
|
|
536
|
+
message:
|
|
537
|
+
`${scanned.length} file${scanned.length === 1 ? '' : 's'} scanned `
|
|
538
|
+
+ 'with no changes and no policy findings',
|
|
539
|
+
},
|
|
540
|
+
}),
|
|
541
|
+
policies: [],
|
|
542
|
+
},
|
|
543
|
+
];
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Render the machine-readable CI output and wait for it to flush.
|
|
548
|
+
*
|
|
549
|
+
* The payload is assembled and written once rather than line by line, so a
|
|
550
|
+
* caller has a single write to await -- see {@link writeStdout} for why that
|
|
551
|
+
* matters. The rendered bytes are unchanged.
|
|
552
|
+
* @param {any[]} results
|
|
553
|
+
* @param {string} format
|
|
554
|
+
* @returns {Promise<void>}
|
|
555
|
+
*/
|
|
556
|
+
async function printCiOutput(results, format) {
|
|
401
557
|
if (format === 'json') {
|
|
402
|
-
|
|
558
|
+
await writeStdout(JSON.stringify(results, null, 2));
|
|
403
559
|
return;
|
|
404
560
|
}
|
|
405
561
|
if (format === 'ndjson') {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
562
|
+
if (results.length === 0) return;
|
|
563
|
+
await writeStdout(results.map((result) => JSON.stringify(result)).join('\n'));
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (format === 'sarif') {
|
|
567
|
+
const sarif = buildSarif(results, { cwd: process.cwd(), toolVersion: PKG.version });
|
|
568
|
+
await writeStdout(JSON.stringify(sarif, null, 2));
|
|
409
569
|
return;
|
|
410
570
|
}
|
|
411
571
|
if (format === 'github-annotations') {
|
|
572
|
+
const lines = [];
|
|
412
573
|
for (const result of results) {
|
|
413
574
|
for (const event of result.envelope.changes) {
|
|
414
575
|
const title = `flecto ${event.type}`;
|
|
415
576
|
const detail = event.note ? `${event.path} (${event.note})` : event.path;
|
|
416
|
-
|
|
577
|
+
lines.push(`::warning file=${escapeWorkflowCommandProperty(result.file)},title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandData(detail)}`);
|
|
417
578
|
}
|
|
418
579
|
for (const finding of result.policies) {
|
|
419
580
|
const level = finding.severity === 'error' ? 'error' : 'warning';
|
|
420
581
|
const pack = finding.pack ? ` [${finding.pack}]` : '';
|
|
421
582
|
const title = `flecto policy ${finding.id}${pack}`;
|
|
422
583
|
const detail = `${finding.path}: ${finding.message}`;
|
|
423
|
-
|
|
584
|
+
lines.push(`::${level} file=${escapeWorkflowCommandProperty(result.file)},title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandData(detail)}`);
|
|
424
585
|
}
|
|
425
586
|
}
|
|
587
|
+
if (lines.length === 0) return;
|
|
588
|
+
await writeStdout(lines.join('\n'));
|
|
426
589
|
}
|
|
427
590
|
}
|
|
428
591
|
|
|
@@ -431,11 +594,12 @@ function printCiOutput(results, format) {
|
|
|
431
594
|
* delivery problem warns, and the exit code stays with the diff/policy result.
|
|
432
595
|
* @param {string} body
|
|
433
596
|
* @param {boolean} enabled
|
|
597
|
+
* @param {string} [provider] force a delivery adapter instead of detecting one
|
|
434
598
|
*/
|
|
435
|
-
async function deliverPrCommentSafely(body, enabled) {
|
|
599
|
+
async function deliverPrCommentSafely(body, enabled, provider) {
|
|
436
600
|
if (!enabled) return;
|
|
437
601
|
try {
|
|
438
|
-
const result = await deliverPrComment(body, { enabled: true });
|
|
602
|
+
const result = await deliverPrComment(body, { enabled: true, provider });
|
|
439
603
|
if (result.posted) {
|
|
440
604
|
renderNote(`PR comment ${result.action}${result.url ? `: ${result.url}` : ''}`);
|
|
441
605
|
return;
|
|
@@ -479,6 +643,10 @@ program
|
|
|
479
643
|
.option('--mask-secrets-webhooks', 'Also mask secrets in webhook payloads', false)
|
|
480
644
|
.option('--snapshot', 'Save current state as baseline instead of watching')
|
|
481
645
|
.option('--diff', 'Diff current file against saved baseline and exit')
|
|
646
|
+
.option('--snapshot-store <id>', `Snapshot store: ${SNAPSHOT_STORE_IDS.join(' | ')} (shared is repo-relative and meant to be committed)`)
|
|
647
|
+
.option('--snapshot-dir <path>', 'Directory holding the snapshot store (default: .flecto-snapshots local, .flecto/snapshots shared)')
|
|
648
|
+
.option('--snapshot-mask <mode>', `How the store records secret-like values: ${SNAPSHOT_MASK_MODES.join(' | ')} (default: hash for shared, none for local)`)
|
|
649
|
+
.option('--snapshot-retention <n>', 'Snapshots kept per file, 0 keeps every one (default: 20 for shared, unlimited for local)')
|
|
482
650
|
.option('--allow-empty', 'Allow --snapshot to succeed when nothing was written', false)
|
|
483
651
|
.action(async (files, opts, command) => {
|
|
484
652
|
try {
|
|
@@ -502,11 +670,18 @@ program
|
|
|
502
670
|
const maskSecretsWebhooks = Boolean(effective.maskSecretsWebhooks);
|
|
503
671
|
const webhookFormat = resolveWebhookFormat(effective.webhookFormat, effective.webhook);
|
|
504
672
|
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
673
|
+
const snapshotStore = snapshotStoreFromEffective(effective);
|
|
505
674
|
|
|
506
675
|
if (effective.snapshot) {
|
|
507
|
-
mkdirSync(
|
|
508
|
-
|
|
676
|
+
mkdirSync(snapshotStore.root, { recursive: true });
|
|
677
|
+
// Snapshots carry config values, so a store directory that is itself a
|
|
678
|
+
// link out of the project would write them somewhere the repository does
|
|
679
|
+
// not control. Same rule as a target, checked after mkdir so an existing
|
|
680
|
+
// link is seen rather than a path that does not exist yet.
|
|
681
|
+
assertTargetContained(snapshotStore.root, process.cwd());
|
|
509
682
|
let written = 0;
|
|
683
|
+
let pruned = 0;
|
|
684
|
+
const warnings = new Set();
|
|
510
685
|
for (const filepath of targets) {
|
|
511
686
|
if (!existsSync(filepath)) {
|
|
512
687
|
renderWarn(`Skipping missing file: ${filepath}`);
|
|
@@ -517,25 +692,32 @@ program
|
|
|
517
692
|
continue;
|
|
518
693
|
}
|
|
519
694
|
const state = parseFile(filepath);
|
|
520
|
-
const snapshotPath = snapshotPathForFile(filepath);
|
|
521
|
-
preserveLegacySnapshotForHistory(filepath, snapshotPath, idsWithHistory);
|
|
522
695
|
// Only a multi-document file records `documents`, so an ordinary
|
|
523
696
|
// snapshot is byte-for-byte what it was before this field existed.
|
|
524
|
-
const
|
|
525
|
-
const snapshot = {
|
|
526
|
-
file: filepath,
|
|
697
|
+
const result = snapshotStore.write(filepath, {
|
|
527
698
|
state,
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
// Keep the set in step with what this run has written, so a repeated
|
|
534
|
-
// target behaves exactly as it did when the check hit the disk.
|
|
535
|
-
idsWithHistory.add(snapshotIdForPath(filepath));
|
|
536
|
-
console.log(chalk.green(`✓ Snapshot saved: ${snapshotPath}`));
|
|
699
|
+
documents: documentKeysOf(state) ?? [],
|
|
700
|
+
});
|
|
701
|
+
if (result.warning) warnings.add(result.warning);
|
|
702
|
+
pruned += result.pruned;
|
|
703
|
+
console.log(chalk.green(`✓ Snapshot saved: ${result.path}`));
|
|
537
704
|
written += 1;
|
|
538
705
|
}
|
|
706
|
+
for (const warning of warnings) renderWarn(warning);
|
|
707
|
+
if (pruned > 0) {
|
|
708
|
+
renderNote(
|
|
709
|
+
`Pruned ${pruned} snapshot${pruned === 1 ? '' : 's'} beyond the`
|
|
710
|
+
+ ` ${snapshotStore.retention}-entry retention of the ${snapshotStore.id} store.`,
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
if (written > 0 && snapshotStore.id === 'shared') {
|
|
714
|
+
renderNote(
|
|
715
|
+
`Shared store: commit ${snapshotStore.label} so every runner reads the same history.`
|
|
716
|
+
+ (snapshotStore.maskMode === 'none'
|
|
717
|
+
? ' Masking is off, so these files carry config values verbatim into git history.'
|
|
718
|
+
: ' Secret-like values are stored as digests, not plaintext.'),
|
|
719
|
+
);
|
|
720
|
+
}
|
|
539
721
|
if (written === 0 && !effective.allowEmpty) {
|
|
540
722
|
throw new Error(
|
|
541
723
|
'No snapshots written — all targets were missing or unsupported.' +
|
|
@@ -547,18 +729,37 @@ program
|
|
|
547
729
|
|
|
548
730
|
if (effective.diff) {
|
|
549
731
|
let hasChanges = false;
|
|
732
|
+
let compared = 0;
|
|
733
|
+
let missing = 0;
|
|
550
734
|
for (const filepath of targets) {
|
|
551
|
-
const
|
|
552
|
-
if (!
|
|
553
|
-
renderWarn(`No snapshot found for "${filepath}"`);
|
|
735
|
+
const record = snapshotStore.readLatest(filepath);
|
|
736
|
+
if (!record) {
|
|
737
|
+
renderWarn(`No snapshot found for "${filepath}" in ${snapshotStore.label}`);
|
|
738
|
+
missing += 1;
|
|
554
739
|
continue;
|
|
555
740
|
}
|
|
556
|
-
const before =
|
|
557
|
-
const after = parseFile(filepath);
|
|
741
|
+
const before = record.state;
|
|
742
|
+
const after = alignStateWithStore(parseFile(filepath), snapshotStore);
|
|
558
743
|
const events = diffTrees(before, after, dOpts);
|
|
559
744
|
renderDiff(filepath, events, { maskSecrets });
|
|
745
|
+
compared += 1;
|
|
560
746
|
if (events.length > 0) hasChanges = true;
|
|
561
747
|
}
|
|
748
|
+
// Exiting 0 having compared nothing is the worst answer available: the
|
|
749
|
+
// caller reads it as "no drift" when the truth is "no baseline to drift
|
|
750
|
+
// from" (#141). Snapshot history lives in the working directory, so this
|
|
751
|
+
// is the normal state of a fresh CI runner.
|
|
752
|
+
if (compared === 0) {
|
|
753
|
+
throw new Error(
|
|
754
|
+
`No snapshot found for any target in ${snapshotStore.label}, so nothing was compared.`
|
|
755
|
+
+ ' Run "flecto watch <file> --snapshot" first — no history is not no drift.',
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
if (missing > 0) {
|
|
759
|
+
renderNote(
|
|
760
|
+
`${missing} of ${targets.length} targets had no snapshot and were not compared.`,
|
|
761
|
+
);
|
|
762
|
+
}
|
|
562
763
|
process.exit(hasChanges ? 1 : 0);
|
|
563
764
|
}
|
|
564
765
|
|
|
@@ -668,8 +869,10 @@ program
|
|
|
668
869
|
|
|
669
870
|
program
|
|
670
871
|
.command('history [files...]')
|
|
671
|
-
.description('Summarize drift across
|
|
872
|
+
.description('Summarize drift across saved snapshots')
|
|
672
873
|
.option('-l, --limit <n>', 'Number of recent snapshots to show', '10')
|
|
874
|
+
.option('--snapshot-store <id>', `Snapshot store to read: ${SNAPSHOT_STORE_IDS.join(' | ')}`)
|
|
875
|
+
.option('--snapshot-dir <path>', 'Directory holding the snapshot store (default: .flecto-snapshots local, .flecto/snapshots shared)')
|
|
673
876
|
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
674
877
|
.option('--ignore <keys>', 'Comma-separated key paths to ignore (e.g. "updated_at,meta.ts")')
|
|
675
878
|
.option('--array-id-key <key>', 'Diff arrays by this object identity key (opt-in)')
|
|
@@ -688,7 +891,8 @@ program
|
|
|
688
891
|
const ignorePaths = parseCsv(effective.ignore);
|
|
689
892
|
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
690
893
|
|
|
691
|
-
const
|
|
894
|
+
const snapshotStore = snapshotStoreFromEffective(effective);
|
|
895
|
+
const allSnapshots = snapshotStore.readHistory();
|
|
692
896
|
let snapshots = allSnapshots;
|
|
693
897
|
if (files.length > 0) {
|
|
694
898
|
const targets = new Set((await resolveTargetFiles(files, config)).map((file) => resolve(file)));
|
|
@@ -699,18 +903,42 @@ program
|
|
|
699
903
|
if (summaries.length === 0) {
|
|
700
904
|
if (files.length > 0 && allSnapshots.length > 0) {
|
|
701
905
|
throw new Error(
|
|
702
|
-
|
|
906
|
+
`No snapshots in ${snapshotStore.label} matched the given files.`
|
|
907
|
+
+ ' Omit files to view all saved snapshot history.',
|
|
703
908
|
);
|
|
704
909
|
}
|
|
705
|
-
throw new Error(
|
|
910
|
+
throw new Error(
|
|
911
|
+
`No snapshots found in ${snapshotStore.label}.`
|
|
912
|
+
+ ` Run "flecto watch <file> --snapshot${snapshotStore.id === 'shared' ? ' --snapshot-store shared' : ''}" first`
|
|
913
|
+
+ ' — no history is not no drift.',
|
|
914
|
+
);
|
|
706
915
|
}
|
|
707
916
|
|
|
708
|
-
console.log(
|
|
917
|
+
console.log(
|
|
918
|
+
`Snapshot history from ${snapshotStore.label} (${summaries.length} snapshots, ${snapshotStore.id} store)`,
|
|
919
|
+
);
|
|
920
|
+
let baselines = 0;
|
|
709
921
|
for (const snapshot of summaries) {
|
|
710
922
|
const file = relative(process.cwd(), snapshot.file) || snapshot.file;
|
|
711
|
-
|
|
923
|
+
// A snapshot with nothing before it was never compared, so printing
|
|
924
|
+
// "0 changes" for it states a result that was never computed (#141).
|
|
925
|
+
if (!snapshot.previousCreatedAt) baselines += 1;
|
|
926
|
+
const changes = snapshot.previousCreatedAt
|
|
927
|
+
? `${snapshot.changeCount} change${snapshot.changeCount === 1 ? '' : 's'}`
|
|
928
|
+
: 'baseline (no earlier snapshot to compare against)';
|
|
712
929
|
console.log(`${snapshot.createdAt} ${file} — ${changes}`);
|
|
713
930
|
}
|
|
931
|
+
if (baselines === summaries.length) {
|
|
932
|
+
renderNote(
|
|
933
|
+
'Nothing was compared: every snapshot shown is the first of its file.'
|
|
934
|
+
+ ' That is no history, not no drift.',
|
|
935
|
+
);
|
|
936
|
+
} else if (baselines > 0) {
|
|
937
|
+
renderNote(
|
|
938
|
+
`${baselines} of ${summaries.length} snapshots shown are the first of their file`
|
|
939
|
+
+ ' and were not compared against anything.',
|
|
940
|
+
);
|
|
941
|
+
}
|
|
714
942
|
} catch (err) {
|
|
715
943
|
renderError(err.message);
|
|
716
944
|
process.exit(1);
|
|
@@ -719,8 +947,10 @@ program
|
|
|
719
947
|
|
|
720
948
|
program
|
|
721
949
|
.command('report [files...]')
|
|
722
|
-
.description('Render
|
|
950
|
+
.description('Render saved snapshot history as a self-contained HTML report')
|
|
723
951
|
.option('-o, --output <path>', 'Write the report to this path', 'flecto-report.html')
|
|
952
|
+
.option('--snapshot-store <id>', `Snapshot store to read: ${SNAPSHOT_STORE_IDS.join(' | ')}`)
|
|
953
|
+
.option('--snapshot-dir <path>', 'Directory holding the snapshot store (default: .flecto-snapshots local, .flecto/snapshots shared)')
|
|
724
954
|
.option('-l, --limit <n>', 'Number of recent snapshots to include', '10')
|
|
725
955
|
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
726
956
|
.option('--ignore <keys>', 'Comma-separated key paths to ignore (e.g. "updated_at,meta.ts")')
|
|
@@ -746,10 +976,15 @@ program
|
|
|
746
976
|
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
747
977
|
const maskSecrets = Boolean(effective.maskSecrets);
|
|
748
978
|
const outputPath = resolve(String(effective.output ?? 'flecto-report.html'));
|
|
979
|
+
assertWriteDestinationContained(outputPath, {
|
|
980
|
+
option: '--output',
|
|
981
|
+
fromCli: cliOverrides.output !== undefined,
|
|
982
|
+
});
|
|
749
983
|
|
|
750
984
|
// Same snapshot source, filtering, and errors as `flecto history` — this
|
|
751
985
|
// command only changes how that history is rendered.
|
|
752
|
-
const
|
|
986
|
+
const snapshotStore = snapshotStoreFromEffective(effective);
|
|
987
|
+
const allSnapshots = snapshotStore.readHistory();
|
|
753
988
|
let snapshots = allSnapshots;
|
|
754
989
|
if (files.length > 0) {
|
|
755
990
|
const targets = new Set((await resolveTargetFiles(files, config)).map((file) => resolve(file)));
|
|
@@ -760,10 +995,15 @@ program
|
|
|
760
995
|
if (summaries.length === 0) {
|
|
761
996
|
if (files.length > 0 && allSnapshots.length > 0) {
|
|
762
997
|
throw new Error(
|
|
763
|
-
|
|
998
|
+
`No snapshots in ${snapshotStore.label} matched the given files.`
|
|
999
|
+
+ ' Omit files to report on all saved snapshot history.',
|
|
764
1000
|
);
|
|
765
1001
|
}
|
|
766
|
-
throw new Error(
|
|
1002
|
+
throw new Error(
|
|
1003
|
+
`No snapshots found in ${snapshotStore.label}.`
|
|
1004
|
+
+ ` Run "flecto watch <file> --snapshot${snapshotStore.id === 'shared' ? ' --snapshot-store shared' : ''}" first`
|
|
1005
|
+
+ ' — no history is not no drift.',
|
|
1006
|
+
);
|
|
767
1007
|
}
|
|
768
1008
|
|
|
769
1009
|
const reportSnapshots = [];
|
|
@@ -797,6 +1037,7 @@ program
|
|
|
797
1037
|
version: PKG.version,
|
|
798
1038
|
limit,
|
|
799
1039
|
maskSecrets,
|
|
1040
|
+
store: snapshotStore.label,
|
|
800
1041
|
});
|
|
801
1042
|
mkdirSync(dirname(outputPath), { recursive: true });
|
|
802
1043
|
writeFileSync(outputPath, html, 'utf8');
|
|
@@ -814,9 +1055,14 @@ program
|
|
|
814
1055
|
.description('Run semantic diff in CI mode')
|
|
815
1056
|
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
816
1057
|
.option('--snapshot-ref <ref>', 'Snapshot reference: snapshot path or git ref')
|
|
817
|
-
.option('--
|
|
818
|
-
.option('--
|
|
1058
|
+
.option('--snapshot-store <id>', `Snapshot store to read: ${SNAPSHOT_STORE_IDS.join(' | ')}`)
|
|
1059
|
+
.option('--snapshot-dir <path>', 'Directory holding the snapshot store (default: .flecto-snapshots local, .flecto/snapshots shared)')
|
|
1060
|
+
.option('--format <type>', 'Output format: json | ndjson | sarif | github-annotations | pr-comment', 'json')
|
|
1061
|
+
.option('--pr-comment-post', 'With --format pr-comment, upsert the comment on the PR (needs a token + merge request context)', false)
|
|
1062
|
+
.option('--pr-provider <name>', `Force the comment delivery target: ${PR_PROVIDER_IDS.join(' | ')} (default: detect from CI)`)
|
|
819
1063
|
.option('--fail-on <rules>', 'Comma-separated fail rules: changed,added,removed,policy,error,warn', 'changed,policy,error')
|
|
1064
|
+
.option('--baseline <file>', 'Gate only on findings not already recorded in this baseline file')
|
|
1065
|
+
.option('--update-baseline', 'Rewrite the --baseline file from the current findings (explicit, never automatic)', false)
|
|
820
1066
|
.option('--ignore <keys>', 'Comma-separated key paths to ignore')
|
|
821
1067
|
.option('--policies <ids>', 'Comma-separated policy pack ids')
|
|
822
1068
|
.option('--plugins <paths>', 'Comma-separated local ESM plugin paths')
|
|
@@ -824,6 +1070,8 @@ program
|
|
|
824
1070
|
.option('--no-array-id', 'Diff arrays by index instead of object identity')
|
|
825
1071
|
.option('--array-ignore-order', 'Treat array order as insignificant', false)
|
|
826
1072
|
.option('--mask-secrets', 'Mask secret-like values in CI output', false)
|
|
1073
|
+
.option('--show-suppressed', 'List inline-suppressed findings instead of only counting them', false)
|
|
1074
|
+
.option('--changed-only', 'With --format json|ndjson, replace envelopes for unchanged files with one scanned manifest', false)
|
|
827
1075
|
.option('--allow-empty', 'Allow CI to succeed when no files were diffed', false)
|
|
828
1076
|
.action(async (files, opts, command) => {
|
|
829
1077
|
try {
|
|
@@ -840,19 +1088,61 @@ program
|
|
|
840
1088
|
const ignorePaths = parseCsv(effective.ignore);
|
|
841
1089
|
const failOn = parseFailOn(effective.failOn ?? 'changed,policy,error');
|
|
842
1090
|
const format = String(effective.format ?? 'json');
|
|
843
|
-
if (!['json', 'ndjson', 'github-annotations', 'pr-comment'].includes(format)) {
|
|
844
|
-
throw new Error('--format must be json, ndjson, github-annotations, or pr-comment');
|
|
1091
|
+
if (!['json', 'ndjson', 'sarif', 'github-annotations', 'pr-comment'].includes(format)) {
|
|
1092
|
+
throw new Error('--format must be json, ndjson, sarif, github-annotations, or pr-comment');
|
|
845
1093
|
}
|
|
846
1094
|
const prCommentPost = Boolean(effective.prCommentPost);
|
|
1095
|
+
if (effective.prProvider && !PR_PROVIDER_IDS.includes(String(effective.prProvider))) {
|
|
1096
|
+
throw new Error(`--pr-provider must be one of: ${PR_PROVIDER_IDS.join(', ')}`);
|
|
1097
|
+
}
|
|
847
1098
|
if (prCommentPost && format !== 'pr-comment') {
|
|
848
1099
|
renderWarn('Ignoring --pr-comment-post: it only applies to --format pr-comment.');
|
|
849
1100
|
}
|
|
850
1101
|
const maskSecrets = Boolean(effective.maskSecrets);
|
|
1102
|
+
const showSuppressed = Boolean(effective.showSuppressed);
|
|
1103
|
+
const changedOnly = Boolean(effective.changedOnly);
|
|
1104
|
+
// github-annotations and pr-comment already render only what changed, so
|
|
1105
|
+
// there is nothing for the flag to collapse there. Say so rather than
|
|
1106
|
+
// accepting it and quietly doing nothing.
|
|
1107
|
+
if (changedOnly && format !== 'json' && format !== 'ndjson') {
|
|
1108
|
+
renderWarn(`Ignoring --changed-only: it only applies to --format json or ndjson (got ${format}).`);
|
|
1109
|
+
}
|
|
851
1110
|
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
852
1111
|
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
1112
|
+
const snapshotStore = snapshotStoreFromEffective(effective);
|
|
1113
|
+
|
|
1114
|
+
const cwd = process.cwd();
|
|
1115
|
+
const baselinePath = effective.baseline ? resolve(cwd, String(effective.baseline)) : null;
|
|
1116
|
+
if (baselinePath) {
|
|
1117
|
+
assertWriteDestinationContained(baselinePath, {
|
|
1118
|
+
option: '--baseline',
|
|
1119
|
+
fromCli: cliOverrides.baseline !== undefined,
|
|
1120
|
+
cwd,
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
// `--update-baseline` accepts every finding this run produced, so honoring
|
|
1124
|
+
// it from `.flectorc` would let a pull request turn its own failing gate
|
|
1125
|
+
// green — including one whose `--fail-on` was set explicitly on the command
|
|
1126
|
+
// line. It is an action, not a setting: the CLI is the only place it can
|
|
1127
|
+
// come from, and a declaration in the rc file is refused rather than
|
|
1128
|
+
// ignored, so a repository that meant it finds out.
|
|
1129
|
+
if (effective.updateBaseline && cliOverrides.updateBaseline === undefined) {
|
|
1130
|
+
throw new Error(
|
|
1131
|
+
'updateBaseline is declared in .flectorc, and it is refused there: it accepts every'
|
|
1132
|
+
+ ' current finding, which would turn a failing gate green from a file a pull request'
|
|
1133
|
+
+ ' can edit. Pass --update-baseline on the command line when you mean to record a'
|
|
1134
|
+
+ ' baseline.',
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
1137
|
+
const updateBaseline = Boolean(effective.updateBaseline);
|
|
1138
|
+
if (updateBaseline && !baselinePath) {
|
|
1139
|
+
throw new Error('--update-baseline requires --baseline <file> naming the file to write.');
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/** @type {Array<{ filepath: string, relFile: string, outboundChanges: any[], outboundFindings: any[], changesFail: boolean }>} */
|
|
1143
|
+
const perFile = [];
|
|
1144
|
+
/** @type {Array<{ file: string, finding: any, reason: string }>} */
|
|
1145
|
+
const allSuppressed = [];
|
|
856
1146
|
let diffed = 0;
|
|
857
1147
|
|
|
858
1148
|
for (const filepath of targets) {
|
|
@@ -864,10 +1154,14 @@ program
|
|
|
864
1154
|
renderWarn(`Skipping unsupported file: ${filepath}`);
|
|
865
1155
|
continue;
|
|
866
1156
|
}
|
|
867
|
-
|
|
1157
|
+
// A `--snapshot-ref` baseline is read straight from git, so it is never
|
|
1158
|
+
// masked; only a store-provided baseline needs the live side aligned.
|
|
1159
|
+
const after = effective.snapshotRef
|
|
1160
|
+
? parseFile(filepath)
|
|
1161
|
+
: alignStateWithStore(parseFile(filepath), snapshotStore);
|
|
868
1162
|
let before;
|
|
869
1163
|
try {
|
|
870
|
-
before = readSnapshotStateFromRef(filepath, effective.snapshotRef);
|
|
1164
|
+
before = readSnapshotStateFromRef(filepath, effective.snapshotRef, snapshotStore);
|
|
871
1165
|
} catch (err) {
|
|
872
1166
|
throw new Error(
|
|
873
1167
|
`Failed to resolve snapshot baseline for "${filepath}"` +
|
|
@@ -875,8 +1169,8 @@ program
|
|
|
875
1169
|
);
|
|
876
1170
|
}
|
|
877
1171
|
const events = diffTrees(before, after, dOpts);
|
|
878
|
-
const
|
|
879
|
-
cwd
|
|
1172
|
+
const rawFindings = await evaluatePolicies(events, {
|
|
1173
|
+
cwd,
|
|
880
1174
|
file: filepath,
|
|
881
1175
|
profile: profile ?? null,
|
|
882
1176
|
source: 'ci',
|
|
@@ -884,20 +1178,24 @@ program
|
|
|
884
1178
|
plugins,
|
|
885
1179
|
severityRemap,
|
|
886
1180
|
});
|
|
887
|
-
const outboundChanges = maybeMaskChanges(events, maskSecrets);
|
|
888
|
-
const outboundFindings = maybeMaskFindings(policyFindings, events, maskSecrets);
|
|
889
|
-
const envelope = createEnvelope({
|
|
890
|
-
source: 'ci',
|
|
891
|
-
file: filepath,
|
|
892
|
-
changes: outboundChanges,
|
|
893
|
-
policies: outboundFindings,
|
|
894
|
-
});
|
|
895
|
-
results.push({ file: filepath, envelope, policies: outboundFindings });
|
|
896
|
-
diffed += 1;
|
|
897
1181
|
|
|
898
|
-
|
|
899
|
-
|
|
1182
|
+
// Inline suppressions run first: a deliberately-accepted finding is
|
|
1183
|
+
// removed before the baseline, the gate, and the output ever see it, so
|
|
1184
|
+
// it is never also counted by a baseline. A directive missing its
|
|
1185
|
+
// mandatory reason is refused loudly rather than applied.
|
|
1186
|
+
const { active: policyFindings, suppressed } = resolveSuppressed(filepath, rawFindings);
|
|
1187
|
+
for (const item of suppressed) {
|
|
1188
|
+
allSuppressed.push({ file: filepath, finding: item.finding, reason: item.reason });
|
|
900
1189
|
}
|
|
1190
|
+
|
|
1191
|
+
perFile.push({
|
|
1192
|
+
filepath,
|
|
1193
|
+
relFile: baselineRelativePath(filepath, cwd),
|
|
1194
|
+
outboundChanges: maybeMaskChanges(events, maskSecrets),
|
|
1195
|
+
outboundFindings: maybeMaskFindings(policyFindings, events, maskSecrets),
|
|
1196
|
+
changesFail: shouldFailFromChanges(events, failOn),
|
|
1197
|
+
});
|
|
1198
|
+
diffed += 1;
|
|
901
1199
|
}
|
|
902
1200
|
|
|
903
1201
|
if (diffed === 0 && !effective.allowEmpty) {
|
|
@@ -907,12 +1205,100 @@ program
|
|
|
907
1205
|
);
|
|
908
1206
|
}
|
|
909
1207
|
|
|
1208
|
+
// Suppressed findings are still surfaced — a count by default, the full
|
|
1209
|
+
// list with --show-suppressed — so a gate you cannot see the shape of does
|
|
1210
|
+
// not quietly grow. All of this goes to stderr, leaving machine output clean.
|
|
1211
|
+
if (allSuppressed.length > 0) {
|
|
1212
|
+
renderNote(
|
|
1213
|
+
`${allSuppressed.length} finding${allSuppressed.length === 1 ? '' : 's'} suppressed inline`
|
|
1214
|
+
+ `${showSuppressed ? ':' : ' (use --show-suppressed to list them).'}`,
|
|
1215
|
+
);
|
|
1216
|
+
if (showSuppressed) {
|
|
1217
|
+
for (const { file, finding, reason } of allSuppressed) {
|
|
1218
|
+
const rel = relative(cwd, file) || file;
|
|
1219
|
+
renderNote(` ${rel} ${finding.path}: ${finding.id} — ${reason}`);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// Every finding this run produced, paired with its repo-relative file, so
|
|
1225
|
+
// the baseline can be matched, updated, and stale-checked on stable keys.
|
|
1226
|
+
const located = perFile.flatMap((f) =>
|
|
1227
|
+
f.outboundFindings.map((finding) => ({ file: f.relFile, finding })));
|
|
1228
|
+
|
|
1229
|
+
// Without a baseline, every finding is active — behavior is unchanged.
|
|
1230
|
+
let activeByFile = new Map(perFile.map((f) => [f.relFile, f.outboundFindings]));
|
|
1231
|
+
let baselineSummary = null;
|
|
1232
|
+
if (baselinePath) {
|
|
1233
|
+
const { entries: recorded } = loadBaseline(baselinePath);
|
|
1234
|
+
|
|
1235
|
+
if (updateBaseline) {
|
|
1236
|
+
// Recording the current state accepts all of it: the file is rewritten
|
|
1237
|
+
// from every finding, and nothing is "new" relative to what was just
|
|
1238
|
+
// written, so the gate passes on the policy axis.
|
|
1239
|
+
writeBaselineFile(baselinePath, buildBaselineFile(located, recorded));
|
|
1240
|
+
renderNote(
|
|
1241
|
+
`Baseline updated: ${baselineRelativePath(baselinePath, cwd)} `
|
|
1242
|
+
+ `(${located.length} finding${located.length === 1 ? '' : 's'} recorded)`,
|
|
1243
|
+
);
|
|
1244
|
+
activeByFile = new Map();
|
|
1245
|
+
baselineSummary = { active: 0, accepted: located.length, stale: [] };
|
|
1246
|
+
} else {
|
|
1247
|
+
const { active, accepted, stale } = applyBaseline(located, recorded);
|
|
1248
|
+
const activeMap = new Map();
|
|
1249
|
+
for (const { file, finding } of active) {
|
|
1250
|
+
if (!activeMap.has(file)) activeMap.set(file, []);
|
|
1251
|
+
activeMap.get(file).push(finding);
|
|
1252
|
+
}
|
|
1253
|
+
activeByFile = activeMap;
|
|
1254
|
+
baselineSummary = { active: active.length, accepted: accepted.length, stale };
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// Results reflect the *active* findings: with a baseline in effect, an
|
|
1259
|
+
// accepted finding is suppressed from output as well as from the gate, so a
|
|
1260
|
+
// green run is not buried under hundreds of already-accepted findings.
|
|
1261
|
+
const results = perFile.map((f) => {
|
|
1262
|
+
const active = activeByFile.get(f.relFile) ?? [];
|
|
1263
|
+
return {
|
|
1264
|
+
file: f.filepath,
|
|
1265
|
+
envelope: createEnvelope({
|
|
1266
|
+
source: 'ci',
|
|
1267
|
+
file: f.filepath,
|
|
1268
|
+
changes: f.outboundChanges,
|
|
1269
|
+
policies: active,
|
|
1270
|
+
}),
|
|
1271
|
+
policies: active,
|
|
1272
|
+
};
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
// With --update-baseline everything is now accepted, so there are no active
|
|
1276
|
+
// findings to gate on; the policy gate simply passes. Change-based triggers
|
|
1277
|
+
// are about the diff, not the findings, so they still apply.
|
|
1278
|
+
const activeFindings = results.flatMap((r) => r.policies);
|
|
1279
|
+
const shouldFail = perFile.some((f) => f.changesFail)
|
|
1280
|
+
|| shouldFailFromPolicy(activeFindings, failOn);
|
|
1281
|
+
|
|
1282
|
+
if (baselineSummary) {
|
|
1283
|
+
const parts = [`${baselineSummary.active} new`, `${baselineSummary.accepted} baselined`];
|
|
1284
|
+
if (baselineSummary.stale.length > 0) parts.push(`${baselineSummary.stale.length} stale`);
|
|
1285
|
+
renderNote(`Baseline: ${parts.join(', ')}.`);
|
|
1286
|
+
if (baselineSummary.stale.length > 0 && !updateBaseline) {
|
|
1287
|
+
renderWarn(
|
|
1288
|
+
`${baselineSummary.stale.length} baseline `
|
|
1289
|
+
+ `${baselineSummary.stale.length === 1 ? 'entry no longer occurs' : 'entries no longer occur'}; `
|
|
1290
|
+
+ 're-run with --update-baseline to prune.',
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
910
1295
|
if (format === 'pr-comment') {
|
|
911
|
-
const body = renderPrComment(results, { cwd
|
|
912
|
-
|
|
913
|
-
await deliverPrCommentSafely(body, prCommentPost);
|
|
1296
|
+
const body = renderPrComment(results, { cwd, failed: shouldFail });
|
|
1297
|
+
await writeStdout(body);
|
|
1298
|
+
await deliverPrCommentSafely(body, prCommentPost, effective.prProvider);
|
|
914
1299
|
} else {
|
|
915
|
-
|
|
1300
|
+
const collapsible = changedOnly && (format === 'json' || format === 'ndjson');
|
|
1301
|
+
await printCiOutput(collapsible ? collapseUnchangedResults(results) : results, format);
|
|
916
1302
|
}
|
|
917
1303
|
process.exit(shouldFail ? 1 : 0);
|
|
918
1304
|
} catch (err) {
|
|
@@ -926,7 +1312,8 @@ program
|
|
|
926
1312
|
.description('Diff Terraform plan JSON (terraform show -json) and run policies on it')
|
|
927
1313
|
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
928
1314
|
.option('--format <type>', 'Output format: human | json | ndjson | github-annotations | pr-comment', 'human')
|
|
929
|
-
.option('--pr-comment-post', 'With --format pr-comment, upsert the comment on the PR (needs
|
|
1315
|
+
.option('--pr-comment-post', 'With --format pr-comment, upsert the comment on the PR (needs a token + merge request context)', false)
|
|
1316
|
+
.option('--pr-provider <name>', `Force the comment delivery target: ${PR_PROVIDER_IDS.join(' | ')} (default: detect from CI)`)
|
|
930
1317
|
.option('--fail-on <rules>', 'Comma-separated fail rules: changed,added,removed,policy,error,warn', PLAN_DEFAULT_FAIL_ON)
|
|
931
1318
|
.option('--ignore <keys>', 'Comma-separated key paths to ignore, e.g. "**.tags_all,**.#action"')
|
|
932
1319
|
.option('--policies <ids>', `Comma-separated policy pack ids (default: ${PLAN_DEFAULT_POLICIES})`)
|
|
@@ -955,6 +1342,9 @@ program
|
|
|
955
1342
|
throw new Error('--format must be human, json, ndjson, github-annotations, or pr-comment');
|
|
956
1343
|
}
|
|
957
1344
|
const prCommentPost = Boolean(effective.prCommentPost);
|
|
1345
|
+
if (effective.prProvider && !PR_PROVIDER_IDS.includes(String(effective.prProvider))) {
|
|
1346
|
+
throw new Error(`--pr-provider must be one of: ${PR_PROVIDER_IDS.join(', ')}`);
|
|
1347
|
+
}
|
|
958
1348
|
if (prCommentPost && format !== 'pr-comment') {
|
|
959
1349
|
renderWarn('Ignoring --pr-comment-post: it only applies to --format pr-comment.');
|
|
960
1350
|
}
|
|
@@ -1013,10 +1403,10 @@ program
|
|
|
1013
1403
|
|
|
1014
1404
|
if (format === 'pr-comment') {
|
|
1015
1405
|
const body = renderPrComment(results, { cwd: process.cwd(), failed: shouldFail });
|
|
1016
|
-
|
|
1017
|
-
await deliverPrCommentSafely(body, prCommentPost);
|
|
1406
|
+
await writeStdout(body);
|
|
1407
|
+
await deliverPrCommentSafely(body, prCommentPost, effective.prProvider);
|
|
1018
1408
|
} else if (format !== 'human') {
|
|
1019
|
-
printCiOutput(results, format);
|
|
1409
|
+
await printCiOutput(results, format);
|
|
1020
1410
|
}
|
|
1021
1411
|
process.exit(shouldFail ? 1 : 0);
|
|
1022
1412
|
} catch (err) {
|
|
@@ -1099,7 +1489,7 @@ program
|
|
|
1099
1489
|
// Same envelope and printer as `ci`, so machine consumers see one shape.
|
|
1100
1490
|
// `baseline` rides on the result wrapper rather than the envelope, which
|
|
1101
1491
|
// is closed by schemas/flecto-envelope-2.0.json.
|
|
1102
|
-
printCiOutput(
|
|
1492
|
+
await printCiOutput(
|
|
1103
1493
|
[{ file: targetPath, baseline: baselinePath, envelope, policies: outboundFindings }],
|
|
1104
1494
|
format,
|
|
1105
1495
|
);
|