flecto 2.0.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 +533 -0
- package/README.md +345 -211
- package/index.js +826 -77
- package/package.json +9 -7
- package/schemas/flecto-policy-pack-2.0.json +129 -0
- package/src/alerter.js +24 -6
- package/src/config.js +135 -9
- package/src/differ.js +154 -47
- package/src/documents.js +106 -0
- package/src/encrypted.js +573 -0
- package/src/notifiers.js +430 -0
- package/src/packs/compose.json +45 -0
- package/src/packs/default.json +23 -1
- package/src/packs/kubernetes.json +112 -0
- package/src/packs/node-runtime.json +44 -0
- package/src/packs/sops.json +61 -0
- package/src/packs/strict-prod.json +11 -1
- package/src/packs/terraform.json +120 -0
- package/src/parser.js +189 -20
- package/src/policy-test.js +124 -0
- package/src/policy.js +815 -30
- package/src/pr-comment.js +480 -0
- package/src/renderer.js +75 -18
- package/src/report.js +653 -0
- package/src/secrets.js +316 -0
- package/src/terraform.js +500 -0
- package/src/watcher.js +27 -15
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 } 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,38 @@ 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';
|
|
43
|
+
import { testPolicyFixture } from './src/policy-test.js';
|
|
26
44
|
import {
|
|
27
45
|
loadRcConfig,
|
|
28
46
|
resolveEffectiveOptions,
|
|
@@ -37,6 +55,17 @@ const PKG = JSON.parse(
|
|
|
37
55
|
);
|
|
38
56
|
|
|
39
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';
|
|
40
69
|
|
|
41
70
|
function snapshotIdForPath(absPath) {
|
|
42
71
|
const normalized = absPath.replaceAll('\\', '/');
|
|
@@ -48,12 +77,129 @@ function snapshotPathForFile(absPath) {
|
|
|
48
77
|
return resolve(`${SNAPSHOT_DIR}/${id}.json`);
|
|
49
78
|
}
|
|
50
79
|
|
|
80
|
+
function snapshotHistoryPathForFile(absPath) {
|
|
81
|
+
const id = snapshotIdForPath(absPath);
|
|
82
|
+
let timestamp = Date.now();
|
|
83
|
+
let path = resolve(`${SNAPSHOT_DIR}/${id}.${timestamp}.json`);
|
|
84
|
+
while (existsSync(path)) {
|
|
85
|
+
timestamp += 1;
|
|
86
|
+
path = resolve(`${SNAPSHOT_DIR}/${id}.${timestamp}.json`);
|
|
87
|
+
}
|
|
88
|
+
return path;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
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;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function preserveLegacySnapshotForHistory(absPath, snapshotPath, idsWithHistory) {
|
|
111
|
+
if (!existsSync(snapshotPath) || idsWithHistory.has(snapshotIdForPath(absPath))) return;
|
|
112
|
+
|
|
113
|
+
const legacy = JSON.parse(readFileSync(snapshotPath, 'utf8'));
|
|
114
|
+
writeFileSync(
|
|
115
|
+
snapshotHistoryPathForFile(absPath),
|
|
116
|
+
JSON.stringify({
|
|
117
|
+
file: legacy.file ?? absPath,
|
|
118
|
+
state: legacy.state ?? legacy,
|
|
119
|
+
...(Array.isArray(legacy.documents) ? { documents: legacy.documents } : {}),
|
|
120
|
+
createdAt: legacy.createdAt ?? statSync(snapshotPath).mtime.toISOString(),
|
|
121
|
+
}, null, 2),
|
|
122
|
+
'utf8',
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function readLocalSnapshotHistory() {
|
|
127
|
+
if (!existsSync(SNAPSHOT_DIR)) return [];
|
|
128
|
+
|
|
129
|
+
const entries = readdirSync(SNAPSHOT_DIR, { withFileTypes: true })
|
|
130
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'));
|
|
131
|
+
const historyEntries = entries.filter((entry) => /^[a-f0-9]{16}\.\d+\.json$/.test(entry.name));
|
|
132
|
+
const historyIds = new Set(historyEntries.map((entry) => entry.name.slice(0, 16)));
|
|
133
|
+
const legacyEntries = entries.filter((entry) =>
|
|
134
|
+
/^[a-f0-9]{16}\.json$/.test(entry.name) && !historyIds.has(entry.name.slice(0, 16)));
|
|
135
|
+
const snapshotEntries = [...historyEntries, ...legacyEntries];
|
|
136
|
+
|
|
137
|
+
return snapshotEntries.map((entry) => {
|
|
138
|
+
const path = resolve(SNAPSHOT_DIR, entry.name);
|
|
139
|
+
const snapshot = JSON.parse(readFileSync(path, 'utf8'));
|
|
140
|
+
const state = restoreSnapshotDocumentKeys(snapshot?.state ?? snapshot, snapshot);
|
|
141
|
+
if (typeof snapshot?.file !== 'string') {
|
|
142
|
+
throw new Error(`Invalid snapshot file: ${path}`);
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
file: snapshot.file,
|
|
146
|
+
state,
|
|
147
|
+
createdAt: snapshot.createdAt ?? statSync(path).mtime.toISOString(),
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function summarizeSnapshotHistory(snapshots, limit, diffOpts = {}) {
|
|
153
|
+
const byFile = new Map();
|
|
154
|
+
for (const snapshot of snapshots) {
|
|
155
|
+
const records = byFile.get(snapshot.file) ?? [];
|
|
156
|
+
records.push(snapshot);
|
|
157
|
+
byFile.set(snapshot.file, records);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const summaries = [];
|
|
161
|
+
for (const records of byFile.values()) {
|
|
162
|
+
records.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
|
|
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
|
+
: [];
|
|
171
|
+
summaries.push({
|
|
172
|
+
...records[index],
|
|
173
|
+
previousCreatedAt: previous ? previous.createdAt : null,
|
|
174
|
+
changes,
|
|
175
|
+
changeCount: changes.length,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return summaries
|
|
181
|
+
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
|
182
|
+
.slice(0, limit);
|
|
183
|
+
}
|
|
184
|
+
|
|
51
185
|
function parseCsv(value) {
|
|
52
186
|
if (!value) return [];
|
|
53
187
|
if (Array.isArray(value)) return value;
|
|
54
188
|
return String(value).split(',').map((s) => s.trim()).filter(Boolean);
|
|
55
189
|
}
|
|
56
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
|
+
|
|
57
203
|
function parseHeaders(headerList) {
|
|
58
204
|
const webhookHeaders = {};
|
|
59
205
|
if (!Array.isArray(headerList)) return webhookHeaders;
|
|
@@ -80,30 +226,20 @@ function validateInterval(interval) {
|
|
|
80
226
|
}
|
|
81
227
|
}
|
|
82
228
|
|
|
83
|
-
function stripUnsetCliOverrides(opts) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
'policies',
|
|
88
|
-
'plugins',
|
|
89
|
-
'arrayIdKey',
|
|
90
|
-
'maskSecrets',
|
|
91
|
-
'maskSecretsWebhooks',
|
|
92
|
-
'arrayIgnoreOrder',
|
|
93
|
-
'snapshotRef',
|
|
94
|
-
'ignore',
|
|
95
|
-
]) {
|
|
96
|
-
if (out[key] === undefined || out[key] === false || out[key] === null || out[key] === '') {
|
|
97
|
-
delete out[key];
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
return out;
|
|
229
|
+
function stripUnsetCliOverrides(opts, command) {
|
|
230
|
+
return Object.fromEntries(
|
|
231
|
+
Object.entries(opts).filter(([key]) => command.getOptionValueSource(key) === 'cli'),
|
|
232
|
+
);
|
|
101
233
|
}
|
|
102
234
|
|
|
103
235
|
function diffOptionsFromEffective(effective, ignorePaths) {
|
|
236
|
+
const arrayIdKey = effective.arrayIdKey || null;
|
|
104
237
|
return {
|
|
105
238
|
ignorePaths,
|
|
106
|
-
arrayIdKey
|
|
239
|
+
arrayIdKey,
|
|
240
|
+
// Explicit --array-id-key / arrayIdKey enables identity matching even when
|
|
241
|
+
// .flectorc sets arrayId:false (index escape hatch for auto-detect only).
|
|
242
|
+
arrayIdentity: arrayIdKey ? true : effective.arrayId !== false,
|
|
107
243
|
arrayIgnoreOrder: Boolean(effective.arrayIgnoreOrder),
|
|
108
244
|
};
|
|
109
245
|
}
|
|
@@ -113,6 +249,32 @@ function maybeMaskChanges(events, maskSecrets) {
|
|
|
113
249
|
return events.map(maskChangeEvent);
|
|
114
250
|
}
|
|
115
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
|
+
|
|
116
278
|
async function resolveTargetFiles(cliFiles, rcConfig) {
|
|
117
279
|
if (cliFiles && cliFiles.length > 0) {
|
|
118
280
|
const direct = [];
|
|
@@ -137,14 +299,62 @@ async function resolveTargetFiles(cliFiles, rcConfig) {
|
|
|
137
299
|
|
|
138
300
|
return resolveFiles({
|
|
139
301
|
cwd: process.cwd(),
|
|
140
|
-
files: rcConfig?.files ??
|
|
302
|
+
files: rcConfig?.files ?? [],
|
|
303
|
+
include: rcConfig?.include ?? [],
|
|
141
304
|
exclude: rcConfig?.exclude ?? [],
|
|
142
305
|
});
|
|
143
306
|
}
|
|
144
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
|
+
|
|
145
323
|
function readSnapshotStateFromFile(snapshotPath) {
|
|
146
324
|
const snap = JSON.parse(readFileSync(snapshotPath, 'utf8'));
|
|
147
|
-
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
|
+
}
|
|
148
358
|
}
|
|
149
359
|
|
|
150
360
|
function readSnapshotStateFromRef(filePath, snapshotRef) {
|
|
@@ -154,7 +364,7 @@ function readSnapshotStateFromRef(filePath, snapshotRef) {
|
|
|
154
364
|
return readSnapshotStateFromFile(maybePath);
|
|
155
365
|
}
|
|
156
366
|
|
|
157
|
-
const rel =
|
|
367
|
+
const rel = gitRepoRelativePath(filePath);
|
|
158
368
|
const raw = execFileSync('git', ['show', `${snapshotRef}:${rel}`], { encoding: 'utf8' });
|
|
159
369
|
return parseContent(filePath, raw);
|
|
160
370
|
}
|
|
@@ -174,6 +384,19 @@ function shouldFailFromChanges(events, failOn) {
|
|
|
174
384
|
return false;
|
|
175
385
|
}
|
|
176
386
|
|
|
387
|
+
function escapeWorkflowCommandData(value) {
|
|
388
|
+
return String(value)
|
|
389
|
+
.replaceAll('%', '%25')
|
|
390
|
+
.replaceAll('\r', '%0D')
|
|
391
|
+
.replaceAll('\n', '%0A');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function escapeWorkflowCommandProperty(value) {
|
|
395
|
+
return escapeWorkflowCommandData(value)
|
|
396
|
+
.replaceAll(':', '%3A')
|
|
397
|
+
.replaceAll(',', '%2C');
|
|
398
|
+
}
|
|
399
|
+
|
|
177
400
|
function printCiOutput(results, format) {
|
|
178
401
|
if (format === 'json') {
|
|
179
402
|
console.log(JSON.stringify(results, null, 2));
|
|
@@ -190,18 +413,39 @@ function printCiOutput(results, format) {
|
|
|
190
413
|
for (const event of result.envelope.changes) {
|
|
191
414
|
const title = `flecto ${event.type}`;
|
|
192
415
|
const detail = event.note ? `${event.path} (${event.note})` : event.path;
|
|
193
|
-
console.log(`::warning file=${result.file},title=${title}::${detail}`);
|
|
416
|
+
console.log(`::warning file=${escapeWorkflowCommandProperty(result.file)},title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandData(detail)}`);
|
|
194
417
|
}
|
|
195
418
|
for (const finding of result.policies) {
|
|
196
419
|
const level = finding.severity === 'error' ? 'error' : 'warning';
|
|
197
420
|
const pack = finding.pack ? ` [${finding.pack}]` : '';
|
|
198
421
|
const title = `flecto policy ${finding.id}${pack}`;
|
|
199
|
-
|
|
422
|
+
const detail = `${finding.path}: ${finding.message}`;
|
|
423
|
+
console.log(`::${level} file=${escapeWorkflowCommandProperty(result.file)},title=${escapeWorkflowCommandProperty(title)}::${escapeWorkflowCommandData(detail)}`);
|
|
200
424
|
}
|
|
201
425
|
}
|
|
202
426
|
}
|
|
203
427
|
}
|
|
204
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
|
+
|
|
205
449
|
program
|
|
206
450
|
.name('flecto')
|
|
207
451
|
.description('Flecto — semantic config watcher for meaningful structured file changes')
|
|
@@ -220,6 +464,7 @@ program
|
|
|
220
464
|
acc.push(v);
|
|
221
465
|
return acc;
|
|
222
466
|
}, [])
|
|
467
|
+
.option('--webhook-format <service>', `Webhook payload format: ${WEBHOOK_FORMAT_CHOICES.join(' | ')}`)
|
|
223
468
|
.option('--delivery-mode <mode>', 'Alert delivery mode: best-effort | at-least-once', 'best-effort')
|
|
224
469
|
.option('--on-alert-failure <mode>', 'Alert failure behavior: warn | exit | retry', 'warn')
|
|
225
470
|
.option('--webhook-timeout <ms>', 'Webhook timeout in ms', '5000')
|
|
@@ -227,18 +472,20 @@ program
|
|
|
227
472
|
.option('--ignore <keys>', 'Comma-separated key paths to ignore (e.g. "updated_at,meta.ts")')
|
|
228
473
|
.option('--policies <ids>', 'Comma-separated policy pack ids (default: default)')
|
|
229
474
|
.option('--plugins <paths>', 'Comma-separated local ESM plugin paths')
|
|
230
|
-
.option('--array-id-key <key>', 'Diff arrays by this object identity key
|
|
475
|
+
.option('--array-id-key <key>', 'Diff arrays by this object identity key')
|
|
476
|
+
.option('--no-array-id', 'Diff arrays by index instead of object identity')
|
|
231
477
|
.option('--array-ignore-order', 'Treat array order as insignificant', false)
|
|
232
478
|
.option('--mask-secrets', 'Mask secret-like values in human output', false)
|
|
233
479
|
.option('--mask-secrets-webhooks', 'Also mask secrets in webhook payloads', false)
|
|
234
480
|
.option('--snapshot', 'Save current state as baseline instead of watching')
|
|
235
481
|
.option('--diff', 'Diff current file against saved baseline and exit')
|
|
236
|
-
.
|
|
482
|
+
.option('--allow-empty', 'Allow --snapshot to succeed when nothing was written', false)
|
|
483
|
+
.action(async (files, opts, command) => {
|
|
237
484
|
try {
|
|
238
485
|
const { config } = loadRcConfig(process.cwd());
|
|
239
486
|
const profile = resolveProfileName(opts.profile);
|
|
240
|
-
const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts));
|
|
241
|
-
const { policies, plugins } = resolvePolicyOptions(effective);
|
|
487
|
+
const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts, command));
|
|
488
|
+
const { policies, plugins, severityRemap } = resolvePolicyOptions(effective);
|
|
242
489
|
const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
|
|
243
490
|
if (targets.length === 0) {
|
|
244
491
|
throw new Error('No files matched. Provide files or configure .flectorc files/include.');
|
|
@@ -252,16 +499,47 @@ program
|
|
|
252
499
|
validateMode(mode);
|
|
253
500
|
const maskSecrets = Boolean(effective.maskSecrets);
|
|
254
501
|
const maskSecretsWebhooks = Boolean(effective.maskSecretsWebhooks);
|
|
502
|
+
const webhookFormat = resolveWebhookFormat(effective.webhookFormat, effective.webhook);
|
|
255
503
|
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
256
504
|
|
|
257
505
|
if (effective.snapshot) {
|
|
258
506
|
mkdirSync(SNAPSHOT_DIR, { recursive: true });
|
|
507
|
+
const idsWithHistory = snapshotIdsWithHistory();
|
|
508
|
+
let written = 0;
|
|
259
509
|
for (const filepath of targets) {
|
|
260
|
-
if (!existsSync(filepath)
|
|
510
|
+
if (!existsSync(filepath)) {
|
|
511
|
+
renderWarn(`Skipping missing file: ${filepath}`);
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
if (!isSupported(filepath)) {
|
|
515
|
+
renderWarn(`Skipping unsupported file: ${filepath}`);
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
261
518
|
const state = parseFile(filepath);
|
|
262
519
|
const snapshotPath = snapshotPathForFile(filepath);
|
|
263
|
-
|
|
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
|
+
};
|
|
530
|
+
writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2), 'utf8');
|
|
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));
|
|
264
535
|
console.log(chalk.green(`✓ Snapshot saved: ${snapshotPath}`));
|
|
536
|
+
written += 1;
|
|
537
|
+
}
|
|
538
|
+
if (written === 0 && !effective.allowEmpty) {
|
|
539
|
+
throw new Error(
|
|
540
|
+
'No snapshots written — all targets were missing or unsupported.' +
|
|
541
|
+
' Pass --allow-empty to allow an empty snapshot run.',
|
|
542
|
+
);
|
|
265
543
|
}
|
|
266
544
|
return;
|
|
267
545
|
}
|
|
@@ -284,6 +562,33 @@ program
|
|
|
284
562
|
}
|
|
285
563
|
|
|
286
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
|
+
|
|
287
592
|
for (const filepath of targets) {
|
|
288
593
|
if (!existsSync(filepath)) {
|
|
289
594
|
renderWarn(`Skipping missing file: ${filepath}`);
|
|
@@ -310,29 +615,26 @@ program
|
|
|
310
615
|
source: 'watch',
|
|
311
616
|
policies,
|
|
312
617
|
plugins,
|
|
618
|
+
severityRemap,
|
|
313
619
|
});
|
|
314
620
|
} catch (err) {
|
|
315
621
|
renderError(`policy evaluation failed: ${err.message}`);
|
|
316
|
-
|
|
622
|
+
process.exit(1);
|
|
317
623
|
}
|
|
318
|
-
renderPolicyFindings(policyFindings);
|
|
624
|
+
renderPolicyFindings(maybeMaskFindings(policyFindings, event.events, maskSecrets));
|
|
319
625
|
if (effective.command || effective.webhook) {
|
|
320
626
|
const outboundChanges = maybeMaskChanges(event.events, maskSecretsWebhooks);
|
|
321
627
|
const envelope = createEnvelope({
|
|
322
628
|
source: 'watch',
|
|
323
629
|
file: event.filepath,
|
|
324
630
|
changes: outboundChanges,
|
|
325
|
-
policies:
|
|
631
|
+
policies: maybeMaskFindings(
|
|
632
|
+
policyFindings,
|
|
633
|
+
event.events,
|
|
634
|
+
maskSecretsWebhooks,
|
|
635
|
+
),
|
|
326
636
|
});
|
|
327
|
-
await
|
|
328
|
-
command: effective.command,
|
|
329
|
-
webhook: effective.webhook,
|
|
330
|
-
webhookHeaders,
|
|
331
|
-
webhookTimeoutMs: parseInt(String(effective.webhookTimeout ?? '5000'), 10),
|
|
332
|
-
webhookRetries: parseInt(String(effective.webhookRetries ?? '2'), 10),
|
|
333
|
-
deliveryMode: effective.deliveryMode,
|
|
334
|
-
onAlertFailure: effective.onAlertFailure,
|
|
335
|
-
}, envelope);
|
|
637
|
+
await deliverAlert(envelope);
|
|
336
638
|
}
|
|
337
639
|
} else {
|
|
338
640
|
renderInfo(`[lifecycle] ${event.filepath}: ${event.lifecycle.type} - ${event.lifecycle.message}`);
|
|
@@ -342,15 +644,7 @@ program
|
|
|
342
644
|
file: event.filepath,
|
|
343
645
|
lifecycle: event.lifecycle,
|
|
344
646
|
});
|
|
345
|
-
await
|
|
346
|
-
command: effective.command,
|
|
347
|
-
webhook: effective.webhook,
|
|
348
|
-
webhookHeaders,
|
|
349
|
-
webhookTimeoutMs: parseInt(String(effective.webhookTimeout ?? '5000'), 10),
|
|
350
|
-
webhookRetries: parseInt(String(effective.webhookRetries ?? '2'), 10),
|
|
351
|
-
deliveryMode: effective.deliveryMode,
|
|
352
|
-
onAlertFailure: effective.onAlertFailure,
|
|
353
|
-
}, envelope);
|
|
647
|
+
await deliverAlert(envelope);
|
|
354
648
|
}
|
|
355
649
|
}
|
|
356
650
|
}
|
|
@@ -363,13 +657,6 @@ program
|
|
|
363
657
|
}
|
|
364
658
|
renderInfo('Press Ctrl+C to stop.\n');
|
|
365
659
|
|
|
366
|
-
const closeAll = async (exitCode) => {
|
|
367
|
-
await Promise.all(watchers.map((w) => w.close()));
|
|
368
|
-
if (exitCode === 0) {
|
|
369
|
-
console.log(chalk.dim('\nflecto stopped.'));
|
|
370
|
-
}
|
|
371
|
-
process.exit(exitCode);
|
|
372
|
-
};
|
|
373
660
|
process.on('SIGINT', () => void closeAll(0));
|
|
374
661
|
process.on('SIGTERM', () => void closeAll(0));
|
|
375
662
|
} catch (err) {
|
|
@@ -378,35 +665,183 @@ program
|
|
|
378
665
|
}
|
|
379
666
|
});
|
|
380
667
|
|
|
668
|
+
program
|
|
669
|
+
.command('history [files...]')
|
|
670
|
+
.description('Summarize drift across local snapshots')
|
|
671
|
+
.option('-l, --limit <n>', 'Number of recent snapshots to show', '10')
|
|
672
|
+
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
673
|
+
.option('--ignore <keys>', 'Comma-separated key paths to ignore (e.g. "updated_at,meta.ts")')
|
|
674
|
+
.option('--array-id-key <key>', 'Diff arrays by this object identity key (opt-in)')
|
|
675
|
+
.option('--array-ignore-order', 'Treat array order as insignificant', false)
|
|
676
|
+
.action(async (files, opts, command) => {
|
|
677
|
+
try {
|
|
678
|
+
const limit = Number.parseInt(String(opts.limit), 10);
|
|
679
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
680
|
+
throw new Error('--limit must be a positive integer');
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const { config } = loadRcConfig(process.cwd());
|
|
684
|
+
const profile = resolveProfileName(opts.profile);
|
|
685
|
+
const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts, command));
|
|
686
|
+
const ignorePaths = parseCsv(effective.ignore);
|
|
687
|
+
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
688
|
+
|
|
689
|
+
const allSnapshots = readLocalSnapshotHistory();
|
|
690
|
+
let snapshots = allSnapshots;
|
|
691
|
+
if (files.length > 0) {
|
|
692
|
+
const targets = new Set((await resolveTargetFiles(files, config)).map((file) => resolve(file)));
|
|
693
|
+
snapshots = snapshots.filter((snapshot) => targets.has(resolve(snapshot.file)));
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const summaries = summarizeSnapshotHistory(snapshots, limit, dOpts);
|
|
697
|
+
if (summaries.length === 0) {
|
|
698
|
+
if (files.length > 0 && allSnapshots.length > 0) {
|
|
699
|
+
throw new Error(
|
|
700
|
+
'No local snapshots matched the given files. Omit files to view all saved snapshot history.',
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
throw new Error('No local snapshots found. Run "flecto watch <file> --snapshot" first.');
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
console.log(`Local snapshot history (${summaries.length} snapshots)`);
|
|
707
|
+
for (const snapshot of summaries) {
|
|
708
|
+
const file = relative(process.cwd(), snapshot.file) || snapshot.file;
|
|
709
|
+
const changes = `${snapshot.changeCount} change${snapshot.changeCount === 1 ? '' : 's'}`;
|
|
710
|
+
console.log(`${snapshot.createdAt} ${file} — ${changes}`);
|
|
711
|
+
}
|
|
712
|
+
} catch (err) {
|
|
713
|
+
renderError(err.message);
|
|
714
|
+
process.exit(1);
|
|
715
|
+
}
|
|
716
|
+
});
|
|
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
|
+
|
|
381
809
|
program
|
|
382
810
|
.command('ci [files...]')
|
|
383
811
|
.description('Run semantic diff in CI mode')
|
|
384
812
|
.option('-p, --profile <name>', 'Use profile from .flectorc (else FLECTO_PROFILE)')
|
|
385
813
|
.option('--snapshot-ref <ref>', 'Snapshot reference: snapshot path or git ref')
|
|
386
|
-
.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)
|
|
387
816
|
.option('--fail-on <rules>', 'Comma-separated fail rules: changed,added,removed,policy,error,warn', 'changed,policy,error')
|
|
388
817
|
.option('--ignore <keys>', 'Comma-separated key paths to ignore')
|
|
389
818
|
.option('--policies <ids>', 'Comma-separated policy pack ids')
|
|
390
819
|
.option('--plugins <paths>', 'Comma-separated local ESM plugin paths')
|
|
391
|
-
.option('--array-id-key <key>', 'Diff arrays by this object identity key
|
|
820
|
+
.option('--array-id-key <key>', 'Diff arrays by this object identity key')
|
|
821
|
+
.option('--no-array-id', 'Diff arrays by index instead of object identity')
|
|
392
822
|
.option('--array-ignore-order', 'Treat array order as insignificant', false)
|
|
393
823
|
.option('--mask-secrets', 'Mask secret-like values in CI output', false)
|
|
394
|
-
.
|
|
824
|
+
.option('--allow-empty', 'Allow CI to succeed when no files were diffed', false)
|
|
825
|
+
.action(async (files, opts, command) => {
|
|
395
826
|
try {
|
|
396
827
|
const { config } = loadRcConfig(process.cwd());
|
|
397
828
|
const profile = resolveProfileName(opts.profile);
|
|
398
|
-
const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts));
|
|
399
|
-
const { policies: packIds, plugins } = resolvePolicyOptions(effective);
|
|
829
|
+
const effective = resolveEffectiveOptions(config, profile, stripUnsetCliOverrides(opts, command));
|
|
830
|
+
const { policies: packIds, plugins, severityRemap } = resolvePolicyOptions(effective);
|
|
400
831
|
const targets = (await resolveTargetFiles(files, config)).map((f) => resolve(f));
|
|
401
832
|
if (targets.length === 0) {
|
|
402
833
|
throw new Error('No files matched. Provide files or configure .flectorc files/include.');
|
|
403
834
|
}
|
|
404
835
|
|
|
405
836
|
const ignorePaths = parseCsv(effective.ignore);
|
|
406
|
-
const failOn =
|
|
837
|
+
const failOn = parseFailOn(effective.failOn ?? 'changed,policy,error');
|
|
407
838
|
const format = String(effective.format ?? 'json');
|
|
408
|
-
if (!['json', 'ndjson', 'github-annotations'].includes(format)) {
|
|
409
|
-
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.');
|
|
410
845
|
}
|
|
411
846
|
const maskSecrets = Boolean(effective.maskSecrets);
|
|
412
847
|
const dOpts = diffOptionsFromEffective(effective, ignorePaths);
|
|
@@ -414,9 +849,17 @@ program
|
|
|
414
849
|
/** @type {any[]} */
|
|
415
850
|
const results = [];
|
|
416
851
|
let shouldFail = false;
|
|
852
|
+
let diffed = 0;
|
|
417
853
|
|
|
418
854
|
for (const filepath of targets) {
|
|
419
|
-
if (!existsSync(filepath)
|
|
855
|
+
if (!existsSync(filepath)) {
|
|
856
|
+
renderWarn(`Skipping missing file: ${filepath}`);
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
if (!isSupported(filepath)) {
|
|
860
|
+
renderWarn(`Skipping unsupported file: ${filepath}`);
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
420
863
|
const after = parseFile(filepath);
|
|
421
864
|
let before;
|
|
422
865
|
try {
|
|
@@ -435,22 +878,38 @@ program
|
|
|
435
878
|
source: 'ci',
|
|
436
879
|
policies: packIds,
|
|
437
880
|
plugins,
|
|
881
|
+
severityRemap,
|
|
438
882
|
});
|
|
439
883
|
const outboundChanges = maybeMaskChanges(events, maskSecrets);
|
|
884
|
+
const outboundFindings = maybeMaskFindings(policyFindings, events, maskSecrets);
|
|
440
885
|
const envelope = createEnvelope({
|
|
441
886
|
source: 'ci',
|
|
442
887
|
file: filepath,
|
|
443
888
|
changes: outboundChanges,
|
|
444
|
-
policies:
|
|
889
|
+
policies: outboundFindings,
|
|
445
890
|
});
|
|
446
|
-
results.push({ file: filepath, envelope, policies:
|
|
891
|
+
results.push({ file: filepath, envelope, policies: outboundFindings });
|
|
892
|
+
diffed += 1;
|
|
447
893
|
|
|
448
894
|
if (shouldFailFromChanges(events, failOn) || shouldFailFromPolicy(policyFindings, failOn)) {
|
|
449
895
|
shouldFail = true;
|
|
450
896
|
}
|
|
451
897
|
}
|
|
452
898
|
|
|
453
|
-
|
|
899
|
+
if (diffed === 0 && !effective.allowEmpty) {
|
|
900
|
+
throw new Error(
|
|
901
|
+
'No files were diffed — all targets were missing or unsupported.' +
|
|
902
|
+
' Pass --allow-empty to allow an empty CI run.',
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
|
|
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
|
+
}
|
|
454
913
|
process.exit(shouldFail ? 1 : 0);
|
|
455
914
|
} catch (err) {
|
|
456
915
|
renderError(err.message);
|
|
@@ -458,12 +917,297 @@ program
|
|
|
458
917
|
}
|
|
459
918
|
});
|
|
460
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);
|
|
1103
|
+
process.exit(shouldFail ? 1 : 0);
|
|
1104
|
+
} catch (err) {
|
|
1105
|
+
renderError(err.message);
|
|
1106
|
+
process.exit(1);
|
|
1107
|
+
}
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
{
|
|
1111
|
+
const policies = program
|
|
1112
|
+
.command('policies')
|
|
1113
|
+
.description('Work with policy packs and plugins');
|
|
1114
|
+
|
|
1115
|
+
policies
|
|
1116
|
+
.command('test <fixtureDir>')
|
|
1117
|
+
.description('Assert policy findings from a fixture directory')
|
|
1118
|
+
.option('--config <name>', 'Fixture config file name', 'flecto-policy-test.json')
|
|
1119
|
+
.action(async (fixtureDir, opts) => {
|
|
1120
|
+
try {
|
|
1121
|
+
const result = await testPolicyFixture(fixtureDir, { configName: opts.config });
|
|
1122
|
+
console.log(chalk.green(
|
|
1123
|
+
`✓ Policy fixture passed: ${result.fixtureDir} (${result.findings.length} findings)`,
|
|
1124
|
+
));
|
|
1125
|
+
} catch (err) {
|
|
1126
|
+
renderError(err.message);
|
|
1127
|
+
process.exitCode = 1;
|
|
1128
|
+
}
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
policies
|
|
1132
|
+
.command('list')
|
|
1133
|
+
.description('List built-in and local policy packs')
|
|
1134
|
+
.option('--json', 'Output machine-readable JSON')
|
|
1135
|
+
.action((opts) => {
|
|
1136
|
+
try {
|
|
1137
|
+
const packs = listPolicyPacks(process.cwd());
|
|
1138
|
+
if (opts.json) {
|
|
1139
|
+
console.log(JSON.stringify(packs, null, 2));
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
console.log('Resolution order: policies/<id>.json, .yaml, .yml, then built-in packs.');
|
|
1144
|
+
console.log('id\tsource path\trules\toverrides builtin\tpackage');
|
|
1145
|
+
for (const pack of packs) {
|
|
1146
|
+
console.log(
|
|
1147
|
+
`${pack.id}\t${pack.sourcePath}\t${pack.ruleCount}\t${pack.overridesBuiltin ? 'yes' : 'no'}\t${pack.package ?? '-'}`,
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
} catch (err) {
|
|
1151
|
+
renderError(err.message);
|
|
1152
|
+
process.exit(1);
|
|
1153
|
+
}
|
|
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
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
|
|
461
1193
|
program
|
|
462
1194
|
.command('init')
|
|
463
|
-
.description('Create starter .flectorc configuration')
|
|
1195
|
+
.description('Create starter .flectorc configuration from detected stack signals')
|
|
464
1196
|
.action(() => {
|
|
465
|
-
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
|
+
}
|
|
466
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(', ')}`);
|
|
467
1211
|
});
|
|
468
1212
|
|
|
469
1213
|
program
|
|
@@ -485,8 +1229,13 @@ program
|
|
|
485
1229
|
exclude: config?.exclude ?? [],
|
|
486
1230
|
});
|
|
487
1231
|
renderInfo(`resolved files: ${files.length}`);
|
|
1232
|
+
const [major, minor] = process.versions.node.split('.').map(Number);
|
|
1233
|
+
if (major < 20 || (major === 20 && minor < 19)) {
|
|
1234
|
+
throw new Error(`Node.js ${process.versions.node} is unsupported. Use Node.js >= 20.19.0.`);
|
|
1235
|
+
}
|
|
1236
|
+
renderInfo(`node: ${process.versions.node}`);
|
|
488
1237
|
if (typeof fetch !== 'function') {
|
|
489
|
-
throw new Error('Global fetch unavailable. Use Node.js >=
|
|
1238
|
+
throw new Error('Global fetch unavailable. Use Node.js >= 20.19.0.');
|
|
490
1239
|
}
|
|
491
1240
|
renderInfo('fetch: available');
|
|
492
1241
|
renderInfo(`version: ${PKG.version}`);
|
|
@@ -497,7 +1246,7 @@ program
|
|
|
497
1246
|
}
|
|
498
1247
|
});
|
|
499
1248
|
|
|
500
|
-
program.
|
|
1249
|
+
await program.parseAsync(process.argv);
|
|
501
1250
|
|
|
502
1251
|
if (!process.argv.slice(2).length) {
|
|
503
1252
|
program.help();
|