flecto 2.1.0 → 3.0.1
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 +464 -1
- package/README.md +348 -304
- package/index.js +586 -61
- 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 +173 -22
- 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')
|
|
@@ -328,8 +484,9 @@ program
|
|
|
328
484
|
try {
|
|
329
485
|
const { config } = loadRcConfig(process.cwd());
|
|
330
486
|
const profile = resolveProfileName(opts.profile);
|
|
331
|
-
const
|
|
332
|
-
const
|
|
487
|
+
const cliOverrides = stripUnsetCliOverrides(opts, command);
|
|
488
|
+
const effective = resolveEffectiveOptions(config, profile, cliOverrides);
|
|
489
|
+
const { policies, plugins, severityRemap } = resolvePolicyOptions(effective, { pluginsFromCli: cliOverrides.plugins !== undefined });
|
|
333
490
|
const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
|
|
334
491
|
if (targets.length === 0) {
|
|
335
492
|
throw new Error('No files matched. Provide files or configure .flectorc files/include.');
|
|
@@ -343,10 +500,12 @@ program
|
|
|
343
500
|
validateMode(mode);
|
|
344
501
|
const maskSecrets = Boolean(effective.maskSecrets);
|
|
345
502
|
const maskSecretsWebhooks = Boolean(effective.maskSecretsWebhooks);
|
|
503
|
+
const webhookFormat = resolveWebhookFormat(effective.webhookFormat, effective.webhook);
|
|
346
504
|
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
347
505
|
|
|
348
506
|
if (effective.snapshot) {
|
|
349
507
|
mkdirSync(SNAPSHOT_DIR, { recursive: true });
|
|
508
|
+
const idsWithHistory = snapshotIdsWithHistory();
|
|
350
509
|
let written = 0;
|
|
351
510
|
for (const filepath of targets) {
|
|
352
511
|
if (!existsSync(filepath)) {
|
|
@@ -359,10 +518,21 @@ program
|
|
|
359
518
|
}
|
|
360
519
|
const state = parseFile(filepath);
|
|
361
520
|
const snapshotPath = snapshotPathForFile(filepath);
|
|
362
|
-
preserveLegacySnapshotForHistory(filepath, snapshotPath);
|
|
363
|
-
|
|
521
|
+
preserveLegacySnapshotForHistory(filepath, snapshotPath, idsWithHistory);
|
|
522
|
+
// Only a multi-document file records `documents`, so an ordinary
|
|
523
|
+
// snapshot is byte-for-byte what it was before this field existed.
|
|
524
|
+
const documents = documentKeysOf(state) ?? [];
|
|
525
|
+
const snapshot = {
|
|
526
|
+
file: filepath,
|
|
527
|
+
state,
|
|
528
|
+
...(documents.length > 0 ? { documents: [...documents] } : {}),
|
|
529
|
+
createdAt: new Date().toISOString(),
|
|
530
|
+
};
|
|
364
531
|
writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2), 'utf8');
|
|
365
532
|
writeFileSync(snapshotHistoryPathForFile(filepath), JSON.stringify(snapshot, null, 2), 'utf8');
|
|
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));
|
|
366
536
|
console.log(chalk.green(`✓ Snapshot saved: ${snapshotPath}`));
|
|
367
537
|
written += 1;
|
|
368
538
|
}
|
|
@@ -393,6 +563,33 @@ program
|
|
|
393
563
|
}
|
|
394
564
|
|
|
395
565
|
const watchers = [];
|
|
566
|
+
let closing = false;
|
|
567
|
+
const closeAll = async (exitCode) => {
|
|
568
|
+
if (closing) return;
|
|
569
|
+
closing = true;
|
|
570
|
+
await Promise.all(watchers.map((w) => w.close()));
|
|
571
|
+
if (exitCode === 0) {
|
|
572
|
+
console.log(chalk.dim('\nflecto stopped.'));
|
|
573
|
+
}
|
|
574
|
+
process.exit(exitCode);
|
|
575
|
+
};
|
|
576
|
+
const alertOptions = {
|
|
577
|
+
command: effective.command,
|
|
578
|
+
webhook: effective.webhook,
|
|
579
|
+
webhookHeaders,
|
|
580
|
+
webhookTimeoutMs: parseInt(String(effective.webhookTimeout ?? '5000'), 10),
|
|
581
|
+
webhookRetries: parseInt(String(effective.webhookRetries ?? '2'), 10),
|
|
582
|
+
webhookFormat,
|
|
583
|
+
deliveryMode: effective.deliveryMode,
|
|
584
|
+
onAlertFailure: effective.onAlertFailure,
|
|
585
|
+
};
|
|
586
|
+
const deliverAlert = async (envelope) => {
|
|
587
|
+
const result = await fireAlerts(alertOptions, envelope);
|
|
588
|
+
if (!result.ok && effective.onAlertFailure === 'exit') {
|
|
589
|
+
await closeAll(1);
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
|
|
396
593
|
for (const filepath of targets) {
|
|
397
594
|
if (!existsSync(filepath)) {
|
|
398
595
|
renderWarn(`Skipping missing file: ${filepath}`);
|
|
@@ -425,24 +622,20 @@ program
|
|
|
425
622
|
renderError(`policy evaluation failed: ${err.message}`);
|
|
426
623
|
process.exit(1);
|
|
427
624
|
}
|
|
428
|
-
renderPolicyFindings(policyFindings);
|
|
625
|
+
renderPolicyFindings(maybeMaskFindings(policyFindings, event.events, maskSecrets));
|
|
429
626
|
if (effective.command || effective.webhook) {
|
|
430
627
|
const outboundChanges = maybeMaskChanges(event.events, maskSecretsWebhooks);
|
|
431
628
|
const envelope = createEnvelope({
|
|
432
629
|
source: 'watch',
|
|
433
630
|
file: event.filepath,
|
|
434
631
|
changes: outboundChanges,
|
|
435
|
-
policies:
|
|
632
|
+
policies: maybeMaskFindings(
|
|
633
|
+
policyFindings,
|
|
634
|
+
event.events,
|
|
635
|
+
maskSecretsWebhooks,
|
|
636
|
+
),
|
|
436
637
|
});
|
|
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);
|
|
638
|
+
await deliverAlert(envelope);
|
|
446
639
|
}
|
|
447
640
|
} else {
|
|
448
641
|
renderInfo(`[lifecycle] ${event.filepath}: ${event.lifecycle.type} - ${event.lifecycle.message}`);
|
|
@@ -452,15 +645,7 @@ program
|
|
|
452
645
|
file: event.filepath,
|
|
453
646
|
lifecycle: event.lifecycle,
|
|
454
647
|
});
|
|
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);
|
|
648
|
+
await deliverAlert(envelope);
|
|
464
649
|
}
|
|
465
650
|
}
|
|
466
651
|
}
|
|
@@ -473,13 +658,6 @@ program
|
|
|
473
658
|
}
|
|
474
659
|
renderInfo('Press Ctrl+C to stop.\n');
|
|
475
660
|
|
|
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
661
|
process.on('SIGINT', () => void closeAll(0));
|
|
484
662
|
process.on('SIGTERM', () => void closeAll(0));
|
|
485
663
|
} catch (err) {
|
|
@@ -505,7 +683,8 @@ program
|
|
|
505
683
|
|
|
506
684
|
const { config } = loadRcConfig(process.cwd());
|
|
507
685
|
const profile = resolveProfileName(opts.profile);
|
|
508
|
-
const
|
|
686
|
+
const cliOverrides = stripUnsetCliOverrides(opts, command);
|
|
687
|
+
const effective = resolveEffectiveOptions(config, profile, cliOverrides);
|
|
509
688
|
const ignorePaths = parseCsv(effective.ignore);
|
|
510
689
|
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
511
690
|
|
|
@@ -538,12 +717,105 @@ program
|
|
|
538
717
|
}
|
|
539
718
|
});
|
|
540
719
|
|
|
720
|
+
program
|
|
721
|
+
.command('report [files...]')
|
|
722
|
+
.description('Render local snapshot history as a self-contained HTML report')
|
|
723
|
+
.option('-o, --output <path>', 'Write the report to this path', 'flecto-report.html')
|
|
724
|
+
.option('-l, --limit <n>', 'Number of recent snapshots to include', '10')
|
|
725
|
+
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
726
|
+
.option('--ignore <keys>', 'Comma-separated key paths to ignore (e.g. "updated_at,meta.ts")')
|
|
727
|
+
.option('--policies <ids>', 'Comma-separated policy pack ids (default: default)')
|
|
728
|
+
.option('--plugins <paths>', 'Comma-separated local ESM plugin paths')
|
|
729
|
+
.option('--array-id-key <key>', 'Diff arrays by this object identity key')
|
|
730
|
+
.option('--no-array-id', 'Diff arrays by index instead of object identity')
|
|
731
|
+
.option('--array-ignore-order', 'Treat array order as insignificant', false)
|
|
732
|
+
.option('--mask-secrets', 'Mask secret-like values in the report', false)
|
|
733
|
+
.action(async (files, opts, command) => {
|
|
734
|
+
try {
|
|
735
|
+
const { config } = loadRcConfig(process.cwd());
|
|
736
|
+
const profile = resolveProfileName(opts.profile);
|
|
737
|
+
const cliOverrides = stripUnsetCliOverrides(opts, command);
|
|
738
|
+
const effective = resolveEffectiveOptions(config, profile, cliOverrides);
|
|
739
|
+
const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(effective, { pluginsFromCli: cliOverrides.plugins !== undefined });
|
|
740
|
+
|
|
741
|
+
const limit = Number.parseInt(String(effective.limit ?? '10'), 10);
|
|
742
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
743
|
+
throw new Error('--limit must be a positive integer');
|
|
744
|
+
}
|
|
745
|
+
const ignorePaths = parseCsv(effective.ignore);
|
|
746
|
+
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
747
|
+
const maskSecrets = Boolean(effective.maskSecrets);
|
|
748
|
+
const outputPath = resolve(String(effective.output ?? 'flecto-report.html'));
|
|
749
|
+
|
|
750
|
+
// Same snapshot source, filtering, and errors as `flecto history` — this
|
|
751
|
+
// command only changes how that history is rendered.
|
|
752
|
+
const allSnapshots = readLocalSnapshotHistory();
|
|
753
|
+
let snapshots = allSnapshots;
|
|
754
|
+
if (files.length > 0) {
|
|
755
|
+
const targets = new Set((await resolveTargetFiles(files, config)).map((file) => resolve(file)));
|
|
756
|
+
snapshots = snapshots.filter((snapshot) => targets.has(resolve(snapshot.file)));
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const summaries = summarizeSnapshotHistory(snapshots, limit, dOpts);
|
|
760
|
+
if (summaries.length === 0) {
|
|
761
|
+
if (files.length > 0 && allSnapshots.length > 0) {
|
|
762
|
+
throw new Error(
|
|
763
|
+
'No local snapshots matched the given files. Omit files to report on all saved snapshot history.',
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
throw new Error('No local snapshots found. Run "flecto watch <file> --snapshot" first.');
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const reportSnapshots = [];
|
|
770
|
+
for (const summary of summaries) {
|
|
771
|
+
// Policies run on the unmasked events: masking first would hide the
|
|
772
|
+
// very values the secret rules match on. Redaction happens after, on
|
|
773
|
+
// everything that reaches the page.
|
|
774
|
+
const findings = await evaluatePolicies(summary.changes, {
|
|
775
|
+
cwd: process.cwd(),
|
|
776
|
+
file: summary.file,
|
|
777
|
+
profile: profile ?? null,
|
|
778
|
+
source: 'diff',
|
|
779
|
+
policies: packIds,
|
|
780
|
+
plugins,
|
|
781
|
+
severityRemap,
|
|
782
|
+
});
|
|
783
|
+
reportSnapshots.push({
|
|
784
|
+
file: summary.file,
|
|
785
|
+
createdAt: summary.createdAt,
|
|
786
|
+
previousCreatedAt: summary.previousCreatedAt,
|
|
787
|
+
changeCount: summary.changeCount,
|
|
788
|
+
changes: maybeMaskChanges(summary.changes, maskSecrets),
|
|
789
|
+
policies: maybeMaskFindings(findings, summary.changes, maskSecrets),
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const html = renderReportHtml({
|
|
794
|
+
snapshots: reportSnapshots,
|
|
795
|
+
generatedAt: new Date().toISOString(),
|
|
796
|
+
cwd: process.cwd(),
|
|
797
|
+
version: PKG.version,
|
|
798
|
+
limit,
|
|
799
|
+
maskSecrets,
|
|
800
|
+
});
|
|
801
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
802
|
+
writeFileSync(outputPath, html, 'utf8');
|
|
803
|
+
console.log(chalk.green(
|
|
804
|
+
`✓ Report written: ${outputPath} (${summaries.length} snapshot${summaries.length === 1 ? '' : 's'})`,
|
|
805
|
+
));
|
|
806
|
+
} catch (err) {
|
|
807
|
+
renderError(err.message);
|
|
808
|
+
process.exit(1);
|
|
809
|
+
}
|
|
810
|
+
});
|
|
811
|
+
|
|
541
812
|
program
|
|
542
813
|
.command('ci [files...]')
|
|
543
814
|
.description('Run semantic diff in CI mode')
|
|
544
815
|
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
545
816
|
.option('--snapshot-ref <ref>', 'Snapshot reference: snapshot path or git ref')
|
|
546
|
-
.option('--format <type>', 'Output format: json | ndjson | github-annotations', 'json')
|
|
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 GITHUB_TOKEN + PR context)', false)
|
|
547
819
|
.option('--fail-on <rules>', 'Comma-separated fail rules: changed,added,removed,policy,error,warn', 'changed,policy,error')
|
|
548
820
|
.option('--ignore <keys>', 'Comma-separated key paths to ignore')
|
|
549
821
|
.option('--policies <ids>', 'Comma-separated policy pack ids')
|
|
@@ -557,18 +829,23 @@ program
|
|
|
557
829
|
try {
|
|
558
830
|
const { config } = loadRcConfig(process.cwd());
|
|
559
831
|
const profile = resolveProfileName(opts.profile);
|
|
560
|
-
const
|
|
561
|
-
const
|
|
832
|
+
const cliOverrides = stripUnsetCliOverrides(opts, command);
|
|
833
|
+
const effective = resolveEffectiveOptions(config, profile, cliOverrides);
|
|
834
|
+
const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(effective, { pluginsFromCli: cliOverrides.plugins !== undefined });
|
|
562
835
|
const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
|
|
563
836
|
if (targets.length === 0) {
|
|
564
837
|
throw new Error('No files matched. Provide files or configure .flectorc files/include.');
|
|
565
838
|
}
|
|
566
839
|
|
|
567
840
|
const ignorePaths = parseCsv(effective.ignore);
|
|
568
|
-
const failOn =
|
|
841
|
+
const failOn = parseFailOn(effective.failOn ?? 'changed,policy,error');
|
|
569
842
|
const format = String(effective.format ?? 'json');
|
|
570
|
-
if (!['json', 'ndjson', 'github-annotations'].includes(format)) {
|
|
571
|
-
throw new Error('--format must be json, ndjson, or
|
|
843
|
+
if (!['json', 'ndjson', 'github-annotations', 'pr-comment'].includes(format)) {
|
|
844
|
+
throw new Error('--format must be json, ndjson, github-annotations, or pr-comment');
|
|
845
|
+
}
|
|
846
|
+
const prCommentPost = Boolean(effective.prCommentPost);
|
|
847
|
+
if (prCommentPost && format !== 'pr-comment') {
|
|
848
|
+
renderWarn('Ignoring --pr-comment-post: it only applies to --format pr-comment.');
|
|
572
849
|
}
|
|
573
850
|
const maskSecrets = Boolean(effective.maskSecrets);
|
|
574
851
|
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
@@ -608,13 +885,14 @@ program
|
|
|
608
885
|
severityRemap,
|
|
609
886
|
});
|
|
610
887
|
const outboundChanges = maybeMaskChanges(events, maskSecrets);
|
|
888
|
+
const outboundFindings = maybeMaskFindings(policyFindings, events, maskSecrets);
|
|
611
889
|
const envelope = createEnvelope({
|
|
612
890
|
source: 'ci',
|
|
613
891
|
file: filepath,
|
|
614
892
|
changes: outboundChanges,
|
|
615
|
-
policies:
|
|
893
|
+
policies: outboundFindings,
|
|
616
894
|
});
|
|
617
|
-
results.push({ file: filepath, envelope, policies:
|
|
895
|
+
results.push({ file: filepath, envelope, policies: outboundFindings });
|
|
618
896
|
diffed += 1;
|
|
619
897
|
|
|
620
898
|
if (shouldFailFromChanges(events, failOn) || shouldFailFromPolicy(policyFindings, failOn)) {
|
|
@@ -629,7 +907,206 @@ program
|
|
|
629
907
|
);
|
|
630
908
|
}
|
|
631
909
|
|
|
632
|
-
|
|
910
|
+
if (format === 'pr-comment') {
|
|
911
|
+
const body = renderPrComment(results, { cwd: process.cwd(), failed: shouldFail });
|
|
912
|
+
console.log(body);
|
|
913
|
+
await deliverPrCommentSafely(body, prCommentPost);
|
|
914
|
+
} else {
|
|
915
|
+
printCiOutput(results, format);
|
|
916
|
+
}
|
|
917
|
+
process.exit(shouldFail ? 1 : 0);
|
|
918
|
+
} catch (err) {
|
|
919
|
+
renderError(err.message);
|
|
920
|
+
process.exit(1);
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
program
|
|
925
|
+
.command('plan <planFiles...>')
|
|
926
|
+
.description('Diff Terraform plan JSON (terraform show -json) and run policies on it')
|
|
927
|
+
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
928
|
+
.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 GITHUB_TOKEN + PR context)', false)
|
|
930
|
+
.option('--fail-on <rules>', 'Comma-separated fail rules: changed,added,removed,policy,error,warn', PLAN_DEFAULT_FAIL_ON)
|
|
931
|
+
.option('--ignore <keys>', 'Comma-separated key paths to ignore, e.g. "**.tags_all,**.#action"')
|
|
932
|
+
.option('--policies <ids>', `Comma-separated policy pack ids (default: ${PLAN_DEFAULT_POLICIES})`)
|
|
933
|
+
.option('--plugins <paths>', 'Comma-separated local ESM plugin paths')
|
|
934
|
+
.option('--mask-secrets', 'Also mask Flecto-detected secret-like values (Terraform-sensitive values are always redacted)', false)
|
|
935
|
+
.action(async (planFiles, opts, command) => {
|
|
936
|
+
try {
|
|
937
|
+
const { config } = loadRcConfig(process.cwd());
|
|
938
|
+
const profile = resolveProfileName(opts.profile);
|
|
939
|
+
const cliOverrides = stripUnsetCliOverrides(opts, command);
|
|
940
|
+
const effective = resolveEffectiveOptions(config, profile, cliOverrides);
|
|
941
|
+
// A plan carries Terraform-shaped paths, so the config-file packs are not
|
|
942
|
+
// the useful default here; `terraform` is. An explicit --policies or a
|
|
943
|
+
// .flectorc entry still wins.
|
|
944
|
+
const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(
|
|
945
|
+
effective.policies === undefined
|
|
946
|
+
? { ...effective, policies: PLAN_DEFAULT_POLICIES }
|
|
947
|
+
: effective,
|
|
948
|
+
{ pluginsFromCli: cliOverrides.plugins !== undefined },
|
|
949
|
+
);
|
|
950
|
+
|
|
951
|
+
const ignorePaths = parseCsv(effective.ignore);
|
|
952
|
+
const failOn = new Set(parseCsv(effective.failOn ?? PLAN_DEFAULT_FAIL_ON));
|
|
953
|
+
const format = String(effective.format ?? 'human');
|
|
954
|
+
if (!['human', 'json', 'ndjson', 'github-annotations', 'pr-comment'].includes(format)) {
|
|
955
|
+
throw new Error('--format must be human, json, ndjson, github-annotations, or pr-comment');
|
|
956
|
+
}
|
|
957
|
+
const prCommentPost = Boolean(effective.prCommentPost);
|
|
958
|
+
if (prCommentPost && format !== 'pr-comment') {
|
|
959
|
+
renderWarn('Ignoring --pr-comment-post: it only applies to --format pr-comment.');
|
|
960
|
+
}
|
|
961
|
+
const maskSecrets = Boolean(effective.maskSecrets);
|
|
962
|
+
|
|
963
|
+
/** @type {any[]} */
|
|
964
|
+
const results = [];
|
|
965
|
+
let shouldFail = false;
|
|
966
|
+
|
|
967
|
+
for (const planFile of planFiles) {
|
|
968
|
+
const filepath = resolve(planFile);
|
|
969
|
+
if (!existsSync(filepath)) {
|
|
970
|
+
throw new Error(`File not found: ${filepath}`);
|
|
971
|
+
}
|
|
972
|
+
const plan = readTerraformPlanFile(filepath);
|
|
973
|
+
const { changes, summary, formatVersion, terraformVersion, warnings } =
|
|
974
|
+
diffTerraformPlan(plan, { ignorePaths });
|
|
975
|
+
for (const warning of warnings) renderWarn(warning);
|
|
976
|
+
|
|
977
|
+
// Terraform-sensitive values were already replaced during conversion,
|
|
978
|
+
// so policies never see them. --mask-secrets adds Flecto's own
|
|
979
|
+
// value-shaped detection on top, for credentials Terraform did not mark.
|
|
980
|
+
const policyFindings = await evaluatePolicies(changes, {
|
|
981
|
+
cwd: process.cwd(),
|
|
982
|
+
file: filepath,
|
|
983
|
+
profile: profile ?? null,
|
|
984
|
+
source: 'ci',
|
|
985
|
+
policies: packIds,
|
|
986
|
+
plugins,
|
|
987
|
+
severityRemap,
|
|
988
|
+
});
|
|
989
|
+
const outboundChanges = maybeMaskChanges(changes, maskSecrets);
|
|
990
|
+
const envelope = createEnvelope({
|
|
991
|
+
source: 'ci',
|
|
992
|
+
file: filepath,
|
|
993
|
+
changes: outboundChanges,
|
|
994
|
+
policies: maybeMaskFindings(policyFindings, maskSecrets),
|
|
995
|
+
});
|
|
996
|
+
results.push({ file: filepath, envelope, policies: envelope.policies });
|
|
997
|
+
|
|
998
|
+
if (format === 'human') {
|
|
999
|
+
const version = [
|
|
1000
|
+
formatVersion ? `plan format ${formatVersion}` : null,
|
|
1001
|
+
terraformVersion ? `terraform ${terraformVersion}` : null,
|
|
1002
|
+
].filter(Boolean).join(', ');
|
|
1003
|
+
renderInfo(`${filepath}${version ? ` — ${version}` : ''}`);
|
|
1004
|
+
renderInfo(formatPlanSummary(summary));
|
|
1005
|
+
renderDiff(filepath, outboundChanges, { maskSecrets, baseline: 'the current state' });
|
|
1006
|
+
renderPolicyFindings(envelope.policies);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
if (shouldFailFromChanges(changes, failOn) || shouldFailFromPolicy(policyFindings, failOn)) {
|
|
1010
|
+
shouldFail = true;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
if (format === 'pr-comment') {
|
|
1015
|
+
const body = renderPrComment(results, { cwd: process.cwd(), failed: shouldFail });
|
|
1016
|
+
console.log(body);
|
|
1017
|
+
await deliverPrCommentSafely(body, prCommentPost);
|
|
1018
|
+
} else if (format !== 'human') {
|
|
1019
|
+
printCiOutput(results, format);
|
|
1020
|
+
}
|
|
1021
|
+
process.exit(shouldFail ? 1 : 0);
|
|
1022
|
+
} catch (err) {
|
|
1023
|
+
renderError(err.message);
|
|
1024
|
+
process.exit(1);
|
|
1025
|
+
}
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
program
|
|
1029
|
+
.command('compare <fileA> <fileB>')
|
|
1030
|
+
.description('Diff two config files against each other (fileA is the baseline)')
|
|
1031
|
+
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
1032
|
+
.option('--format <type>', 'Output format: human | json | ndjson | github-annotations', 'human')
|
|
1033
|
+
.option('--fail-on <rules>', 'Comma-separated fail rules: changed,added,removed,policy,error,warn', 'changed,added,removed,policy,error')
|
|
1034
|
+
.option('--ignore <keys>', 'Comma-separated key paths to ignore')
|
|
1035
|
+
.option('--policies <ids>', 'Comma-separated policy pack ids')
|
|
1036
|
+
.option('--plugins <paths>', 'Comma-separated local ESM plugin paths')
|
|
1037
|
+
.option('--array-id-key <key>', 'Diff arrays by this object identity key')
|
|
1038
|
+
.option('--no-array-id', 'Diff arrays by index instead of object identity')
|
|
1039
|
+
.option('--array-ignore-order', 'Treat array order as insignificant', false)
|
|
1040
|
+
.option('--mask-secrets', 'Mask secret-like values in output', false)
|
|
1041
|
+
.action(async (fileA, fileB, opts, command) => {
|
|
1042
|
+
try {
|
|
1043
|
+
const { config } = loadRcConfig(process.cwd());
|
|
1044
|
+
const profile = resolveProfileName(opts.profile);
|
|
1045
|
+
const cliOverrides = stripUnsetCliOverrides(opts, command);
|
|
1046
|
+
const effective = resolveEffectiveOptions(config, profile, cliOverrides);
|
|
1047
|
+
const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(effective, { pluginsFromCli: cliOverrides.plugins !== undefined });
|
|
1048
|
+
|
|
1049
|
+
const ignorePaths = parseCsv(effective.ignore);
|
|
1050
|
+
const failOn = parseFailOn(effective.failOn ?? 'changed,added,removed,policy,error');
|
|
1051
|
+
const format = String(effective.format ?? 'human');
|
|
1052
|
+
if (!['human', 'json', 'ndjson', 'github-annotations'].includes(format)) {
|
|
1053
|
+
throw new Error('--format must be human, json, ndjson, or github-annotations');
|
|
1054
|
+
}
|
|
1055
|
+
const maskSecrets = Boolean(effective.maskSecrets);
|
|
1056
|
+
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
1057
|
+
|
|
1058
|
+
const baselinePath = resolve(fileA);
|
|
1059
|
+
const targetPath = resolve(fileB);
|
|
1060
|
+
// Both sides are named explicitly, so a missing one is an error rather
|
|
1061
|
+
// than the skip-and-warn `ci` applies to expanded globs.
|
|
1062
|
+
for (const filepath of [baselinePath, targetPath]) {
|
|
1063
|
+
if (!existsSync(filepath)) {
|
|
1064
|
+
throw new Error(`File not found: ${filepath}`);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// fileA is the baseline: "removed" is present only in fileA, "added" only
|
|
1069
|
+
// in fileB. Every format parses to a plain tree, so the two sides need not
|
|
1070
|
+
// share one — parseFile rejects unsupported extensions with the same
|
|
1071
|
+
// message every other command uses.
|
|
1072
|
+
const before = parseFile(baselinePath);
|
|
1073
|
+
const after = parseFile(targetPath);
|
|
1074
|
+
const events = diffTrees(before, after, dOpts);
|
|
1075
|
+
const policyFindings = await evaluatePolicies(events, {
|
|
1076
|
+
cwd: process.cwd(),
|
|
1077
|
+
file: targetPath,
|
|
1078
|
+
profile: profile ?? null,
|
|
1079
|
+
source: 'diff',
|
|
1080
|
+
policies: packIds,
|
|
1081
|
+
plugins,
|
|
1082
|
+
severityRemap,
|
|
1083
|
+
});
|
|
1084
|
+
const outboundFindings = maybeMaskFindings(policyFindings, events, maskSecrets);
|
|
1085
|
+
|
|
1086
|
+
if (format === 'human') {
|
|
1087
|
+
if (events.length > 0) {
|
|
1088
|
+
renderInfo('"+" exists only in the compared file, "-" only in the baseline, "~" differs');
|
|
1089
|
+
}
|
|
1090
|
+
renderDiff(targetPath, events, { maskSecrets, baseline: baselinePath });
|
|
1091
|
+
renderPolicyFindings(outboundFindings);
|
|
1092
|
+
} else {
|
|
1093
|
+
const envelope = createEnvelope({
|
|
1094
|
+
source: 'diff',
|
|
1095
|
+
file: targetPath,
|
|
1096
|
+
changes: maybeMaskChanges(events, maskSecrets),
|
|
1097
|
+
policies: outboundFindings,
|
|
1098
|
+
});
|
|
1099
|
+
// Same envelope and printer as `ci`, so machine consumers see one shape.
|
|
1100
|
+
// `baseline` rides on the result wrapper rather than the envelope, which
|
|
1101
|
+
// is closed by schemas/flecto-envelope-2.0.json.
|
|
1102
|
+
printCiOutput(
|
|
1103
|
+
[{ file: targetPath, baseline: baselinePath, envelope, policies: outboundFindings }],
|
|
1104
|
+
format,
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
const shouldFail = shouldFailFromChanges(events, failOn)
|
|
1109
|
+
|| shouldFailFromPolicy(policyFindings, failOn);
|
|
633
1110
|
process.exit(shouldFail ? 1 : 0);
|
|
634
1111
|
} catch (err) {
|
|
635
1112
|
renderError(err.message);
|
|
@@ -671,10 +1148,10 @@ program
|
|
|
671
1148
|
}
|
|
672
1149
|
|
|
673
1150
|
console.log('Resolution order: policies/<id>.json, .yaml, .yml, then built-in packs.');
|
|
674
|
-
console.log('id\tsource path\trules\toverrides builtin');
|
|
1151
|
+
console.log('id\tsource path\trules\toverrides builtin\tpackage');
|
|
675
1152
|
for (const pack of packs) {
|
|
676
1153
|
console.log(
|
|
677
|
-
`${pack.id}\t${pack.sourcePath}\t${pack.ruleCount}\t${pack.overridesBuiltin ? 'yes' : 'no'}`,
|
|
1154
|
+
`${pack.id}\t${pack.sourcePath}\t${pack.ruleCount}\t${pack.overridesBuiltin ? 'yes' : 'no'}\t${pack.package ?? '-'}`,
|
|
678
1155
|
);
|
|
679
1156
|
}
|
|
680
1157
|
} catch (err) {
|
|
@@ -682,14 +1159,62 @@ program
|
|
|
682
1159
|
process.exit(1);
|
|
683
1160
|
}
|
|
684
1161
|
});
|
|
1162
|
+
|
|
1163
|
+
policies
|
|
1164
|
+
.command('add <name>')
|
|
1165
|
+
.description('Install a policy pack from an installed flecto-pack-* npm package')
|
|
1166
|
+
.option('--force', 'Overwrite an existing local pack with the same id')
|
|
1167
|
+
.action((name, opts) => {
|
|
1168
|
+
try {
|
|
1169
|
+
const added = addPolicyPackFromPackage(name, {
|
|
1170
|
+
cwd: process.cwd(),
|
|
1171
|
+
force: Boolean(opts.force),
|
|
1172
|
+
});
|
|
1173
|
+
const version = added.packageVersion ? `@${added.packageVersion}` : '';
|
|
1174
|
+
const verb = added.overwritten ? 'Updated' : 'Added';
|
|
1175
|
+
renderInfo(
|
|
1176
|
+
`${verb} policy pack "${added.id}" from ${added.packageName}${version} `
|
|
1177
|
+
+ `→ ${relative(process.cwd(), added.targetPath)} `
|
|
1178
|
+
+ `(${added.ruleCount} rule${added.ruleCount === 1 ? '' : 's'})`,
|
|
1179
|
+
);
|
|
1180
|
+
if (added.overridesBuiltin) {
|
|
1181
|
+
renderWarn(`Pack "${added.id}" now overrides the built-in pack of the same id.`);
|
|
1182
|
+
}
|
|
1183
|
+
for (const path of added.shadowed) {
|
|
1184
|
+
renderWarn(`${relative(process.cwd(), path)} is no longer used: ${added.id}.json wins.`);
|
|
1185
|
+
}
|
|
1186
|
+
if (added.shipsCode) {
|
|
1187
|
+
renderInfo(
|
|
1188
|
+
`${added.packageName} also ships JavaScript. It was ignored: only the declarative `
|
|
1189
|
+
+ 'pack file is read, and no package code is ever imported or run.',
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
renderInfo(`Activate it with: flecto ci <files> --policies ${added.id}`);
|
|
1193
|
+
} catch (err) {
|
|
1194
|
+
renderError(err.message);
|
|
1195
|
+
process.exit(1);
|
|
1196
|
+
}
|
|
1197
|
+
});
|
|
685
1198
|
}
|
|
686
1199
|
|
|
687
1200
|
program
|
|
688
1201
|
.command('init')
|
|
689
|
-
.description('Create starter .flectorc configuration')
|
|
1202
|
+
.description('Create starter .flectorc configuration from detected stack signals')
|
|
690
1203
|
.action(() => {
|
|
691
|
-
const path = initRcFile(process.cwd());
|
|
1204
|
+
const { path, created, detection } = initRcFile(process.cwd());
|
|
1205
|
+
if (!created) {
|
|
1206
|
+
renderWarn(`Config already exists: ${path} (left unchanged)`);
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
692
1209
|
renderInfo(`Initialized config: ${path}`);
|
|
1210
|
+
if (detection.signals.length === 0) {
|
|
1211
|
+
renderInfo('No stack signals detected — wrote the generic starter config.');
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
for (const signal of detection.signals) {
|
|
1215
|
+
renderInfo(signal.summary);
|
|
1216
|
+
}
|
|
1217
|
+
renderInfo(`Policy packs: ${detection.packs.join(', ')}`);
|
|
693
1218
|
});
|
|
694
1219
|
|
|
695
1220
|
program
|