flecto 2.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +431 -1
- package/README.md +305 -309
- package/index.js +574 -56
- package/package.json +3 -2
- package/schemas/flecto-policy-pack-2.0.json +5 -0
- package/src/alerter.js +20 -3
- package/src/config.js +113 -8
- package/src/differ.js +59 -2
- package/src/documents.js +106 -0
- package/src/encrypted.js +573 -0
- package/src/notifiers.js +430 -0
- package/src/packs/default.json +22 -0
- package/src/packs/kubernetes.json +112 -0
- package/src/packs/sops.json +61 -0
- package/src/packs/strict-prod.json +10 -0
- package/src/packs/terraform.json +120 -0
- package/src/parser.js +189 -20
- package/src/policy.js +498 -11
- package/src/pr-comment.js +480 -0
- package/src/renderer.js +70 -16
- package/src/report.js +653 -0
- package/src/secrets.js +316 -0
- package/src/terraform.js +500 -0
- package/src/watcher.js +9 -7
package/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { program } from 'commander';
|
|
4
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from 'fs';
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, realpathSync } from 'fs';
|
|
5
5
|
import { resolve, relative, dirname, join } from 'path';
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
7
|
import { createHash } from 'crypto';
|
|
@@ -9,20 +9,37 @@ import { execFileSync } from 'child_process';
|
|
|
9
9
|
import chalk from 'chalk';
|
|
10
10
|
|
|
11
11
|
import { parseFile, isSupported, parseContent } from './src/parser.js';
|
|
12
|
-
import { diffTrees } from './src/differ.js';
|
|
12
|
+
import { diffTrees, secretMatchPath } from './src/differ.js';
|
|
13
|
+
import { documentKeysOf, withDocumentKeys } from './src/documents.js';
|
|
13
14
|
import { startWatcher } from './src/watcher.js';
|
|
14
15
|
import {
|
|
15
16
|
renderChanges,
|
|
16
17
|
renderDiff,
|
|
17
18
|
renderError,
|
|
18
19
|
renderInfo,
|
|
20
|
+
renderNote,
|
|
19
21
|
renderWarn,
|
|
20
22
|
renderPolicyFindings,
|
|
21
23
|
maskChangeEvent,
|
|
24
|
+
maskSensitiveValue,
|
|
22
25
|
} from './src/renderer.js';
|
|
26
|
+
import { deliverPrComment, renderPrComment } from './src/pr-comment.js';
|
|
27
|
+
import {
|
|
28
|
+
diffTerraformPlan,
|
|
29
|
+
formatPlanSummary,
|
|
30
|
+
readTerraformPlanFile,
|
|
31
|
+
} from './src/terraform.js';
|
|
32
|
+
import { renderReportHtml } from './src/report.js';
|
|
33
|
+
import { redactSecretString } from './src/secrets.js';
|
|
23
34
|
import { fireAlerts } from './src/alerter.js';
|
|
35
|
+
import { resolveWebhookFormat, WEBHOOK_FORMAT_CHOICES } from './src/notifiers.js';
|
|
24
36
|
import { createEnvelope } from './src/envelope.js';
|
|
25
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
evaluatePolicies,
|
|
39
|
+
highestSeverity,
|
|
40
|
+
listPolicyPacks,
|
|
41
|
+
addPolicyPackFromPackage,
|
|
42
|
+
} from './src/policy.js';
|
|
26
43
|
import { testPolicyFixture } from './src/policy-test.js';
|
|
27
44
|
import {
|
|
28
45
|
loadRcConfig,
|
|
@@ -38,6 +55,17 @@ const PKG = JSON.parse(
|
|
|
38
55
|
);
|
|
39
56
|
|
|
40
57
|
const SNAPSHOT_DIR = '.flecto-snapshots';
|
|
58
|
+
const FAIL_ON_CHOICES = ['changed', 'added', 'removed', 'policy', 'error', 'warn'];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `flecto plan` defaults. A plan is expected to contain changes — that is the
|
|
62
|
+
* point of running one — so gating on `changed` the way `ci` does would fail
|
|
63
|
+
* every non-empty plan. The `terraform` pack reserves `error` for the patterns
|
|
64
|
+
* that genuinely should block a merge and leaves cost and sizing advice at
|
|
65
|
+
* `warn`, so `error` is the default gate; `--fail-on policy` or `warn` widens it.
|
|
66
|
+
*/
|
|
67
|
+
const PLAN_DEFAULT_FAIL_ON = 'error';
|
|
68
|
+
const PLAN_DEFAULT_POLICIES = 'terraform';
|
|
41
69
|
|
|
42
70
|
function snapshotIdForPath(absPath) {
|
|
43
71
|
const normalized = absPath.replaceAll('\\', '/');
|
|
@@ -60,14 +88,27 @@ function snapshotHistoryPathForFile(absPath) {
|
|
|
60
88
|
return path;
|
|
61
89
|
}
|
|
62
90
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Snapshot ids that already have at least one timestamped history entry.
|
|
93
|
+
*
|
|
94
|
+
* Listed once per run and threaded through the snapshot loop: probing the
|
|
95
|
+
* directory per file made writing N baselines cost N listings of O(N) entries
|
|
96
|
+
* each, which is quadratic in the number of tracked files.
|
|
97
|
+
* @returns {Set<string>}
|
|
98
|
+
*/
|
|
99
|
+
function snapshotIdsWithHistory() {
|
|
100
|
+
/** @type {Set<string>} */
|
|
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;
|
|
67
108
|
}
|
|
68
109
|
|
|
69
|
-
function preserveLegacySnapshotForHistory(absPath, snapshotPath) {
|
|
70
|
-
if (!existsSync(snapshotPath) ||
|
|
110
|
+
function preserveLegacySnapshotForHistory(absPath, snapshotPath, idsWithHistory) {
|
|
111
|
+
if (!existsSync(snapshotPath) || idsWithHistory.has(snapshotIdForPath(absPath))) return;
|
|
71
112
|
|
|
72
113
|
const legacy = JSON.parse(readFileSync(snapshotPath, 'utf8'));
|
|
73
114
|
writeFileSync(
|
|
@@ -75,6 +116,7 @@ function preserveLegacySnapshotForHistory(absPath, snapshotPath) {
|
|
|
75
116
|
JSON.stringify({
|
|
76
117
|
file: legacy.file ?? absPath,
|
|
77
118
|
state: legacy.state ?? legacy,
|
|
119
|
+
...(Array.isArray(legacy.documents) ? { documents: legacy.documents } : {}),
|
|
78
120
|
createdAt: legacy.createdAt ?? statSync(snapshotPath).mtime.toISOString(),
|
|
79
121
|
}, null, 2),
|
|
80
122
|
'utf8',
|
|
@@ -95,7 +137,7 @@ function readLocalSnapshotHistory() {
|
|
|
95
137
|
return snapshotEntries.map((entry) => {
|
|
96
138
|
const path = resolve(SNAPSHOT_DIR, entry.name);
|
|
97
139
|
const snapshot = JSON.parse(readFileSync(path, 'utf8'));
|
|
98
|
-
const state = snapshot?.state ?? snapshot;
|
|
140
|
+
const state = restoreSnapshotDocumentKeys(snapshot?.state ?? snapshot, snapshot);
|
|
99
141
|
if (typeof snapshot?.file !== 'string') {
|
|
100
142
|
throw new Error(`Invalid snapshot file: ${path}`);
|
|
101
143
|
}
|
|
@@ -119,11 +161,18 @@ function summarizeSnapshotHistory(snapshots, limit, diffOpts = {}) {
|
|
|
119
161
|
for (const records of byFile.values()) {
|
|
120
162
|
records.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
|
|
121
163
|
for (let index = 0; index < records.length; index += 1) {
|
|
164
|
+
// The previous snapshot of the *same file* is the baseline, and it may
|
|
165
|
+
// fall outside the limit window — so carry it here rather than letting
|
|
166
|
+
// callers infer it from the truncated result.
|
|
167
|
+
const previous = index === 0 ? null : records[index - 1];
|
|
168
|
+
const changes = previous
|
|
169
|
+
? diffTrees(previous.state, records[index].state, diffOpts)
|
|
170
|
+
: [];
|
|
122
171
|
summaries.push({
|
|
123
172
|
...records[index],
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
173
|
+
previousCreatedAt: previous ? previous.createdAt : null,
|
|
174
|
+
changes,
|
|
175
|
+
changeCount: changes.length,
|
|
127
176
|
});
|
|
128
177
|
}
|
|
129
178
|
}
|
|
@@ -139,6 +188,18 @@ function parseCsv(value) {
|
|
|
139
188
|
return String(value).split(',').map((s) => s.trim()).filter(Boolean);
|
|
140
189
|
}
|
|
141
190
|
|
|
191
|
+
function parseFailOn(value) {
|
|
192
|
+
const rules = parseCsv(value);
|
|
193
|
+
const invalid = rules.filter((rule) => !FAIL_ON_CHOICES.includes(rule));
|
|
194
|
+
if (invalid.length > 0) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`--fail-on contains unknown trigger${invalid.length === 1 ? '' : 's'}: ${invalid.join(', ')}. `
|
|
197
|
+
+ `Valid triggers: ${FAIL_ON_CHOICES.join(', ')}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
return new Set(rules);
|
|
201
|
+
}
|
|
202
|
+
|
|
142
203
|
function parseHeaders(headerList) {
|
|
143
204
|
const webhookHeaders = {};
|
|
144
205
|
if (!Array.isArray(headerList)) return webhookHeaders;
|
|
@@ -188,6 +249,32 @@ function maybeMaskChanges(events, maskSecrets) {
|
|
|
188
249
|
return events.map(maskChangeEvent);
|
|
189
250
|
}
|
|
190
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Redact secret-shaped text from policy messages. A rule using
|
|
254
|
+
* `messageTemplate` can interpolate `{before}` / `{after}`, so a finding can
|
|
255
|
+
* carry a credential even when the change events beside it are masked. Replace
|
|
256
|
+
* exact interpolated values using the same path-aware masking as change events,
|
|
257
|
+
* then catch any other recognizable secret fragments in free-form messages.
|
|
258
|
+
* @param {import('./src/policy.js').PolicyFinding[]} findings
|
|
259
|
+
* @param {import('./src/differ.js').ChangeEvent[]} changes
|
|
260
|
+
* @param {boolean} maskSecrets
|
|
261
|
+
* @returns {import('./src/policy.js').PolicyFinding[]}
|
|
262
|
+
*/
|
|
263
|
+
function maybeMaskFindings(findings, changes, maskSecrets) {
|
|
264
|
+
if (!maskSecrets) return findings;
|
|
265
|
+
return findings.map((finding) => {
|
|
266
|
+
let message = String(finding.message ?? '');
|
|
267
|
+
for (const change of changes.filter((event) => event.path === finding.path)) {
|
|
268
|
+
for (const value of [change.before, change.after]) {
|
|
269
|
+
const original = String(value);
|
|
270
|
+
const masked = String(maskSensitiveValue(value, secretMatchPath(change)));
|
|
271
|
+
if (original && original !== masked) message = message.replaceAll(original, masked);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return { ...finding, message: redactSecretString(message) };
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
191
278
|
async function resolveTargetFiles(cliFiles, rcConfig) {
|
|
192
279
|
if (cliFiles && cliFiles.length > 0) {
|
|
193
280
|
const direct = [];
|
|
@@ -212,14 +299,62 @@ async function resolveTargetFiles(cliFiles, rcConfig) {
|
|
|
212
299
|
|
|
213
300
|
return resolveFiles({
|
|
214
301
|
cwd: process.cwd(),
|
|
215
|
-
files: rcConfig?.files ??
|
|
302
|
+
files: rcConfig?.files ?? [],
|
|
303
|
+
include: rcConfig?.include ?? [],
|
|
216
304
|
exclude: rcConfig?.exclude ?? [],
|
|
217
305
|
});
|
|
218
306
|
}
|
|
219
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Restore the parser's multi-document signal onto a state read back from a
|
|
310
|
+
* snapshot. A snapshot is plain JSON, so the in-memory marking is gone; a
|
|
311
|
+
* snapshot written before this field existed simply leaves the provenance
|
|
312
|
+
* unknown, which is what it is.
|
|
313
|
+
* @param {unknown} state
|
|
314
|
+
* @param {unknown} snapshot the parsed snapshot envelope
|
|
315
|
+
* @returns {unknown} the same state
|
|
316
|
+
*/
|
|
317
|
+
function restoreSnapshotDocumentKeys(state, snapshot) {
|
|
318
|
+
const documents = /** @type {{ documents?: unknown }} */ (snapshot)?.documents;
|
|
319
|
+
if (!Array.isArray(documents)) return state;
|
|
320
|
+
return withDocumentKeys(state, documents.map(String));
|
|
321
|
+
}
|
|
322
|
+
|
|
220
323
|
function readSnapshotStateFromFile(snapshotPath) {
|
|
221
324
|
const snap = JSON.parse(readFileSync(snapshotPath, 'utf8'));
|
|
222
|
-
return snap?.state ?? snap;
|
|
325
|
+
return restoreSnapshotDocumentKeys(snap?.state ?? snap, snap);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Resolve a file to a path relative to its git repository root.
|
|
330
|
+
*
|
|
331
|
+
* `git show <rev>:<path>` interprets <path> from the repository root, so a
|
|
332
|
+
* cwd-relative path breaks whenever Flecto runs from a subdirectory. Both sides
|
|
333
|
+
* are canonicalized first: process.cwd() reports a symlink-resolved path while
|
|
334
|
+
* CLI arguments keep the symlinks the user typed, and on macOS /tmp and
|
|
335
|
+
* /var/folders are symlinks, so comparing the two forms directly misresolves.
|
|
336
|
+
* @param {string} filePath
|
|
337
|
+
* @returns {string} POSIX-style path relative to the repository root
|
|
338
|
+
*/
|
|
339
|
+
function gitRepoRelativePath(filePath) {
|
|
340
|
+
const top = execFileSync('git', ['-C', dirname(filePath), 'rev-parse', '--show-toplevel'], {
|
|
341
|
+
encoding: 'utf8',
|
|
342
|
+
}).trim();
|
|
343
|
+
return relative(canonicalPath(top), canonicalPath(filePath)).replaceAll('\\', '/');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Resolve symlinks where possible, falling back to the input when the path does
|
|
348
|
+
* not exist on disk.
|
|
349
|
+
* @param {string} path
|
|
350
|
+
* @returns {string}
|
|
351
|
+
*/
|
|
352
|
+
function canonicalPath(path) {
|
|
353
|
+
try {
|
|
354
|
+
return realpathSync(path);
|
|
355
|
+
} catch {
|
|
356
|
+
return resolve(path);
|
|
357
|
+
}
|
|
223
358
|
}
|
|
224
359
|
|
|
225
360
|
function readSnapshotStateFromRef(filePath, snapshotRef) {
|
|
@@ -229,7 +364,7 @@ function readSnapshotStateFromRef(filePath, snapshotRef) {
|
|
|
229
364
|
return readSnapshotStateFromFile(maybePath);
|
|
230
365
|
}
|
|
231
366
|
|
|
232
|
-
const rel =
|
|
367
|
+
const rel = gitRepoRelativePath(filePath);
|
|
233
368
|
const raw = execFileSync('git', ['show', `${snapshotRef}:${rel}`], { encoding: 'utf8' });
|
|
234
369
|
return parseContent(filePath, raw);
|
|
235
370
|
}
|
|
@@ -291,6 +426,26 @@ function printCiOutput(results, format) {
|
|
|
291
426
|
}
|
|
292
427
|
}
|
|
293
428
|
|
|
429
|
+
/**
|
|
430
|
+
* Deliver the sticky PR comment without ever changing the CI outcome: a
|
|
431
|
+
* delivery problem warns, and the exit code stays with the diff/policy result.
|
|
432
|
+
* @param {string} body
|
|
433
|
+
* @param {boolean} enabled
|
|
434
|
+
*/
|
|
435
|
+
async function deliverPrCommentSafely(body, enabled) {
|
|
436
|
+
if (!enabled) return;
|
|
437
|
+
try {
|
|
438
|
+
const result = await deliverPrComment(body, { enabled: true });
|
|
439
|
+
if (result.posted) {
|
|
440
|
+
renderNote(`PR comment ${result.action}${result.url ? `: ${result.url}` : ''}`);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
renderWarn(`Could not post the PR comment: ${result.reason}`);
|
|
444
|
+
} catch (err) {
|
|
445
|
+
renderWarn(`Could not post the PR comment: ${err.message}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
294
449
|
program
|
|
295
450
|
.name('flecto')
|
|
296
451
|
.description('Flecto — semantic config watcher for meaningful structured file changes')
|
|
@@ -309,6 +464,7 @@ program
|
|
|
309
464
|
acc.push(v);
|
|
310
465
|
return acc;
|
|
311
466
|
}, [])
|
|
467
|
+
.option('--webhook-format <service>', `Webhook payload format: ${WEBHOOK_FORMAT_CHOICES.join(' | ')}`)
|
|
312
468
|
.option('--delivery-mode <mode>', 'Alert delivery mode: best-effort | at-least-once', 'best-effort')
|
|
313
469
|
.option('--on-alert-failure <mode>', 'Alert failure behavior: warn | exit | retry', 'warn')
|
|
314
470
|
.option('--webhook-timeout <ms>', 'Webhook timeout in ms', '5000')
|
|
@@ -343,10 +499,12 @@ program
|
|
|
343
499
|
validateMode(mode);
|
|
344
500
|
const maskSecrets = Boolean(effective.maskSecrets);
|
|
345
501
|
const maskSecretsWebhooks = Boolean(effective.maskSecretsWebhooks);
|
|
502
|
+
const webhookFormat = resolveWebhookFormat(effective.webhookFormat, effective.webhook);
|
|
346
503
|
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
347
504
|
|
|
348
505
|
if (effective.snapshot) {
|
|
349
506
|
mkdirSync(SNAPSHOT_DIR, { recursive: true });
|
|
507
|
+
const idsWithHistory = snapshotIdsWithHistory();
|
|
350
508
|
let written = 0;
|
|
351
509
|
for (const filepath of targets) {
|
|
352
510
|
if (!existsSync(filepath)) {
|
|
@@ -359,10 +517,21 @@ program
|
|
|
359
517
|
}
|
|
360
518
|
const state = parseFile(filepath);
|
|
361
519
|
const snapshotPath = snapshotPathForFile(filepath);
|
|
362
|
-
preserveLegacySnapshotForHistory(filepath, snapshotPath);
|
|
363
|
-
|
|
520
|
+
preserveLegacySnapshotForHistory(filepath, snapshotPath, idsWithHistory);
|
|
521
|
+
// Only a multi-document file records `documents`, so an ordinary
|
|
522
|
+
// snapshot is byte-for-byte what it was before this field existed.
|
|
523
|
+
const documents = documentKeysOf(state) ?? [];
|
|
524
|
+
const snapshot = {
|
|
525
|
+
file: filepath,
|
|
526
|
+
state,
|
|
527
|
+
...(documents.length > 0 ? { documents: [...documents] } : {}),
|
|
528
|
+
createdAt: new Date().toISOString(),
|
|
529
|
+
};
|
|
364
530
|
writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2), 'utf8');
|
|
365
531
|
writeFileSync(snapshotHistoryPathForFile(filepath), JSON.stringify(snapshot, null, 2), 'utf8');
|
|
532
|
+
// Keep the set in step with what this run has written, so a repeated
|
|
533
|
+
// target behaves exactly as it did when the check hit the disk.
|
|
534
|
+
idsWithHistory.add(snapshotIdForPath(filepath));
|
|
366
535
|
console.log(chalk.green(`✓ Snapshot saved: ${snapshotPath}`));
|
|
367
536
|
written += 1;
|
|
368
537
|
}
|
|
@@ -393,6 +562,33 @@ program
|
|
|
393
562
|
}
|
|
394
563
|
|
|
395
564
|
const watchers = [];
|
|
565
|
+
let closing = false;
|
|
566
|
+
const closeAll = async (exitCode) => {
|
|
567
|
+
if (closing) return;
|
|
568
|
+
closing = true;
|
|
569
|
+
await Promise.all(watchers.map((w) => w.close()));
|
|
570
|
+
if (exitCode === 0) {
|
|
571
|
+
console.log(chalk.dim('\nflecto stopped.'));
|
|
572
|
+
}
|
|
573
|
+
process.exit(exitCode);
|
|
574
|
+
};
|
|
575
|
+
const alertOptions = {
|
|
576
|
+
command: effective.command,
|
|
577
|
+
webhook: effective.webhook,
|
|
578
|
+
webhookHeaders,
|
|
579
|
+
webhookTimeoutMs: parseInt(String(effective.webhookTimeout ?? '5000'), 10),
|
|
580
|
+
webhookRetries: parseInt(String(effective.webhookRetries ?? '2'), 10),
|
|
581
|
+
webhookFormat,
|
|
582
|
+
deliveryMode: effective.deliveryMode,
|
|
583
|
+
onAlertFailure: effective.onAlertFailure,
|
|
584
|
+
};
|
|
585
|
+
const deliverAlert = async (envelope) => {
|
|
586
|
+
const result = await fireAlerts(alertOptions, envelope);
|
|
587
|
+
if (!result.ok && effective.onAlertFailure === 'exit') {
|
|
588
|
+
await closeAll(1);
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
|
|
396
592
|
for (const filepath of targets) {
|
|
397
593
|
if (!existsSync(filepath)) {
|
|
398
594
|
renderWarn(`Skipping missing file: ${filepath}`);
|
|
@@ -425,24 +621,20 @@ program
|
|
|
425
621
|
renderError(`policy evaluation failed: ${err.message}`);
|
|
426
622
|
process.exit(1);
|
|
427
623
|
}
|
|
428
|
-
renderPolicyFindings(policyFindings);
|
|
624
|
+
renderPolicyFindings(maybeMaskFindings(policyFindings, event.events, maskSecrets));
|
|
429
625
|
if (effective.command || effective.webhook) {
|
|
430
626
|
const outboundChanges = maybeMaskChanges(event.events, maskSecretsWebhooks);
|
|
431
627
|
const envelope = createEnvelope({
|
|
432
628
|
source: 'watch',
|
|
433
629
|
file: event.filepath,
|
|
434
630
|
changes: outboundChanges,
|
|
435
|
-
policies:
|
|
631
|
+
policies: maybeMaskFindings(
|
|
632
|
+
policyFindings,
|
|
633
|
+
event.events,
|
|
634
|
+
maskSecretsWebhooks,
|
|
635
|
+
),
|
|
436
636
|
});
|
|
437
|
-
await
|
|
438
|
-
command: effective.command,
|
|
439
|
-
webhook: effective.webhook,
|
|
440
|
-
webhookHeaders,
|
|
441
|
-
webhookTimeoutMs: parseInt(String(effective.webhookTimeout ?? '5000'), 10),
|
|
442
|
-
webhookRetries: parseInt(String(effective.webhookRetries ?? '2'), 10),
|
|
443
|
-
deliveryMode: effective.deliveryMode,
|
|
444
|
-
onAlertFailure: effective.onAlertFailure,
|
|
445
|
-
}, envelope);
|
|
637
|
+
await deliverAlert(envelope);
|
|
446
638
|
}
|
|
447
639
|
} else {
|
|
448
640
|
renderInfo(`[lifecycle] ${event.filepath}: ${event.lifecycle.type} - ${event.lifecycle.message}`);
|
|
@@ -452,15 +644,7 @@ program
|
|
|
452
644
|
file: event.filepath,
|
|
453
645
|
lifecycle: event.lifecycle,
|
|
454
646
|
});
|
|
455
|
-
await
|
|
456
|
-
command: effective.command,
|
|
457
|
-
webhook: effective.webhook,
|
|
458
|
-
webhookHeaders,
|
|
459
|
-
webhookTimeoutMs: parseInt(String(effective.webhookTimeout ?? '5000'), 10),
|
|
460
|
-
webhookRetries: parseInt(String(effective.webhookRetries ?? '2'), 10),
|
|
461
|
-
deliveryMode: effective.deliveryMode,
|
|
462
|
-
onAlertFailure: effective.onAlertFailure,
|
|
463
|
-
}, envelope);
|
|
647
|
+
await deliverAlert(envelope);
|
|
464
648
|
}
|
|
465
649
|
}
|
|
466
650
|
}
|
|
@@ -473,13 +657,6 @@ program
|
|
|
473
657
|
}
|
|
474
658
|
renderInfo('Press Ctrl+C to stop.\n');
|
|
475
659
|
|
|
476
|
-
const closeAll = async (exitCode) => {
|
|
477
|
-
await Promise.all(watchers.map((w) => w.close()));
|
|
478
|
-
if (exitCode === 0) {
|
|
479
|
-
console.log(chalk.dim('\nflecto stopped.'));
|
|
480
|
-
}
|
|
481
|
-
process.exit(exitCode);
|
|
482
|
-
};
|
|
483
660
|
process.on('SIGINT', () => void closeAll(0));
|
|
484
661
|
process.on('SIGTERM', () => void closeAll(0));
|
|
485
662
|
} catch (err) {
|
|
@@ -538,12 +715,104 @@ program
|
|
|
538
715
|
}
|
|
539
716
|
});
|
|
540
717
|
|
|
718
|
+
program
|
|
719
|
+
.command('report [files...]')
|
|
720
|
+
.description('Render local snapshot history as a self-contained HTML report')
|
|
721
|
+
.option('-o, --output <path>', 'Write the report to this path', 'flecto-report.html')
|
|
722
|
+
.option('-l, --limit <n>', 'Number of recent snapshots to include', '10')
|
|
723
|
+
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
724
|
+
.option('--ignore <keys>', 'Comma-separated key paths to ignore (e.g. "updated_at,meta.ts")')
|
|
725
|
+
.option('--policies <ids>', 'Comma-separated policy pack ids (default: default)')
|
|
726
|
+
.option('--plugins <paths>', 'Comma-separated local ESM plugin paths')
|
|
727
|
+
.option('--array-id-key <key>', 'Diff arrays by this object identity key')
|
|
728
|
+
.option('--no-array-id', 'Diff arrays by index instead of object identity')
|
|
729
|
+
.option('--array-ignore-order', 'Treat array order as insignificant', false)
|
|
730
|
+
.option('--mask-secrets', 'Mask secret-like values in the report', false)
|
|
731
|
+
.action(async (files, opts, command) => {
|
|
732
|
+
try {
|
|
733
|
+
const { config } = loadRcConfig(process.cwd());
|
|
734
|
+
const profile = resolveProfileName(opts.profile);
|
|
735
|
+
const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts, command));
|
|
736
|
+
const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(effective);
|
|
737
|
+
|
|
738
|
+
const limit = Number.parseInt(String(effective.limit ?? '10'), 10);
|
|
739
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
740
|
+
throw new Error('--limit must be a positive integer');
|
|
741
|
+
}
|
|
742
|
+
const ignorePaths = parseCsv(effective.ignore);
|
|
743
|
+
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
744
|
+
const maskSecrets = Boolean(effective.maskSecrets);
|
|
745
|
+
const outputPath = resolve(String(effective.output ?? 'flecto-report.html'));
|
|
746
|
+
|
|
747
|
+
// Same snapshot source, filtering, and errors as `flecto history` — this
|
|
748
|
+
// command only changes how that history is rendered.
|
|
749
|
+
const allSnapshots = readLocalSnapshotHistory();
|
|
750
|
+
let snapshots = allSnapshots;
|
|
751
|
+
if (files.length > 0) {
|
|
752
|
+
const targets = new Set((await resolveTargetFiles(files, config)).map((file) => resolve(file)));
|
|
753
|
+
snapshots = snapshots.filter((snapshot) => targets.has(resolve(snapshot.file)));
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const summaries = summarizeSnapshotHistory(snapshots, limit, dOpts);
|
|
757
|
+
if (summaries.length === 0) {
|
|
758
|
+
if (files.length > 0 && allSnapshots.length > 0) {
|
|
759
|
+
throw new Error(
|
|
760
|
+
'No local snapshots matched the given files. Omit files to report on all saved snapshot history.',
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
throw new Error('No local snapshots found. Run "flecto watch <file> --snapshot" first.');
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const reportSnapshots = [];
|
|
767
|
+
for (const summary of summaries) {
|
|
768
|
+
// Policies run on the unmasked events: masking first would hide the
|
|
769
|
+
// very values the secret rules match on. Redaction happens after, on
|
|
770
|
+
// everything that reaches the page.
|
|
771
|
+
const findings = await evaluatePolicies(summary.changes, {
|
|
772
|
+
cwd: process.cwd(),
|
|
773
|
+
file: summary.file,
|
|
774
|
+
profile: profile ?? null,
|
|
775
|
+
source: 'diff',
|
|
776
|
+
policies: packIds,
|
|
777
|
+
plugins,
|
|
778
|
+
severityRemap,
|
|
779
|
+
});
|
|
780
|
+
reportSnapshots.push({
|
|
781
|
+
file: summary.file,
|
|
782
|
+
createdAt: summary.createdAt,
|
|
783
|
+
previousCreatedAt: summary.previousCreatedAt,
|
|
784
|
+
changeCount: summary.changeCount,
|
|
785
|
+
changes: maybeMaskChanges(summary.changes, maskSecrets),
|
|
786
|
+
policies: maybeMaskFindings(findings, summary.changes, maskSecrets),
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const html = renderReportHtml({
|
|
791
|
+
snapshots: reportSnapshots,
|
|
792
|
+
generatedAt: new Date().toISOString(),
|
|
793
|
+
cwd: process.cwd(),
|
|
794
|
+
version: PKG.version,
|
|
795
|
+
limit,
|
|
796
|
+
maskSecrets,
|
|
797
|
+
});
|
|
798
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
799
|
+
writeFileSync(outputPath, html, 'utf8');
|
|
800
|
+
console.log(chalk.green(
|
|
801
|
+
`✓ Report written: ${outputPath} (${summaries.length} snapshot${summaries.length === 1 ? '' : 's'})`,
|
|
802
|
+
));
|
|
803
|
+
} catch (err) {
|
|
804
|
+
renderError(err.message);
|
|
805
|
+
process.exit(1);
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
|
|
541
809
|
program
|
|
542
810
|
.command('ci [files...]')
|
|
543
811
|
.description('Run semantic diff in CI mode')
|
|
544
812
|
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
545
813
|
.option('--snapshot-ref <ref>', 'Snapshot reference: snapshot path or git ref')
|
|
546
|
-
.option('--format <type>', 'Output format: json | ndjson | github-annotations', 'json')
|
|
814
|
+
.option('--format <type>', 'Output format: json | ndjson | github-annotations | pr-comment', 'json')
|
|
815
|
+
.option('--pr-comment-post', 'With --format pr-comment, upsert the comment on the PR (needs GITHUB_TOKEN + PR context)', false)
|
|
547
816
|
.option('--fail-on <rules>', 'Comma-separated fail rules: changed,added,removed,policy,error,warn', 'changed,policy,error')
|
|
548
817
|
.option('--ignore <keys>', 'Comma-separated key paths to ignore')
|
|
549
818
|
.option('--policies <ids>', 'Comma-separated policy pack ids')
|
|
@@ -565,10 +834,14 @@ program
|
|
|
565
834
|
}
|
|
566
835
|
|
|
567
836
|
const ignorePaths = parseCsv(effective.ignore);
|
|
568
|
-
const failOn =
|
|
837
|
+
const failOn = parseFailOn(effective.failOn ?? 'changed,policy,error');
|
|
569
838
|
const format = String(effective.format ?? 'json');
|
|
570
|
-
if (!['json', 'ndjson', 'github-annotations'].includes(format)) {
|
|
571
|
-
throw new Error('--format must be json, ndjson, or
|
|
839
|
+
if (!['json', 'ndjson', 'github-annotations', 'pr-comment'].includes(format)) {
|
|
840
|
+
throw new Error('--format must be json, ndjson, github-annotations, or pr-comment');
|
|
841
|
+
}
|
|
842
|
+
const prCommentPost = Boolean(effective.prCommentPost);
|
|
843
|
+
if (prCommentPost && format !== 'pr-comment') {
|
|
844
|
+
renderWarn('Ignoring --pr-comment-post: it only applies to --format pr-comment.');
|
|
572
845
|
}
|
|
573
846
|
const maskSecrets = Boolean(effective.maskSecrets);
|
|
574
847
|
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
@@ -608,13 +881,14 @@ program
|
|
|
608
881
|
severityRemap,
|
|
609
882
|
});
|
|
610
883
|
const outboundChanges = maybeMaskChanges(events, maskSecrets);
|
|
884
|
+
const outboundFindings = maybeMaskFindings(policyFindings, events, maskSecrets);
|
|
611
885
|
const envelope = createEnvelope({
|
|
612
886
|
source: 'ci',
|
|
613
887
|
file: filepath,
|
|
614
888
|
changes: outboundChanges,
|
|
615
|
-
policies:
|
|
889
|
+
policies: outboundFindings,
|
|
616
890
|
});
|
|
617
|
-
results.push({ file: filepath, envelope, policies:
|
|
891
|
+
results.push({ file: filepath, envelope, policies: outboundFindings });
|
|
618
892
|
diffed += 1;
|
|
619
893
|
|
|
620
894
|
if (shouldFailFromChanges(events, failOn) || shouldFailFromPolicy(policyFindings, failOn)) {
|
|
@@ -629,7 +903,203 @@ program
|
|
|
629
903
|
);
|
|
630
904
|
}
|
|
631
905
|
|
|
632
|
-
|
|
906
|
+
if (format === 'pr-comment') {
|
|
907
|
+
const body = renderPrComment(results, { cwd: process.cwd(), failed: shouldFail });
|
|
908
|
+
console.log(body);
|
|
909
|
+
await deliverPrCommentSafely(body, prCommentPost);
|
|
910
|
+
} else {
|
|
911
|
+
printCiOutput(results, format);
|
|
912
|
+
}
|
|
913
|
+
process.exit(shouldFail ? 1 : 0);
|
|
914
|
+
} catch (err) {
|
|
915
|
+
renderError(err.message);
|
|
916
|
+
process.exit(1);
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
program
|
|
921
|
+
.command('plan <planFiles...>')
|
|
922
|
+
.description('Diff Terraform plan JSON (terraform show -json) and run policies on it')
|
|
923
|
+
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
924
|
+
.option('--format <type>', 'Output format: human | json | ndjson | github-annotations | pr-comment', 'human')
|
|
925
|
+
.option('--pr-comment-post', 'With --format pr-comment, upsert the comment on the PR (needs GITHUB_TOKEN + PR context)', false)
|
|
926
|
+
.option('--fail-on <rules>', 'Comma-separated fail rules: changed,added,removed,policy,error,warn', PLAN_DEFAULT_FAIL_ON)
|
|
927
|
+
.option('--ignore <keys>', 'Comma-separated key paths to ignore, e.g. "**.tags_all,**.#action"')
|
|
928
|
+
.option('--policies <ids>', `Comma-separated policy pack ids (default: ${PLAN_DEFAULT_POLICIES})`)
|
|
929
|
+
.option('--plugins <paths>', 'Comma-separated local ESM plugin paths')
|
|
930
|
+
.option('--mask-secrets', 'Also mask Flecto-detected secret-like values (Terraform-sensitive values are always redacted)', false)
|
|
931
|
+
.action(async (planFiles, opts, command) => {
|
|
932
|
+
try {
|
|
933
|
+
const { config } = loadRcConfig(process.cwd());
|
|
934
|
+
const profile = resolveProfileName(opts.profile);
|
|
935
|
+
const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts, command));
|
|
936
|
+
// A plan carries Terraform-shaped paths, so the config-file packs are not
|
|
937
|
+
// the useful default here; `terraform` is. An explicit --policies or a
|
|
938
|
+
// .flectorc entry still wins.
|
|
939
|
+
const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(
|
|
940
|
+
effective.policies === undefined
|
|
941
|
+
? { ...effective, policies: PLAN_DEFAULT_POLICIES }
|
|
942
|
+
: effective,
|
|
943
|
+
);
|
|
944
|
+
|
|
945
|
+
const ignorePaths = parseCsv(effective.ignore);
|
|
946
|
+
const failOn = new Set(parseCsv(effective.failOn ?? PLAN_DEFAULT_FAIL_ON));
|
|
947
|
+
const format = String(effective.format ?? 'human');
|
|
948
|
+
if (!['human', 'json', 'ndjson', 'github-annotations', 'pr-comment'].includes(format)) {
|
|
949
|
+
throw new Error('--format must be human, json, ndjson, github-annotations, or pr-comment');
|
|
950
|
+
}
|
|
951
|
+
const prCommentPost = Boolean(effective.prCommentPost);
|
|
952
|
+
if (prCommentPost && format !== 'pr-comment') {
|
|
953
|
+
renderWarn('Ignoring --pr-comment-post: it only applies to --format pr-comment.');
|
|
954
|
+
}
|
|
955
|
+
const maskSecrets = Boolean(effective.maskSecrets);
|
|
956
|
+
|
|
957
|
+
/** @type {any[]} */
|
|
958
|
+
const results = [];
|
|
959
|
+
let shouldFail = false;
|
|
960
|
+
|
|
961
|
+
for (const planFile of planFiles) {
|
|
962
|
+
const filepath = resolve(planFile);
|
|
963
|
+
if (!existsSync(filepath)) {
|
|
964
|
+
throw new Error(`File not found: ${filepath}`);
|
|
965
|
+
}
|
|
966
|
+
const plan = readTerraformPlanFile(filepath);
|
|
967
|
+
const { changes, summary, formatVersion, terraformVersion, warnings } =
|
|
968
|
+
diffTerraformPlan(plan, { ignorePaths });
|
|
969
|
+
for (const warning of warnings) renderWarn(warning);
|
|
970
|
+
|
|
971
|
+
// Terraform-sensitive values were already replaced during conversion,
|
|
972
|
+
// so policies never see them. --mask-secrets adds Flecto's own
|
|
973
|
+
// value-shaped detection on top, for credentials Terraform did not mark.
|
|
974
|
+
const policyFindings = await evaluatePolicies(changes, {
|
|
975
|
+
cwd: process.cwd(),
|
|
976
|
+
file: filepath,
|
|
977
|
+
profile: profile ?? null,
|
|
978
|
+
source: 'ci',
|
|
979
|
+
policies: packIds,
|
|
980
|
+
plugins,
|
|
981
|
+
severityRemap,
|
|
982
|
+
});
|
|
983
|
+
const outboundChanges = maybeMaskChanges(changes, maskSecrets);
|
|
984
|
+
const envelope = createEnvelope({
|
|
985
|
+
source: 'ci',
|
|
986
|
+
file: filepath,
|
|
987
|
+
changes: outboundChanges,
|
|
988
|
+
policies: maybeMaskFindings(policyFindings, maskSecrets),
|
|
989
|
+
});
|
|
990
|
+
results.push({ file: filepath, envelope, policies: envelope.policies });
|
|
991
|
+
|
|
992
|
+
if (format === 'human') {
|
|
993
|
+
const version = [
|
|
994
|
+
formatVersion ? `plan format ${formatVersion}` : null,
|
|
995
|
+
terraformVersion ? `terraform ${terraformVersion}` : null,
|
|
996
|
+
].filter(Boolean).join(', ');
|
|
997
|
+
renderInfo(`${filepath}${version ? ` — ${version}` : ''}`);
|
|
998
|
+
renderInfo(formatPlanSummary(summary));
|
|
999
|
+
renderDiff(filepath, outboundChanges, { maskSecrets, baseline: 'the current state' });
|
|
1000
|
+
renderPolicyFindings(envelope.policies);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
if (shouldFailFromChanges(changes, failOn) || shouldFailFromPolicy(policyFindings, failOn)) {
|
|
1004
|
+
shouldFail = true;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
if (format === 'pr-comment') {
|
|
1009
|
+
const body = renderPrComment(results, { cwd: process.cwd(), failed: shouldFail });
|
|
1010
|
+
console.log(body);
|
|
1011
|
+
await deliverPrCommentSafely(body, prCommentPost);
|
|
1012
|
+
} else if (format !== 'human') {
|
|
1013
|
+
printCiOutput(results, format);
|
|
1014
|
+
}
|
|
1015
|
+
process.exit(shouldFail ? 1 : 0);
|
|
1016
|
+
} catch (err) {
|
|
1017
|
+
renderError(err.message);
|
|
1018
|
+
process.exit(1);
|
|
1019
|
+
}
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
program
|
|
1023
|
+
.command('compare <fileA> <fileB>')
|
|
1024
|
+
.description('Diff two config files against each other (fileA is the baseline)')
|
|
1025
|
+
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
1026
|
+
.option('--format <type>', 'Output format: human | json | ndjson | github-annotations', 'human')
|
|
1027
|
+
.option('--fail-on <rules>', 'Comma-separated fail rules: changed,added,removed,policy,error,warn', 'changed,added,removed,policy,error')
|
|
1028
|
+
.option('--ignore <keys>', 'Comma-separated key paths to ignore')
|
|
1029
|
+
.option('--policies <ids>', 'Comma-separated policy pack ids')
|
|
1030
|
+
.option('--plugins <paths>', 'Comma-separated local ESM plugin paths')
|
|
1031
|
+
.option('--array-id-key <key>', 'Diff arrays by this object identity key')
|
|
1032
|
+
.option('--no-array-id', 'Diff arrays by index instead of object identity')
|
|
1033
|
+
.option('--array-ignore-order', 'Treat array order as insignificant', false)
|
|
1034
|
+
.option('--mask-secrets', 'Mask secret-like values in output', false)
|
|
1035
|
+
.action(async (fileA, fileB, opts, command) => {
|
|
1036
|
+
try {
|
|
1037
|
+
const { config } = loadRcConfig(process.cwd());
|
|
1038
|
+
const profile = resolveProfileName(opts.profile);
|
|
1039
|
+
const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts, command));
|
|
1040
|
+
const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(effective);
|
|
1041
|
+
|
|
1042
|
+
const ignorePaths = parseCsv(effective.ignore);
|
|
1043
|
+
const failOn = parseFailOn(effective.failOn ?? 'changed,added,removed,policy,error');
|
|
1044
|
+
const format = String(effective.format ?? 'human');
|
|
1045
|
+
if (!['human', 'json', 'ndjson', 'github-annotations'].includes(format)) {
|
|
1046
|
+
throw new Error('--format must be human, json, ndjson, or github-annotations');
|
|
1047
|
+
}
|
|
1048
|
+
const maskSecrets = Boolean(effective.maskSecrets);
|
|
1049
|
+
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
1050
|
+
|
|
1051
|
+
const baselinePath = resolve(fileA);
|
|
1052
|
+
const targetPath = resolve(fileB);
|
|
1053
|
+
// Both sides are named explicitly, so a missing one is an error rather
|
|
1054
|
+
// than the skip-and-warn `ci` applies to expanded globs.
|
|
1055
|
+
for (const filepath of [baselinePath, targetPath]) {
|
|
1056
|
+
if (!existsSync(filepath)) {
|
|
1057
|
+
throw new Error(`File not found: ${filepath}`);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// fileA is the baseline: "removed" is present only in fileA, "added" only
|
|
1062
|
+
// in fileB. Every format parses to a plain tree, so the two sides need not
|
|
1063
|
+
// share one — parseFile rejects unsupported extensions with the same
|
|
1064
|
+
// message every other command uses.
|
|
1065
|
+
const before = parseFile(baselinePath);
|
|
1066
|
+
const after = parseFile(targetPath);
|
|
1067
|
+
const events = diffTrees(before, after, dOpts);
|
|
1068
|
+
const policyFindings = await evaluatePolicies(events, {
|
|
1069
|
+
cwd: process.cwd(),
|
|
1070
|
+
file: targetPath,
|
|
1071
|
+
profile: profile ?? null,
|
|
1072
|
+
source: 'diff',
|
|
1073
|
+
policies: packIds,
|
|
1074
|
+
plugins,
|
|
1075
|
+
severityRemap,
|
|
1076
|
+
});
|
|
1077
|
+
const outboundFindings = maybeMaskFindings(policyFindings, events, maskSecrets);
|
|
1078
|
+
|
|
1079
|
+
if (format === 'human') {
|
|
1080
|
+
if (events.length > 0) {
|
|
1081
|
+
renderInfo('"+" exists only in the compared file, "-" only in the baseline, "~" differs');
|
|
1082
|
+
}
|
|
1083
|
+
renderDiff(targetPath, events, { maskSecrets, baseline: baselinePath });
|
|
1084
|
+
renderPolicyFindings(outboundFindings);
|
|
1085
|
+
} else {
|
|
1086
|
+
const envelope = createEnvelope({
|
|
1087
|
+
source: 'diff',
|
|
1088
|
+
file: targetPath,
|
|
1089
|
+
changes: maybeMaskChanges(events, maskSecrets),
|
|
1090
|
+
policies: outboundFindings,
|
|
1091
|
+
});
|
|
1092
|
+
// Same envelope and printer as `ci`, so machine consumers see one shape.
|
|
1093
|
+
// `baseline` rides on the result wrapper rather than the envelope, which
|
|
1094
|
+
// is closed by schemas/flecto-envelope-2.0.json.
|
|
1095
|
+
printCiOutput(
|
|
1096
|
+
[{ file: targetPath, baseline: baselinePath, envelope, policies: outboundFindings }],
|
|
1097
|
+
format,
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
const shouldFail = shouldFailFromChanges(events, failOn)
|
|
1102
|
+
|| shouldFailFromPolicy(policyFindings, failOn);
|
|
633
1103
|
process.exit(shouldFail ? 1 : 0);
|
|
634
1104
|
} catch (err) {
|
|
635
1105
|
renderError(err.message);
|
|
@@ -671,10 +1141,10 @@ program
|
|
|
671
1141
|
}
|
|
672
1142
|
|
|
673
1143
|
console.log('Resolution order: policies/<id>.json, .yaml, .yml, then built-in packs.');
|
|
674
|
-
console.log('id\tsource path\trules\toverrides builtin');
|
|
1144
|
+
console.log('id\tsource path\trules\toverrides builtin\tpackage');
|
|
675
1145
|
for (const pack of packs) {
|
|
676
1146
|
console.log(
|
|
677
|
-
`${pack.id}\t${pack.sourcePath}\t${pack.ruleCount}\t${pack.overridesBuiltin ? 'yes' : 'no'}`,
|
|
1147
|
+
`${pack.id}\t${pack.sourcePath}\t${pack.ruleCount}\t${pack.overridesBuiltin ? 'yes' : 'no'}\t${pack.package ?? '-'}`,
|
|
678
1148
|
);
|
|
679
1149
|
}
|
|
680
1150
|
} catch (err) {
|
|
@@ -682,14 +1152,62 @@ program
|
|
|
682
1152
|
process.exit(1);
|
|
683
1153
|
}
|
|
684
1154
|
});
|
|
1155
|
+
|
|
1156
|
+
policies
|
|
1157
|
+
.command('add <name>')
|
|
1158
|
+
.description('Install a policy pack from an installed flecto-pack-* npm package')
|
|
1159
|
+
.option('--force', 'Overwrite an existing local pack with the same id')
|
|
1160
|
+
.action((name, opts) => {
|
|
1161
|
+
try {
|
|
1162
|
+
const added = addPolicyPackFromPackage(name, {
|
|
1163
|
+
cwd: process.cwd(),
|
|
1164
|
+
force: Boolean(opts.force),
|
|
1165
|
+
});
|
|
1166
|
+
const version = added.packageVersion ? `@${added.packageVersion}` : '';
|
|
1167
|
+
const verb = added.overwritten ? 'Updated' : 'Added';
|
|
1168
|
+
renderInfo(
|
|
1169
|
+
`${verb} policy pack "${added.id}" from ${added.packageName}${version} `
|
|
1170
|
+
+ `→ ${relative(process.cwd(), added.targetPath)} `
|
|
1171
|
+
+ `(${added.ruleCount} rule${added.ruleCount === 1 ? '' : 's'})`,
|
|
1172
|
+
);
|
|
1173
|
+
if (added.overridesBuiltin) {
|
|
1174
|
+
renderWarn(`Pack "${added.id}" now overrides the built-in pack of the same id.`);
|
|
1175
|
+
}
|
|
1176
|
+
for (const path of added.shadowed) {
|
|
1177
|
+
renderWarn(`${relative(process.cwd(), path)} is no longer used: ${added.id}.json wins.`);
|
|
1178
|
+
}
|
|
1179
|
+
if (added.shipsCode) {
|
|
1180
|
+
renderInfo(
|
|
1181
|
+
`${added.packageName} also ships JavaScript. It was ignored: only the declarative `
|
|
1182
|
+
+ 'pack file is read, and no package code is ever imported or run.',
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
renderInfo(`Activate it with: flecto ci <files> --policies ${added.id}`);
|
|
1186
|
+
} catch (err) {
|
|
1187
|
+
renderError(err.message);
|
|
1188
|
+
process.exit(1);
|
|
1189
|
+
}
|
|
1190
|
+
});
|
|
685
1191
|
}
|
|
686
1192
|
|
|
687
1193
|
program
|
|
688
1194
|
.command('init')
|
|
689
|
-
.description('Create starter .flectorc configuration')
|
|
1195
|
+
.description('Create starter .flectorc configuration from detected stack signals')
|
|
690
1196
|
.action(() => {
|
|
691
|
-
const path = initRcFile(process.cwd());
|
|
1197
|
+
const { path, created, detection } = initRcFile(process.cwd());
|
|
1198
|
+
if (!created) {
|
|
1199
|
+
renderWarn(`Config already exists: ${path} (left unchanged)`);
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
692
1202
|
renderInfo(`Initialized config: ${path}`);
|
|
1203
|
+
if (detection.signals.length === 0) {
|
|
1204
|
+
renderInfo('No stack signals detected — wrote the generic starter config.');
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
for (const signal of detection.signals) {
|
|
1208
|
+
renderInfo(signal.summary);
|
|
1209
|
+
}
|
|
1210
|
+
renderInfo(`Policy packs: ${detection.packs.join(', ')}`);
|
|
693
1211
|
});
|
|
694
1212
|
|
|
695
1213
|
program
|