flecto 3.0.1 → 3.0.2
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 +501 -1
- package/README.md +11 -1
- package/index.js +410 -44
- 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 +404 -6
- 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 +53 -87
- package/src/pr-providers.js +261 -0
- package/src/renderer.js +7 -7
- package/src/report.js +39 -1
- package/src/sarif.js +144 -0
- package/src/secrets.js +41 -7
- package/src/suppressions.js +431 -0
- package/src/terraform.js +28 -0
package/index.js
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
maskSensitiveValue,
|
|
25
25
|
} from './src/renderer.js';
|
|
26
26
|
import { deliverPrComment, renderPrComment } from './src/pr-comment.js';
|
|
27
|
+
import { PR_PROVIDER_IDS } from './src/pr-providers.js';
|
|
27
28
|
import {
|
|
28
29
|
diffTerraformPlan,
|
|
29
30
|
formatPlanSummary,
|
|
@@ -34,6 +35,19 @@ import { redactSecretString } from './src/secrets.js';
|
|
|
34
35
|
import { fireAlerts } from './src/alerter.js';
|
|
35
36
|
import { resolveWebhookFormat, WEBHOOK_FORMAT_CHOICES } from './src/notifiers.js';
|
|
36
37
|
import { createEnvelope } from './src/envelope.js';
|
|
38
|
+
import { buildSarif } from './src/sarif.js';
|
|
39
|
+
import {
|
|
40
|
+
loadBaseline,
|
|
41
|
+
applyBaseline,
|
|
42
|
+
buildBaselineFile,
|
|
43
|
+
writeBaselineFile,
|
|
44
|
+
baselineRelativePath,
|
|
45
|
+
} from './src/baseline.js';
|
|
46
|
+
import {
|
|
47
|
+
suppressionFormat,
|
|
48
|
+
parseSuppressions,
|
|
49
|
+
applySuppressions,
|
|
50
|
+
} from './src/suppressions.js';
|
|
37
51
|
import {
|
|
38
52
|
evaluatePolicies,
|
|
39
53
|
highestSeverity,
|
|
@@ -48,6 +62,7 @@ import {
|
|
|
48
62
|
initRcFile,
|
|
49
63
|
resolveProfileName,
|
|
50
64
|
resolvePolicyOptions,
|
|
65
|
+
assertTargetContained,
|
|
51
66
|
} from './src/config.js';
|
|
52
67
|
|
|
53
68
|
const PKG = JSON.parse(
|
|
@@ -275,6 +290,18 @@ function maybeMaskFindings(findings, changes, maskSecrets) {
|
|
|
275
290
|
});
|
|
276
291
|
}
|
|
277
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Every command's targets pass through here, which makes it the one place the
|
|
295
|
+
* symlink-escape check has to run: a target that leaves the project through a
|
|
296
|
+
* link is refused before anything reads it.
|
|
297
|
+
* @param {string[]} files
|
|
298
|
+
* @returns {string[]} the same list
|
|
299
|
+
*/
|
|
300
|
+
function assertTargetsContained(files) {
|
|
301
|
+
for (const file of files) assertTargetContained(file, process.cwd());
|
|
302
|
+
return files;
|
|
303
|
+
}
|
|
304
|
+
|
|
278
305
|
async function resolveTargetFiles(cliFiles, rcConfig) {
|
|
279
306
|
if (cliFiles && cliFiles.length > 0) {
|
|
280
307
|
const direct = [];
|
|
@@ -294,15 +321,15 @@ async function resolveTargetFiles(cliFiles, rcConfig) {
|
|
|
294
321
|
exclude: rcConfig?.exclude ?? [],
|
|
295
322
|
});
|
|
296
323
|
}
|
|
297
|
-
return [...new Set([...direct, ...expanded])];
|
|
324
|
+
return assertTargetsContained([...new Set([...direct, ...expanded])]);
|
|
298
325
|
}
|
|
299
326
|
|
|
300
|
-
return resolveFiles({
|
|
327
|
+
return assertTargetsContained(await resolveFiles({
|
|
301
328
|
cwd: process.cwd(),
|
|
302
329
|
files: rcConfig?.files ?? [],
|
|
303
330
|
include: rcConfig?.include ?? [],
|
|
304
331
|
exclude: rcConfig?.exclude ?? [],
|
|
305
|
-
});
|
|
332
|
+
}));
|
|
306
333
|
}
|
|
307
334
|
|
|
308
335
|
/**
|
|
@@ -346,10 +373,28 @@ function gitRepoRelativePath(filePath) {
|
|
|
346
373
|
/**
|
|
347
374
|
* Resolve symlinks where possible, falling back to the input when the path does
|
|
348
375
|
* not exist on disk.
|
|
376
|
+
*
|
|
377
|
+
* `realpathSync.native` is tried first because on Windows it asks the OS for the
|
|
378
|
+
* final path, which resolves 8.3 short names and normalizes case. Those are not
|
|
379
|
+
* cosmetic here: `git rev-parse --show-toplevel` reports the long form, while
|
|
380
|
+
* `os.tmpdir()` and many shells hand Flecto the short one
|
|
381
|
+
* (`C:\Users\RUNNER~1\...`). The JS `realpathSync` leaves both as written, so
|
|
382
|
+
* the two spellings of one directory compare as different and `relative()`
|
|
383
|
+
* produces a path that climbs out of the repository -- making
|
|
384
|
+
* `--snapshot-ref <git-ref>` fail on a file that is plainly tracked.
|
|
385
|
+
*
|
|
386
|
+
* On Linux and macOS the two agree for any path that exists, so this only ever
|
|
387
|
+
* changes the Windows result.
|
|
349
388
|
* @param {string} path
|
|
350
389
|
* @returns {string}
|
|
351
390
|
*/
|
|
352
391
|
function canonicalPath(path) {
|
|
392
|
+
try {
|
|
393
|
+
return realpathSync.native(path);
|
|
394
|
+
} catch {
|
|
395
|
+
// Falls back for a path that does not exist yet, and for the rare platform
|
|
396
|
+
// where the native call is unavailable.
|
|
397
|
+
}
|
|
353
398
|
try {
|
|
354
399
|
return realpathSync(path);
|
|
355
400
|
} catch {
|
|
@@ -358,7 +403,21 @@ function canonicalPath(path) {
|
|
|
358
403
|
}
|
|
359
404
|
|
|
360
405
|
function readSnapshotStateFromRef(filePath, snapshotRef) {
|
|
361
|
-
if (!snapshotRef)
|
|
406
|
+
if (!snapshotRef) {
|
|
407
|
+
const snapshotPath = snapshotPathForFile(filePath);
|
|
408
|
+
// Failing closed here is right — a diff with no baseline is not a clean
|
|
409
|
+
// diff — but an ENOENT on a hashed filename explains nothing. Snapshot
|
|
410
|
+
// history is local to the working directory, so this is what an ephemeral
|
|
411
|
+
// CI runner hits on every run (#141).
|
|
412
|
+
if (!existsSync(snapshotPath)) {
|
|
413
|
+
throw new Error(
|
|
414
|
+
`no local snapshot has been saved for this file (${SNAPSHOT_DIR}/ holds none).`
|
|
415
|
+
+ ' Save one with "flecto watch <file> --snapshot", or pass --snapshot-ref'
|
|
416
|
+
+ ' <git-ref> to diff against a committed revision instead',
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
return readSnapshotStateFromFile(snapshotPath);
|
|
420
|
+
}
|
|
362
421
|
const maybePath = resolve(snapshotRef);
|
|
363
422
|
if (existsSync(maybePath)) {
|
|
364
423
|
return readSnapshotStateFromFile(maybePath);
|
|
@@ -376,6 +435,40 @@ function shouldFailFromPolicy(findings, failOn) {
|
|
|
376
435
|
return false;
|
|
377
436
|
}
|
|
378
437
|
|
|
438
|
+
/**
|
|
439
|
+
* Parse a file's inline suppressions and split its findings into active and
|
|
440
|
+
* suppressed. A directive missing its mandatory reason is a hard error: applying
|
|
441
|
+
* it would hide a finding with no justification, and skipping it silently would
|
|
442
|
+
* fail the build confusingly.
|
|
443
|
+
*
|
|
444
|
+
* A directive that resolves to nothing — an array element, or a file type with
|
|
445
|
+
* no comment syntax — is a warning instead of an error. It already fails closed,
|
|
446
|
+
* because the finding it meant to accept still fires and still gates, so failing
|
|
447
|
+
* the build a second time adds nothing; what was missing was any signal at all
|
|
448
|
+
* that the directive did not take effect.
|
|
449
|
+
* @param {string} filepath
|
|
450
|
+
* @param {import('./src/policy.js').PolicyFinding[]} findings
|
|
451
|
+
* @returns {{ active: any[], suppressed: Array<{ finding: any, reason: string }> }}
|
|
452
|
+
*/
|
|
453
|
+
function resolveSuppressed(filepath, findings) {
|
|
454
|
+
const format = suppressionFormat(filepath);
|
|
455
|
+
|
|
456
|
+
let raw;
|
|
457
|
+
try {
|
|
458
|
+
raw = readFileSync(filepath, 'utf8');
|
|
459
|
+
} catch {
|
|
460
|
+
return { active: findings, suppressed: [] };
|
|
461
|
+
}
|
|
462
|
+
const { suppressions, errors, warnings } = parseSuppressions(raw, format);
|
|
463
|
+
const rel = relative(process.cwd(), filepath) || filepath;
|
|
464
|
+
if (errors.length > 0) {
|
|
465
|
+
const detail = errors.map((e) => ` ${rel}:${e.line}: ${e.message}`).join('\n');
|
|
466
|
+
throw new Error(`Inline suppression is missing a required reason:\n${detail}`);
|
|
467
|
+
}
|
|
468
|
+
for (const warning of warnings) renderNote(`${rel}:${warning.line}: ${warning.message}`);
|
|
469
|
+
return applySuppressions(findings, suppressions);
|
|
470
|
+
}
|
|
471
|
+
|
|
379
472
|
function shouldFailFromChanges(events, failOn) {
|
|
380
473
|
if (events.length === 0) return false;
|
|
381
474
|
if (failOn.has('changed') && events.some((e) => e.type === 'changed')) return true;
|
|
@@ -397,32 +490,143 @@ function escapeWorkflowCommandProperty(value) {
|
|
|
397
490
|
.replaceAll(',', '%2C');
|
|
398
491
|
}
|
|
399
492
|
|
|
400
|
-
|
|
493
|
+
/**
|
|
494
|
+
* Write to stdout and resolve only once the bytes have actually left.
|
|
495
|
+
*
|
|
496
|
+
* `process.exit()` does not flush a pending stdout write, and Node writes to a
|
|
497
|
+
* pipe asynchronously. So `flecto ci --format json | jq`, or any CI harness
|
|
498
|
+
* capturing stdout, silently lost everything past the 64 KB pipe buffer -- and
|
|
499
|
+
* still saw exit 0. A truncated envelope stream that reports success is the
|
|
500
|
+
* worst shape a machine consumer can be handed: it does not look like a
|
|
501
|
+
* failure, it looks like a clean run over fewer files.
|
|
502
|
+
*
|
|
503
|
+
* Redirecting to a file hid this, because Node writes to a file descriptor
|
|
504
|
+
* synchronously. It only appeared through a pipe, which is how every consumer
|
|
505
|
+
* that matters reads it.
|
|
506
|
+
*
|
|
507
|
+
* The callback form fires when that specific chunk drains, and stream writes
|
|
508
|
+
* are ordered, so awaiting the last one means every earlier one is out too.
|
|
509
|
+
*
|
|
510
|
+
* The trailing newline is appended unconditionally, which is exactly what
|
|
511
|
+
* `console.log` did. Adding it only when one is missing would silently drop a
|
|
512
|
+
* byte from any payload that already ends in a newline -- `--format pr-comment`
|
|
513
|
+
* does -- and the point of this change is that the rendered output is identical.
|
|
514
|
+
* @param {string} text
|
|
515
|
+
* @returns {Promise<void>}
|
|
516
|
+
*/
|
|
517
|
+
function writeStdout(text) {
|
|
518
|
+
return new Promise((resolveWrite) => {
|
|
519
|
+
process.stdout.write(`${text}\n`, () => resolveWrite());
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Collapse the envelopes for files that were scanned and had nothing to report
|
|
525
|
+
* into a single manifest entry.
|
|
526
|
+
*
|
|
527
|
+
* `ci` emits one envelope per *scanned* file rather than per *changed* file, so
|
|
528
|
+
* the output grows with the size of the repository instead of the size of the
|
|
529
|
+
* change -- measured on 250 service configs with one file edited, 113.4 KB of
|
|
530
|
+
* output for 0.2 KB of semantic content. For a human that is invisible, because
|
|
531
|
+
* the renderer already prints only what changed; it is the machine consumers
|
|
532
|
+
* (webhooks, NDJSON sinks, and any agent handed the JSON) that pay for it.
|
|
533
|
+
*
|
|
534
|
+
* Dropping those files outright is not safe. An envelope for a scanned but
|
|
535
|
+
* unchanged file is *evidence Flecto looked*, and a consumer diffing two runs
|
|
536
|
+
* can tell "checked and clean" from "not checked at all" -- silently removing
|
|
537
|
+
* that distinction would weaken a gate someone relies on, in the same way a
|
|
538
|
+
* silently skipped plugin would. So the evidence is kept, in the one place it
|
|
539
|
+
* costs almost nothing: a single `lifecycle` envelope carrying the list of
|
|
540
|
+
* paths, instead of a full envelope with its own pair of UUIDs, timestamp, and
|
|
541
|
+
* absolute path for every file.
|
|
542
|
+
*
|
|
543
|
+
* The path list rides on the result wrapper rather than the envelope, which is
|
|
544
|
+
* closed by schemas/flecto-envelope-2.0.json -- the same arrangement `baseline`
|
|
545
|
+
* already uses. Nothing about schema 2.0 changes, and the default output is
|
|
546
|
+
* untouched, so this is opt-in rather than a reshaping of a documented contract.
|
|
547
|
+
*
|
|
548
|
+
* Each envelope keeps its own `batch_id`: that field is documented as grouping
|
|
549
|
+
* the events from one file change, not one run.
|
|
550
|
+
* @param {any[]} results
|
|
551
|
+
* @returns {any[]}
|
|
552
|
+
*/
|
|
553
|
+
function collapseUnchangedResults(results) {
|
|
554
|
+
const reported = [];
|
|
555
|
+
/** @type {string[]} */
|
|
556
|
+
const scanned = [];
|
|
557
|
+
|
|
558
|
+
for (const result of results) {
|
|
559
|
+
const hasChanges = (result.envelope.changes?.length ?? 0) > 0;
|
|
560
|
+
const hasFindings = (result.envelope.policies?.length ?? 0) > 0;
|
|
561
|
+
if (hasChanges || hasFindings) reported.push(result);
|
|
562
|
+
else scanned.push(result.file);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (scanned.length === 0) return reported;
|
|
566
|
+
return [
|
|
567
|
+
...reported,
|
|
568
|
+
{
|
|
569
|
+
// No `file`: this entry is not about one file. Consumers discriminate on
|
|
570
|
+
// `envelope.event_type === "lifecycle"`, which schema 2.0 already carries.
|
|
571
|
+
scanned,
|
|
572
|
+
envelope: createEnvelope({
|
|
573
|
+
source: 'ci',
|
|
574
|
+
file: '',
|
|
575
|
+
lifecycle: {
|
|
576
|
+
type: 'scanned',
|
|
577
|
+
message:
|
|
578
|
+
`${scanned.length} file${scanned.length === 1 ? '' : 's'} scanned `
|
|
579
|
+
+ 'with no changes and no policy findings',
|
|
580
|
+
},
|
|
581
|
+
}),
|
|
582
|
+
policies: [],
|
|
583
|
+
},
|
|
584
|
+
];
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Render the machine-readable CI output and wait for it to flush.
|
|
589
|
+
*
|
|
590
|
+
* The payload is assembled and written once rather than line by line, so a
|
|
591
|
+
* caller has a single write to await -- see {@link writeStdout} for why that
|
|
592
|
+
* matters. The rendered bytes are unchanged.
|
|
593
|
+
* @param {any[]} results
|
|
594
|
+
* @param {string} format
|
|
595
|
+
* @returns {Promise<void>}
|
|
596
|
+
*/
|
|
597
|
+
async function printCiOutput(results, format) {
|
|
401
598
|
if (format === 'json') {
|
|
402
|
-
|
|
599
|
+
await writeStdout(JSON.stringify(results, null, 2));
|
|
403
600
|
return;
|
|
404
601
|
}
|
|
405
602
|
if (format === 'ndjson') {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
603
|
+
if (results.length === 0) return;
|
|
604
|
+
await writeStdout(results.map((result) => JSON.stringify(result)).join('\n'));
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
if (format === 'sarif') {
|
|
608
|
+
const sarif = buildSarif(results, { cwd: process.cwd(), toolVersion: PKG.version });
|
|
609
|
+
await writeStdout(JSON.stringify(sarif, null, 2));
|
|
409
610
|
return;
|
|
410
611
|
}
|
|
411
612
|
if (format === 'github-annotations') {
|
|
613
|
+
const lines = [];
|
|
412
614
|
for (const result of results) {
|
|
413
615
|
for (const event of result.envelope.changes) {
|
|
414
616
|
const title = `flecto ${event.type}`;
|
|
415
617
|
const detail = event.note ? `${event.path} (${event.note})` : event.path;
|
|
416
|
-
|
|
618
|
+
lines.push(`::warning file=${escapeWorkflowCommandProperty(result.file)},title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandData(detail)}`);
|
|
417
619
|
}
|
|
418
620
|
for (const finding of result.policies) {
|
|
419
621
|
const level = finding.severity === 'error' ? 'error' : 'warning';
|
|
420
622
|
const pack = finding.pack ? ` [${finding.pack}]` : '';
|
|
421
623
|
const title = `flecto policy ${finding.id}${pack}`;
|
|
422
624
|
const detail = `${finding.path}: ${finding.message}`;
|
|
423
|
-
|
|
625
|
+
lines.push(`::${level} file=${escapeWorkflowCommandProperty(result.file)},title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandData(detail)}`);
|
|
424
626
|
}
|
|
425
627
|
}
|
|
628
|
+
if (lines.length === 0) return;
|
|
629
|
+
await writeStdout(lines.join('\n'));
|
|
426
630
|
}
|
|
427
631
|
}
|
|
428
632
|
|
|
@@ -431,11 +635,12 @@ function printCiOutput(results, format) {
|
|
|
431
635
|
* delivery problem warns, and the exit code stays with the diff/policy result.
|
|
432
636
|
* @param {string} body
|
|
433
637
|
* @param {boolean} enabled
|
|
638
|
+
* @param {string} [provider] force a delivery adapter instead of detecting one
|
|
434
639
|
*/
|
|
435
|
-
async function deliverPrCommentSafely(body, enabled) {
|
|
640
|
+
async function deliverPrCommentSafely(body, enabled, provider) {
|
|
436
641
|
if (!enabled) return;
|
|
437
642
|
try {
|
|
438
|
-
const result = await deliverPrComment(body, { enabled: true });
|
|
643
|
+
const result = await deliverPrComment(body, { enabled: true, provider });
|
|
439
644
|
if (result.posted) {
|
|
440
645
|
renderNote(`PR comment ${result.action}${result.url ? `: ${result.url}` : ''}`);
|
|
441
646
|
return;
|
|
@@ -505,6 +710,11 @@ program
|
|
|
505
710
|
|
|
506
711
|
if (effective.snapshot) {
|
|
507
712
|
mkdirSync(SNAPSHOT_DIR, { recursive: true });
|
|
713
|
+
// Snapshots carry config values, so a .flecto-snapshots/ that is itself a
|
|
714
|
+
// link out of the project would write them somewhere the repository does
|
|
715
|
+
// not control. Same rule as a target, checked after mkdir so an existing
|
|
716
|
+
// link is seen rather than a path that does not exist yet.
|
|
717
|
+
assertTargetContained(resolve(SNAPSHOT_DIR), process.cwd());
|
|
508
718
|
const idsWithHistory = snapshotIdsWithHistory();
|
|
509
719
|
let written = 0;
|
|
510
720
|
for (const filepath of targets) {
|
|
@@ -547,18 +757,37 @@ program
|
|
|
547
757
|
|
|
548
758
|
if (effective.diff) {
|
|
549
759
|
let hasChanges = false;
|
|
760
|
+
let compared = 0;
|
|
761
|
+
let missing = 0;
|
|
550
762
|
for (const filepath of targets) {
|
|
551
763
|
const snapshotPath = snapshotPathForFile(filepath);
|
|
552
764
|
if (!existsSync(snapshotPath)) {
|
|
553
765
|
renderWarn(`No snapshot found for "${filepath}"`);
|
|
766
|
+
missing += 1;
|
|
554
767
|
continue;
|
|
555
768
|
}
|
|
556
769
|
const before = readSnapshotStateFromFile(snapshotPath);
|
|
557
770
|
const after = parseFile(filepath);
|
|
558
771
|
const events = diffTrees(before, after, dOpts);
|
|
559
772
|
renderDiff(filepath, events, { maskSecrets });
|
|
773
|
+
compared += 1;
|
|
560
774
|
if (events.length > 0) hasChanges = true;
|
|
561
775
|
}
|
|
776
|
+
// Exiting 0 having compared nothing is the worst answer available: the
|
|
777
|
+
// caller reads it as "no drift" when the truth is "no baseline to drift
|
|
778
|
+
// from" (#141). Snapshot history lives in the working directory, so this
|
|
779
|
+
// is the normal state of a fresh CI runner.
|
|
780
|
+
if (compared === 0) {
|
|
781
|
+
throw new Error(
|
|
782
|
+
'No snapshot found for any target, so nothing was compared.'
|
|
783
|
+
+ ' Run "flecto watch <file> --snapshot" first — no history is not no drift.',
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
if (missing > 0) {
|
|
787
|
+
renderNote(
|
|
788
|
+
`${missing} of ${targets.length} targets had no snapshot and were not compared.`,
|
|
789
|
+
);
|
|
790
|
+
}
|
|
562
791
|
process.exit(hasChanges ? 1 : 0);
|
|
563
792
|
}
|
|
564
793
|
|
|
@@ -706,11 +935,28 @@ program
|
|
|
706
935
|
}
|
|
707
936
|
|
|
708
937
|
console.log(`Local snapshot history (${summaries.length} snapshots)`);
|
|
938
|
+
let baselines = 0;
|
|
709
939
|
for (const snapshot of summaries) {
|
|
710
940
|
const file = relative(process.cwd(), snapshot.file) || snapshot.file;
|
|
711
|
-
|
|
941
|
+
// A snapshot with nothing before it was never compared, so printing
|
|
942
|
+
// "0 changes" for it states a result that was never computed (#141).
|
|
943
|
+
if (!snapshot.previousCreatedAt) baselines += 1;
|
|
944
|
+
const changes = snapshot.previousCreatedAt
|
|
945
|
+
? `${snapshot.changeCount} change${snapshot.changeCount === 1 ? '' : 's'}`
|
|
946
|
+
: 'baseline (no earlier snapshot to compare against)';
|
|
712
947
|
console.log(`${snapshot.createdAt} ${file} — ${changes}`);
|
|
713
948
|
}
|
|
949
|
+
if (baselines === summaries.length) {
|
|
950
|
+
renderNote(
|
|
951
|
+
'Nothing was compared: every snapshot shown is the first of its file.'
|
|
952
|
+
+ ' That is no history, not no drift.',
|
|
953
|
+
);
|
|
954
|
+
} else if (baselines > 0) {
|
|
955
|
+
renderNote(
|
|
956
|
+
`${baselines} of ${summaries.length} snapshots shown are the first of their file`
|
|
957
|
+
+ ' and were not compared against anything.',
|
|
958
|
+
);
|
|
959
|
+
}
|
|
714
960
|
} catch (err) {
|
|
715
961
|
renderError(err.message);
|
|
716
962
|
process.exit(1);
|
|
@@ -814,9 +1060,12 @@ program
|
|
|
814
1060
|
.description('Run semantic diff in CI mode')
|
|
815
1061
|
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
816
1062
|
.option('--snapshot-ref <ref>', 'Snapshot reference: snapshot path or git ref')
|
|
817
|
-
.option('--format <type>', 'Output format: json | ndjson | github-annotations | pr-comment', 'json')
|
|
818
|
-
.option('--pr-comment-post', 'With --format pr-comment, upsert the comment on the PR (needs
|
|
1063
|
+
.option('--format <type>', 'Output format: json | ndjson | sarif | github-annotations | pr-comment', 'json')
|
|
1064
|
+
.option('--pr-comment-post', 'With --format pr-comment, upsert the comment on the PR (needs a token + merge request context)', false)
|
|
1065
|
+
.option('--pr-provider <name>', `Force the comment delivery target: ${PR_PROVIDER_IDS.join(' | ')} (default: detect from CI)`)
|
|
819
1066
|
.option('--fail-on <rules>', 'Comma-separated fail rules: changed,added,removed,policy,error,warn', 'changed,policy,error')
|
|
1067
|
+
.option('--baseline <file>', 'Gate only on findings not already recorded in this baseline file')
|
|
1068
|
+
.option('--update-baseline', 'Rewrite the --baseline file from the current findings (explicit, never automatic)', false)
|
|
820
1069
|
.option('--ignore <keys>', 'Comma-separated key paths to ignore')
|
|
821
1070
|
.option('--policies <ids>', 'Comma-separated policy pack ids')
|
|
822
1071
|
.option('--plugins <paths>', 'Comma-separated local ESM plugin paths')
|
|
@@ -824,6 +1073,8 @@ program
|
|
|
824
1073
|
.option('--no-array-id', 'Diff arrays by index instead of object identity')
|
|
825
1074
|
.option('--array-ignore-order', 'Treat array order as insignificant', false)
|
|
826
1075
|
.option('--mask-secrets', 'Mask secret-like values in CI output', false)
|
|
1076
|
+
.option('--show-suppressed', 'List inline-suppressed findings instead of only counting them', false)
|
|
1077
|
+
.option('--changed-only', 'With --format json|ndjson, replace envelopes for unchanged files with one scanned manifest', false)
|
|
827
1078
|
.option('--allow-empty', 'Allow CI to succeed when no files were diffed', false)
|
|
828
1079
|
.action(async (files, opts, command) => {
|
|
829
1080
|
try {
|
|
@@ -840,19 +1091,38 @@ program
|
|
|
840
1091
|
const ignorePaths = parseCsv(effective.ignore);
|
|
841
1092
|
const failOn = parseFailOn(effective.failOn ?? 'changed,policy,error');
|
|
842
1093
|
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');
|
|
1094
|
+
if (!['json', 'ndjson', 'sarif', 'github-annotations', 'pr-comment'].includes(format)) {
|
|
1095
|
+
throw new Error('--format must be json, ndjson, sarif, github-annotations, or pr-comment');
|
|
845
1096
|
}
|
|
846
1097
|
const prCommentPost = Boolean(effective.prCommentPost);
|
|
1098
|
+
if (effective.prProvider && !PR_PROVIDER_IDS.includes(String(effective.prProvider))) {
|
|
1099
|
+
throw new Error(`--pr-provider must be one of: ${PR_PROVIDER_IDS.join(', ')}`);
|
|
1100
|
+
}
|
|
847
1101
|
if (prCommentPost && format !== 'pr-comment') {
|
|
848
1102
|
renderWarn('Ignoring --pr-comment-post: it only applies to --format pr-comment.');
|
|
849
1103
|
}
|
|
850
1104
|
const maskSecrets = Boolean(effective.maskSecrets);
|
|
1105
|
+
const showSuppressed = Boolean(effective.showSuppressed);
|
|
1106
|
+
const changedOnly = Boolean(effective.changedOnly);
|
|
1107
|
+
// github-annotations and pr-comment already render only what changed, so
|
|
1108
|
+
// there is nothing for the flag to collapse there. Say so rather than
|
|
1109
|
+
// accepting it and quietly doing nothing.
|
|
1110
|
+
if (changedOnly && format !== 'json' && format !== 'ndjson') {
|
|
1111
|
+
renderWarn(`Ignoring --changed-only: it only applies to --format json or ndjson (got ${format}).`);
|
|
1112
|
+
}
|
|
851
1113
|
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
852
1114
|
|
|
853
|
-
|
|
854
|
-
const
|
|
855
|
-
|
|
1115
|
+
const cwd = process.cwd();
|
|
1116
|
+
const baselinePath = effective.baseline ? resolve(cwd, String(effective.baseline)) : null;
|
|
1117
|
+
const updateBaseline = Boolean(effective.updateBaseline);
|
|
1118
|
+
if (updateBaseline && !baselinePath) {
|
|
1119
|
+
throw new Error('--update-baseline requires --baseline <file> naming the file to write.');
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/** @type {Array<{ filepath: string, relFile: string, outboundChanges: any[], outboundFindings: any[], changesFail: boolean }>} */
|
|
1123
|
+
const perFile = [];
|
|
1124
|
+
/** @type {Array<{ file: string, finding: any, reason: string }>} */
|
|
1125
|
+
const allSuppressed = [];
|
|
856
1126
|
let diffed = 0;
|
|
857
1127
|
|
|
858
1128
|
for (const filepath of targets) {
|
|
@@ -875,8 +1145,8 @@ program
|
|
|
875
1145
|
);
|
|
876
1146
|
}
|
|
877
1147
|
const events = diffTrees(before, after, dOpts);
|
|
878
|
-
const
|
|
879
|
-
cwd
|
|
1148
|
+
const rawFindings = await evaluatePolicies(events, {
|
|
1149
|
+
cwd,
|
|
880
1150
|
file: filepath,
|
|
881
1151
|
profile: profile ?? null,
|
|
882
1152
|
source: 'ci',
|
|
@@ -884,20 +1154,24 @@ program
|
|
|
884
1154
|
plugins,
|
|
885
1155
|
severityRemap,
|
|
886
1156
|
});
|
|
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
1157
|
|
|
898
|
-
|
|
899
|
-
|
|
1158
|
+
// Inline suppressions run first: a deliberately-accepted finding is
|
|
1159
|
+
// removed before the baseline, the gate, and the output ever see it, so
|
|
1160
|
+
// it is never also counted by a baseline. A directive missing its
|
|
1161
|
+
// mandatory reason is refused loudly rather than applied.
|
|
1162
|
+
const { active: policyFindings, suppressed } = resolveSuppressed(filepath, rawFindings);
|
|
1163
|
+
for (const item of suppressed) {
|
|
1164
|
+
allSuppressed.push({ file: filepath, finding: item.finding, reason: item.reason });
|
|
900
1165
|
}
|
|
1166
|
+
|
|
1167
|
+
perFile.push({
|
|
1168
|
+
filepath,
|
|
1169
|
+
relFile: baselineRelativePath(filepath, cwd),
|
|
1170
|
+
outboundChanges: maybeMaskChanges(events, maskSecrets),
|
|
1171
|
+
outboundFindings: maybeMaskFindings(policyFindings, events, maskSecrets),
|
|
1172
|
+
changesFail: shouldFailFromChanges(events, failOn),
|
|
1173
|
+
});
|
|
1174
|
+
diffed += 1;
|
|
901
1175
|
}
|
|
902
1176
|
|
|
903
1177
|
if (diffed === 0 && !effective.allowEmpty) {
|
|
@@ -907,12 +1181,100 @@ program
|
|
|
907
1181
|
);
|
|
908
1182
|
}
|
|
909
1183
|
|
|
1184
|
+
// Suppressed findings are still surfaced — a count by default, the full
|
|
1185
|
+
// list with --show-suppressed — so a gate you cannot see the shape of does
|
|
1186
|
+
// not quietly grow. All of this goes to stderr, leaving machine output clean.
|
|
1187
|
+
if (allSuppressed.length > 0) {
|
|
1188
|
+
renderNote(
|
|
1189
|
+
`${allSuppressed.length} finding${allSuppressed.length === 1 ? '' : 's'} suppressed inline`
|
|
1190
|
+
+ `${showSuppressed ? ':' : ' (use --show-suppressed to list them).'}`,
|
|
1191
|
+
);
|
|
1192
|
+
if (showSuppressed) {
|
|
1193
|
+
for (const { file, finding, reason } of allSuppressed) {
|
|
1194
|
+
const rel = relative(cwd, file) || file;
|
|
1195
|
+
renderNote(` ${rel} ${finding.path}: ${finding.id} — ${reason}`);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// Every finding this run produced, paired with its repo-relative file, so
|
|
1201
|
+
// the baseline can be matched, updated, and stale-checked on stable keys.
|
|
1202
|
+
const located = perFile.flatMap((f) =>
|
|
1203
|
+
f.outboundFindings.map((finding) => ({ file: f.relFile, finding })));
|
|
1204
|
+
|
|
1205
|
+
// Without a baseline, every finding is active — behavior is unchanged.
|
|
1206
|
+
let activeByFile = new Map(perFile.map((f) => [f.relFile, f.outboundFindings]));
|
|
1207
|
+
let baselineSummary = null;
|
|
1208
|
+
if (baselinePath) {
|
|
1209
|
+
const { entries: recorded } = loadBaseline(baselinePath);
|
|
1210
|
+
|
|
1211
|
+
if (updateBaseline) {
|
|
1212
|
+
// Recording the current state accepts all of it: the file is rewritten
|
|
1213
|
+
// from every finding, and nothing is "new" relative to what was just
|
|
1214
|
+
// written, so the gate passes on the policy axis.
|
|
1215
|
+
writeBaselineFile(baselinePath, buildBaselineFile(located, recorded));
|
|
1216
|
+
renderNote(
|
|
1217
|
+
`Baseline updated: ${baselineRelativePath(baselinePath, cwd)} `
|
|
1218
|
+
+ `(${located.length} finding${located.length === 1 ? '' : 's'} recorded)`,
|
|
1219
|
+
);
|
|
1220
|
+
activeByFile = new Map();
|
|
1221
|
+
baselineSummary = { active: 0, accepted: located.length, stale: [] };
|
|
1222
|
+
} else {
|
|
1223
|
+
const { active, accepted, stale } = applyBaseline(located, recorded);
|
|
1224
|
+
const activeMap = new Map();
|
|
1225
|
+
for (const { file, finding } of active) {
|
|
1226
|
+
if (!activeMap.has(file)) activeMap.set(file, []);
|
|
1227
|
+
activeMap.get(file).push(finding);
|
|
1228
|
+
}
|
|
1229
|
+
activeByFile = activeMap;
|
|
1230
|
+
baselineSummary = { active: active.length, accepted: accepted.length, stale };
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// Results reflect the *active* findings: with a baseline in effect, an
|
|
1235
|
+
// accepted finding is suppressed from output as well as from the gate, so a
|
|
1236
|
+
// green run is not buried under hundreds of already-accepted findings.
|
|
1237
|
+
const results = perFile.map((f) => {
|
|
1238
|
+
const active = activeByFile.get(f.relFile) ?? [];
|
|
1239
|
+
return {
|
|
1240
|
+
file: f.filepath,
|
|
1241
|
+
envelope: createEnvelope({
|
|
1242
|
+
source: 'ci',
|
|
1243
|
+
file: f.filepath,
|
|
1244
|
+
changes: f.outboundChanges,
|
|
1245
|
+
policies: active,
|
|
1246
|
+
}),
|
|
1247
|
+
policies: active,
|
|
1248
|
+
};
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
// With --update-baseline everything is now accepted, so there are no active
|
|
1252
|
+
// findings to gate on; the policy gate simply passes. Change-based triggers
|
|
1253
|
+
// are about the diff, not the findings, so they still apply.
|
|
1254
|
+
const activeFindings = results.flatMap((r) => r.policies);
|
|
1255
|
+
const shouldFail = perFile.some((f) => f.changesFail)
|
|
1256
|
+
|| shouldFailFromPolicy(activeFindings, failOn);
|
|
1257
|
+
|
|
1258
|
+
if (baselineSummary) {
|
|
1259
|
+
const parts = [`${baselineSummary.active} new`, `${baselineSummary.accepted} baselined`];
|
|
1260
|
+
if (baselineSummary.stale.length > 0) parts.push(`${baselineSummary.stale.length} stale`);
|
|
1261
|
+
renderNote(`Baseline: ${parts.join(', ')}.`);
|
|
1262
|
+
if (baselineSummary.stale.length > 0 && !updateBaseline) {
|
|
1263
|
+
renderWarn(
|
|
1264
|
+
`${baselineSummary.stale.length} baseline `
|
|
1265
|
+
+ `${baselineSummary.stale.length === 1 ? 'entry no longer occurs' : 'entries no longer occur'}; `
|
|
1266
|
+
+ 're-run with --update-baseline to prune.',
|
|
1267
|
+
);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
910
1271
|
if (format === 'pr-comment') {
|
|
911
|
-
const body = renderPrComment(results, { cwd
|
|
912
|
-
|
|
913
|
-
await deliverPrCommentSafely(body, prCommentPost);
|
|
1272
|
+
const body = renderPrComment(results, { cwd, failed: shouldFail });
|
|
1273
|
+
await writeStdout(body);
|
|
1274
|
+
await deliverPrCommentSafely(body, prCommentPost, effective.prProvider);
|
|
914
1275
|
} else {
|
|
915
|
-
|
|
1276
|
+
const collapsible = changedOnly && (format === 'json' || format === 'ndjson');
|
|
1277
|
+
await printCiOutput(collapsible ? collapseUnchangedResults(results) : results, format);
|
|
916
1278
|
}
|
|
917
1279
|
process.exit(shouldFail ? 1 : 0);
|
|
918
1280
|
} catch (err) {
|
|
@@ -926,7 +1288,8 @@ program
|
|
|
926
1288
|
.description('Diff Terraform plan JSON (terraform show -json) and run policies on it')
|
|
927
1289
|
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
928
1290
|
.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
|
|
1291
|
+
.option('--pr-comment-post', 'With --format pr-comment, upsert the comment on the PR (needs a token + merge request context)', false)
|
|
1292
|
+
.option('--pr-provider <name>', `Force the comment delivery target: ${PR_PROVIDER_IDS.join(' | ')} (default: detect from CI)`)
|
|
930
1293
|
.option('--fail-on <rules>', 'Comma-separated fail rules: changed,added,removed,policy,error,warn', PLAN_DEFAULT_FAIL_ON)
|
|
931
1294
|
.option('--ignore <keys>', 'Comma-separated key paths to ignore, e.g. "**.tags_all,**.#action"')
|
|
932
1295
|
.option('--policies <ids>', `Comma-separated policy pack ids (default: ${PLAN_DEFAULT_POLICIES})`)
|
|
@@ -955,6 +1318,9 @@ program
|
|
|
955
1318
|
throw new Error('--format must be human, json, ndjson, github-annotations, or pr-comment');
|
|
956
1319
|
}
|
|
957
1320
|
const prCommentPost = Boolean(effective.prCommentPost);
|
|
1321
|
+
if (effective.prProvider && !PR_PROVIDER_IDS.includes(String(effective.prProvider))) {
|
|
1322
|
+
throw new Error(`--pr-provider must be one of: ${PR_PROVIDER_IDS.join(', ')}`);
|
|
1323
|
+
}
|
|
958
1324
|
if (prCommentPost && format !== 'pr-comment') {
|
|
959
1325
|
renderWarn('Ignoring --pr-comment-post: it only applies to --format pr-comment.');
|
|
960
1326
|
}
|
|
@@ -1013,10 +1379,10 @@ program
|
|
|
1013
1379
|
|
|
1014
1380
|
if (format === 'pr-comment') {
|
|
1015
1381
|
const body = renderPrComment(results, { cwd: process.cwd(), failed: shouldFail });
|
|
1016
|
-
|
|
1017
|
-
await deliverPrCommentSafely(body, prCommentPost);
|
|
1382
|
+
await writeStdout(body);
|
|
1383
|
+
await deliverPrCommentSafely(body, prCommentPost, effective.prProvider);
|
|
1018
1384
|
} else if (format !== 'human') {
|
|
1019
|
-
printCiOutput(results, format);
|
|
1385
|
+
await printCiOutput(results, format);
|
|
1020
1386
|
}
|
|
1021
1387
|
process.exit(shouldFail ? 1 : 0);
|
|
1022
1388
|
} catch (err) {
|
|
@@ -1099,7 +1465,7 @@ program
|
|
|
1099
1465
|
// Same envelope and printer as `ci`, so machine consumers see one shape.
|
|
1100
1466
|
// `baseline` rides on the result wrapper rather than the envelope, which
|
|
1101
1467
|
// is closed by schemas/flecto-envelope-2.0.json.
|
|
1102
|
-
printCiOutput(
|
|
1468
|
+
await printCiOutput(
|
|
1103
1469
|
[{ file: targetPath, baseline: baselinePath, envelope, policies: outboundFindings }],
|
|
1104
1470
|
format,
|
|
1105
1471
|
);
|