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/src/differ.js
CHANGED
|
@@ -1,14 +1,61 @@
|
|
|
1
|
+
import { annotateEncryptedChanges } from './encrypted.js';
|
|
2
|
+
import { diffDocumentKeys, stripDocumentPrefix } from './documents.js';
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* @typedef {{ type: 'added' | 'removed' | 'changed', path: string, before?: unknown, after?: unknown, note?: string }} ChangeEvent
|
|
3
6
|
*/
|
|
4
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Where an event's path carries a synthetic multi-document prefix, the same
|
|
10
|
+
* path without it. Recorded non-enumerably so the event stays byte-identical
|
|
11
|
+
* everywhere it is serialized — JSON output, webhook payloads, snapshots.
|
|
12
|
+
*/
|
|
13
|
+
const SECRET_MATCH_PATH = Symbol.for('flecto.secretMatchPath');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The path to match secret-looking *key names* against.
|
|
17
|
+
*
|
|
18
|
+
* For an ordinary file this is just `event.path`. For a multi-document file it
|
|
19
|
+
* is the path with the document identity removed, because that identity is a
|
|
20
|
+
* resource name — user data — and a Deployment called `token-service` must not
|
|
21
|
+
* make every value inside it read as a secret. See documents.js.
|
|
22
|
+
* @param {ChangeEvent} event
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
export function secretMatchPath(event) {
|
|
26
|
+
const stripped = /** @type {Record<string | symbol, unknown>} */ (event)?.[SECRET_MATCH_PATH];
|
|
27
|
+
return typeof stripped === 'string' ? stripped : (event?.path ?? '');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {ChangeEvent[]} events
|
|
32
|
+
* @param {readonly string[]} documentKeys
|
|
33
|
+
* @returns {ChangeEvent[]}
|
|
34
|
+
*/
|
|
35
|
+
function tagSecretMatchPaths(events, documentKeys) {
|
|
36
|
+
if (documentKeys.length === 0) return events;
|
|
37
|
+
for (const event of events) {
|
|
38
|
+
const stripped = stripDocumentPrefix(event.path, documentKeys);
|
|
39
|
+
if (stripped === event.path) continue;
|
|
40
|
+
Object.defineProperty(event, SECRET_MATCH_PATH, {
|
|
41
|
+
value: stripped,
|
|
42
|
+
enumerable: false,
|
|
43
|
+
writable: false,
|
|
44
|
+
configurable: true,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return events;
|
|
48
|
+
}
|
|
49
|
+
|
|
5
50
|
/**
|
|
6
51
|
* Checks whether a value is a plain object (not array, not null).
|
|
7
52
|
* @param {unknown} v
|
|
8
53
|
* @returns {v is Record<string, unknown>}
|
|
9
54
|
*/
|
|
10
55
|
function isPlainObject(v) {
|
|
11
|
-
|
|
56
|
+
if (v === null || typeof v !== 'object' || Array.isArray(v)) return false;
|
|
57
|
+
const prototype = Object.getPrototypeOf(v);
|
|
58
|
+
return prototype === Object.prototype || prototype === null;
|
|
12
59
|
}
|
|
13
60
|
|
|
14
61
|
/**
|
|
@@ -61,10 +108,9 @@ function makeIgnoreMatcher(patterns) {
|
|
|
61
108
|
}
|
|
62
109
|
|
|
63
110
|
if (keyAnywhere.size > 0) {
|
|
111
|
+
const pathParts = splitPathParts(path);
|
|
64
112
|
for (const key of keyAnywhere) {
|
|
65
|
-
if (
|
|
66
|
-
if (path.includes(`.${key}`)) return true;
|
|
67
|
-
if (path.includes(`].${key}`)) return true;
|
|
113
|
+
if (pathParts.includes(key)) return true;
|
|
68
114
|
}
|
|
69
115
|
}
|
|
70
116
|
|
|
@@ -139,7 +185,7 @@ function matchParts(patternParts, pathParts) {
|
|
|
139
185
|
* @param {unknown} after
|
|
140
186
|
* @param {string} path
|
|
141
187
|
* @param {ChangeEvent[]} events accumulator
|
|
142
|
-
* @param {{ arrayIdKey?: string | null, arrayIgnoreOrder?: boolean }} [options]
|
|
188
|
+
* @param {{ arrayIdKey?: string | null, arrayIdentity?: boolean, arrayIgnoreOrder?: boolean }} [options]
|
|
143
189
|
*/
|
|
144
190
|
function diffValues(before, after, path, events, options = {}) {
|
|
145
191
|
const beforeIsObj = isPlainObject(before);
|
|
@@ -200,7 +246,7 @@ function diffValues(before, after, path, events, options = {}) {
|
|
|
200
246
|
* @param {Record<string, unknown>} after
|
|
201
247
|
* @param {string} basePath
|
|
202
248
|
* @param {ChangeEvent[]} events
|
|
203
|
-
* @param {{ arrayIdKey?: string | null, arrayIgnoreOrder?: boolean }} [options]
|
|
249
|
+
* @param {{ arrayIdKey?: string | null, arrayIdentity?: boolean, arrayIgnoreOrder?: boolean }} [options]
|
|
204
250
|
*/
|
|
205
251
|
function diffObjects(before, after, basePath, events, options = {}) {
|
|
206
252
|
const beforeKeys = new Set(Object.keys(before));
|
|
@@ -245,43 +291,90 @@ function identityKey(item, idKey) {
|
|
|
245
291
|
}
|
|
246
292
|
|
|
247
293
|
/**
|
|
248
|
-
*
|
|
294
|
+
* @param {unknown[]} items
|
|
295
|
+
* @param {string} idKey
|
|
296
|
+
* @returns {Map<string, { value: unknown, index: number }> | null}
|
|
297
|
+
*/
|
|
298
|
+
function identityMap(items, idKey) {
|
|
299
|
+
/** @type {Map<string, { value: unknown, index: number }>} */
|
|
300
|
+
const map = new Map();
|
|
301
|
+
for (let i = 0; i < items.length; i++) {
|
|
302
|
+
const key = identityKey(items[i], idKey);
|
|
303
|
+
if (key == null || map.has(key)) return null;
|
|
304
|
+
map.set(key, { value: items[i], index: i });
|
|
305
|
+
}
|
|
306
|
+
return map;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Select a configured identity key, or auto-detect id then name.
|
|
311
|
+
* @param {unknown[]} before
|
|
312
|
+
* @param {unknown[]} after
|
|
313
|
+
* @param {{ arrayIdKey?: string | null, arrayIdentity?: boolean }} options
|
|
314
|
+
* @returns {string | null}
|
|
315
|
+
*/
|
|
316
|
+
function resolveArrayIdKey(before, after, options) {
|
|
317
|
+
// An explicit key always enables identity matching, even when arrayIdentity
|
|
318
|
+
// is off (e.g. .flectorc arrayId:false plus CLI --array-id-key).
|
|
319
|
+
const configured = options.arrayIdKey ? String(options.arrayIdKey) : null;
|
|
320
|
+
if (configured) return configured;
|
|
321
|
+
|
|
322
|
+
if (options.arrayIdentity === false) return null;
|
|
323
|
+
|
|
324
|
+
for (const candidate of ['id', 'name']) {
|
|
325
|
+
if (identityMap(before, candidate) && identityMap(after, candidate)) {
|
|
326
|
+
return candidate;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Produce a stable JSON signature for order-insensitive array comparison.
|
|
334
|
+
* Object keys are sorted so their insertion order does not affect equality.
|
|
335
|
+
* Non-JSON values fall back to their identity, avoiding serialization errors.
|
|
336
|
+
* @param {unknown} value
|
|
337
|
+
* @returns {unknown}
|
|
338
|
+
*/
|
|
339
|
+
function arraySignature(value) {
|
|
340
|
+
try {
|
|
341
|
+
const canonicalize = (item) => {
|
|
342
|
+
if (Array.isArray(item)) return item.map(canonicalize);
|
|
343
|
+
if (
|
|
344
|
+
!isPlainObject(item) ||
|
|
345
|
+
(Object.getPrototypeOf(item) !== Object.prototype && Object.getPrototypeOf(item) !== null)
|
|
346
|
+
) {
|
|
347
|
+
return item;
|
|
348
|
+
}
|
|
349
|
+
return Object.fromEntries(
|
|
350
|
+
Object.keys(item)
|
|
351
|
+
.sort()
|
|
352
|
+
.map((key) => [key, canonicalize(item[key])])
|
|
353
|
+
);
|
|
354
|
+
};
|
|
355
|
+
const signature = JSON.stringify(canonicalize(value));
|
|
356
|
+
return signature === undefined ? value : signature;
|
|
357
|
+
} catch {
|
|
358
|
+
return value;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Diff arrays by configured or auto-detected identity key; otherwise by index.
|
|
249
364
|
* @param {unknown[]} before
|
|
250
365
|
* @param {unknown[]} after
|
|
251
366
|
* @param {string} basePath
|
|
252
367
|
* @param {ChangeEvent[]} events
|
|
253
|
-
* @param {{ arrayIdKey?: string | null, arrayIgnoreOrder?: boolean }} [options]
|
|
368
|
+
* @param {{ arrayIdKey?: string | null, arrayIdentity?: boolean, arrayIgnoreOrder?: boolean }} [options]
|
|
254
369
|
*/
|
|
255
370
|
function diffArrays(before, after, basePath, events, options = {}) {
|
|
256
|
-
const idKey =
|
|
371
|
+
const idKey = resolveArrayIdKey(before, after, options);
|
|
257
372
|
|
|
258
373
|
if (idKey) {
|
|
259
|
-
|
|
260
|
-
const
|
|
261
|
-
/** @type {Map<string, { value: unknown, index: number }>} */
|
|
262
|
-
const afterMap = new Map();
|
|
263
|
-
let canUseIdentity = true;
|
|
264
|
-
|
|
265
|
-
for (let i = 0; i < before.length; i++) {
|
|
266
|
-
const key = identityKey(before[i], idKey);
|
|
267
|
-
if (key == null || beforeMap.has(key)) {
|
|
268
|
-
canUseIdentity = false;
|
|
269
|
-
break;
|
|
270
|
-
}
|
|
271
|
-
beforeMap.set(key, { value: before[i], index: i });
|
|
272
|
-
}
|
|
273
|
-
if (canUseIdentity) {
|
|
274
|
-
for (let i = 0; i < after.length; i++) {
|
|
275
|
-
const key = identityKey(after[i], idKey);
|
|
276
|
-
if (key == null || afterMap.has(key)) {
|
|
277
|
-
canUseIdentity = false;
|
|
278
|
-
break;
|
|
279
|
-
}
|
|
280
|
-
afterMap.set(key, { value: after[i], index: i });
|
|
281
|
-
}
|
|
282
|
-
}
|
|
374
|
+
const beforeMap = identityMap(before, idKey);
|
|
375
|
+
const afterMap = identityMap(after, idKey);
|
|
283
376
|
|
|
284
|
-
if (
|
|
377
|
+
if (beforeMap && afterMap) {
|
|
285
378
|
for (const [key, afterItem] of afterMap) {
|
|
286
379
|
const childPath = `${basePath}[${JSON.stringify(key)}]`;
|
|
287
380
|
if (!beforeMap.has(key)) {
|
|
@@ -302,19 +395,22 @@ function diffArrays(before, after, basePath, events, options = {}) {
|
|
|
302
395
|
|
|
303
396
|
if (options.arrayIgnoreOrder) {
|
|
304
397
|
// Order-insensitive without id key: multiset compare via JSON signatures
|
|
305
|
-
|
|
306
|
-
const afterSigs = after.map((v) => JSON.stringify(v));
|
|
307
|
-
/** @type {Map<string, number>} */
|
|
398
|
+
/** @type {Map<unknown, { count: number, value: unknown }>} */
|
|
308
399
|
const counts = new Map();
|
|
309
|
-
for (const
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
if (
|
|
313
|
-
else
|
|
400
|
+
for (const value of before) {
|
|
401
|
+
const signature = arraySignature(value);
|
|
402
|
+
const entry = counts.get(signature);
|
|
403
|
+
if (entry) entry.count++;
|
|
404
|
+
else counts.set(signature, { count: 1, value });
|
|
405
|
+
}
|
|
406
|
+
for (const value of after) {
|
|
407
|
+
const entry = counts.get(arraySignature(value));
|
|
408
|
+
if (entry?.count > 0) entry.count--;
|
|
409
|
+
else events.push({ type: 'added', path: `${basePath}[*]`, after: value });
|
|
314
410
|
}
|
|
315
|
-
for (const
|
|
316
|
-
for (let i = 0; i <
|
|
317
|
-
events.push({ type: 'removed', path: `${basePath}[*]`, before:
|
|
411
|
+
for (const entry of counts.values()) {
|
|
412
|
+
for (let i = 0; i < entry.count; i++) {
|
|
413
|
+
events.push({ type: 'removed', path: `${basePath}[*]`, before: entry.value });
|
|
318
414
|
}
|
|
319
415
|
}
|
|
320
416
|
return;
|
|
@@ -338,7 +434,7 @@ function diffArrays(before, after, basePath, events, options = {}) {
|
|
|
338
434
|
* Accepts any JSON-like values at the root (object/array/scalar/null).
|
|
339
435
|
* @param {unknown} before
|
|
340
436
|
* @param {unknown} after
|
|
341
|
-
* @param {{ ignorePaths?: string[], arrayIdKey?: string | null, arrayIgnoreOrder?: boolean }} [options]
|
|
437
|
+
* @param {{ ignorePaths?: string[], arrayIdKey?: string | null, arrayIdentity?: boolean, arrayIgnoreOrder?: boolean }} [options]
|
|
342
438
|
* @returns {ChangeEvent[]}
|
|
343
439
|
*/
|
|
344
440
|
export function diffTrees(before, after, options = {}) {
|
|
@@ -347,6 +443,7 @@ export function diffTrees(before, after, options = {}) {
|
|
|
347
443
|
const ignore = makeIgnoreMatcher(options.ignorePaths ?? []);
|
|
348
444
|
const diffOpts = {
|
|
349
445
|
arrayIdKey: options.arrayIdKey ?? null,
|
|
446
|
+
arrayIdentity: options.arrayIdentity !== false,
|
|
350
447
|
arrayIgnoreOrder: Boolean(options.arrayIgnoreOrder),
|
|
351
448
|
};
|
|
352
449
|
|
|
@@ -360,5 +457,15 @@ export function diffTrees(before, after, options = {}) {
|
|
|
360
457
|
diffValues(before, after, '<root>', events, diffOpts);
|
|
361
458
|
}
|
|
362
459
|
|
|
363
|
-
|
|
460
|
+
// Encrypted files carry signals a key-by-key walk cannot express: the file
|
|
461
|
+
// gaining or losing encryption, and a MAC that moved on its own. This is a
|
|
462
|
+
// no-op — the same array, untouched — when neither side is encrypted.
|
|
463
|
+
// Ignore patterns are applied afterwards so `--ignore` silences the derived
|
|
464
|
+
// paths exactly like any other.
|
|
465
|
+
const annotated = annotateEncryptedChanges(before, after, events).filter(e => !ignore(e.path));
|
|
466
|
+
|
|
467
|
+
// Multi-document paths carry a resource name in front. Record what secret-name
|
|
468
|
+
// matching should look at, once, here — the renderers see events and nothing
|
|
469
|
+
// else, and a resource name must never decide whether a value gets masked.
|
|
470
|
+
return tagSecretMatchPaths(annotated, diffDocumentKeys(before, after));
|
|
364
471
|
}
|
package/src/documents.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carrying the "this tree is a multi-document wrapper" signal.
|
|
3
|
+
*
|
|
4
|
+
* A `---`-separated YAML file parses to a synthetic object keyed by document
|
|
5
|
+
* identity (see `parseYamlStream`), so every path inside a document gains a
|
|
6
|
+
* `Kind/namespace/name` prefix. Several subsystems need to know which leading
|
|
7
|
+
* path segments are those synthetic keys rather than real configuration keys —
|
|
8
|
+
* most importantly secret-name matching, which must never look at a resource
|
|
9
|
+
* name, because a resource *named* `token-service` is not a secret.
|
|
10
|
+
*
|
|
11
|
+
* The parser is the only thing that can answer this without guessing: it
|
|
12
|
+
* invented the keys. Rather than re-deriving them downstream from the shape of
|
|
13
|
+
* the keys — a heuristic that misfires the first time somebody writes a config
|
|
14
|
+
* whose top-level keys genuinely look like `Kind/ns/name` — the parser records
|
|
15
|
+
* them here and everything else reads them back.
|
|
16
|
+
*
|
|
17
|
+
* The record is a non-enumerable symbol property, so it is invisible to
|
|
18
|
+
* `Object.keys`, `JSON.stringify`, spread, and `assert.deepEqual`: a marked tree
|
|
19
|
+
* is byte-for-byte the same snapshot, diff, and payload it was before. The flip
|
|
20
|
+
* side is that it does not survive a JSON round trip, so a tree read back out of
|
|
21
|
+
* a snapshot file carries the keys only if the snapshot recorded them.
|
|
22
|
+
* `documentKeysOf` distinguishes the two: `[]` means "known not to be a
|
|
23
|
+
* wrapper", `null` means "provenance unknown".
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Where the synthetic document keys are recorded on a parsed tree. */
|
|
27
|
+
const DOCUMENT_KEYS = Symbol.for('flecto.documentKeys');
|
|
28
|
+
|
|
29
|
+
/** @type {readonly string[]} */
|
|
30
|
+
const NONE = Object.freeze([]);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Record the synthetic document keys the parser invented for a tree.
|
|
34
|
+
*
|
|
35
|
+
* Returns the tree itself — marking is a side effect on an object Flecto just
|
|
36
|
+
* created, never a copy. Non-object roots (a scalar YAML document, an opaque
|
|
37
|
+
* age blob) cannot carry the mark and are returned unchanged.
|
|
38
|
+
* @template T
|
|
39
|
+
* @param {T} tree
|
|
40
|
+
* @param {readonly string[] | null | undefined} keys
|
|
41
|
+
* @returns {T}
|
|
42
|
+
*/
|
|
43
|
+
export function withDocumentKeys(tree, keys) {
|
|
44
|
+
if (tree === null || typeof tree !== 'object') return tree;
|
|
45
|
+
Object.defineProperty(tree, DOCUMENT_KEYS, {
|
|
46
|
+
value: keys == null ? null : Object.freeze([...keys]),
|
|
47
|
+
enumerable: false,
|
|
48
|
+
writable: false,
|
|
49
|
+
configurable: true,
|
|
50
|
+
});
|
|
51
|
+
return tree;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The synthetic document keys recorded on a tree.
|
|
56
|
+
* @param {unknown} tree
|
|
57
|
+
* @returns {readonly string[] | null} `[]` when the tree is known to be a single
|
|
58
|
+
* document, `null` when nothing recorded its provenance
|
|
59
|
+
*/
|
|
60
|
+
export function documentKeysOf(tree) {
|
|
61
|
+
if (tree === null || typeof tree !== 'object') return null;
|
|
62
|
+
const keys = /** @type {Record<string | symbol, unknown>} */ (tree)[DOCUMENT_KEYS];
|
|
63
|
+
return Array.isArray(keys) ? /** @type {readonly string[]} */ (keys) : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The document keys covering either side of a diff, longest first.
|
|
68
|
+
*
|
|
69
|
+
* A document present on only one side still prefixes the paths of its own
|
|
70
|
+
* additions or removals, so the union is what a path can start with. Longest
|
|
71
|
+
* first so that if one identity is a prefix of another — `app` and `app.web` —
|
|
72
|
+
* a path is attributed to the more specific one.
|
|
73
|
+
* @param {unknown} before
|
|
74
|
+
* @param {unknown} after
|
|
75
|
+
* @returns {readonly string[]}
|
|
76
|
+
*/
|
|
77
|
+
export function diffDocumentKeys(before, after) {
|
|
78
|
+
const merged = [...(documentKeysOf(before) ?? NONE), ...(documentKeysOf(after) ?? NONE)];
|
|
79
|
+
if (merged.length === 0) return NONE;
|
|
80
|
+
return [...new Set(merged)].sort((a, b) => b.length - a.length);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Drop a leading document-identity segment from a diff path.
|
|
85
|
+
*
|
|
86
|
+
* Matching is by whole segment against the keys the parser actually invented,
|
|
87
|
+
* not by pattern, so `Deployment/prod/token-service.spec.replicas` becomes
|
|
88
|
+
* `spec.replicas` while an ordinary key that merely begins with the same text
|
|
89
|
+
* is left alone. Returns the path unchanged when no key applies, which is the
|
|
90
|
+
* single-document case and therefore the common one.
|
|
91
|
+
* @param {string} path
|
|
92
|
+
* @param {readonly string[] | null | undefined} keys tried in order; see
|
|
93
|
+
* {@link diffDocumentKeys} for why callers pass them longest first
|
|
94
|
+
* @returns {string}
|
|
95
|
+
*/
|
|
96
|
+
export function stripDocumentPrefix(path, keys) {
|
|
97
|
+
if (!keys || keys.length === 0 || !path) return path;
|
|
98
|
+
for (const key of keys) {
|
|
99
|
+
if (!key || !path.startsWith(key)) continue;
|
|
100
|
+
if (path.length === key.length) return '';
|
|
101
|
+
const next = path[key.length];
|
|
102
|
+
if (next === '.') return path.slice(key.length + 1);
|
|
103
|
+
if (next === '[') return path.slice(key.length);
|
|
104
|
+
}
|
|
105
|
+
return path;
|
|
106
|
+
}
|