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/src/encrypted.js
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural awareness of SOPS- and age-encrypted files.
|
|
3
|
+
*
|
|
4
|
+
* Flecto **never decrypts**. It never shells out to `sops` or `age`, never
|
|
5
|
+
* reads a key file, never touches an agent socket, and never sends ciphertext
|
|
6
|
+
* anywhere. That is the whole security proposition: pointing Flecto at an
|
|
7
|
+
* encrypted file must be exactly as safe as pointing `git diff` at it, minus
|
|
8
|
+
* the ciphertext noise.
|
|
9
|
+
*
|
|
10
|
+
* What it does instead is treat the *shape* of the file as the signal. An
|
|
11
|
+
* encrypted config still tells you a great deal without being opened:
|
|
12
|
+
*
|
|
13
|
+
* - which keys exist (added / removed / moved)
|
|
14
|
+
* - which encrypted values changed, and which did not
|
|
15
|
+
* - who can decrypt it — the recipient list in the `sops` metadata block
|
|
16
|
+
* - whether the file is still encrypted at all
|
|
17
|
+
*
|
|
18
|
+
* The mechanism is a normalization pass applied at parse time, before any
|
|
19
|
+
* other part of Flecto sees the tree. Every ciphertext-bearing string is
|
|
20
|
+
* replaced with an opaque sentinel, `<encrypted:SCHEME:DIGEST>`, where DIGEST
|
|
21
|
+
* is a truncated SHA-256 of the ciphertext. Because the substitution happens in
|
|
22
|
+
* the parser, ciphertext cannot reach a diff, a snapshot file, a webhook body,
|
|
23
|
+
* a PR comment, or an HTML report — there is no code path that carries it. The
|
|
24
|
+
* digest is what makes "this encrypted value changed" observable without
|
|
25
|
+
* revealing the value or even its length.
|
|
26
|
+
*
|
|
27
|
+
* Detection is content-based. Filenames are a hint at best: teams commit
|
|
28
|
+
* `secrets.yaml`, `values.prod.yaml`, and `db.json` that are fully encrypted,
|
|
29
|
+
* and `.sops.yaml` — which is the *creation-rules config*, not an encrypted
|
|
30
|
+
* file at all.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { createHash } from 'crypto';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {'sops' | 'age' | 'pgp'} EncryptionScheme
|
|
37
|
+
* @typedef {EncryptionScheme | 'plaintext'} EncryptionState
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** Synthetic diff path carrying the file-level encryption state. */
|
|
41
|
+
export const ENCRYPTION_STATE_PATH = '<encryption>';
|
|
42
|
+
|
|
43
|
+
/** Synthetic diff path for the "MAC moved on its own" integrity signal. */
|
|
44
|
+
export const ENCRYPTION_MAC_PATH = '<encryption.mac>';
|
|
45
|
+
|
|
46
|
+
/** What an encrypted value collapses to in human-facing output. */
|
|
47
|
+
export const ENCRYPTED_DISPLAY = '<encrypted value>';
|
|
48
|
+
|
|
49
|
+
/** What a value that used to be encrypted collapses to once it is not. */
|
|
50
|
+
export const DECRYPTED_DISPLAY = '<no longer encrypted>';
|
|
51
|
+
|
|
52
|
+
/** Long enough that two versions of one value practically never collide. */
|
|
53
|
+
const DIGEST_CHARS = 12;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A SOPS-encrypted leaf, e.g.
|
|
57
|
+
* `ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]`. The cipher name and the
|
|
58
|
+
* `data:` field are both required, which is specific enough that a hand-written
|
|
59
|
+
* config value has essentially no chance of matching.
|
|
60
|
+
*/
|
|
61
|
+
const SOPS_ENC_TOKEN_RE = /^ENC\[[A-Z0-9_]+,data:[^\]]*\]$/;
|
|
62
|
+
|
|
63
|
+
/** Armored age blob — a whole file, or a wrapped data key inside `sops.age`. */
|
|
64
|
+
const AGE_ARMOR_HEADER = '-----BEGIN AGE ENCRYPTED FILE-----';
|
|
65
|
+
|
|
66
|
+
/** Armored PGP blob — the wrapped data key inside `sops.pgp`. */
|
|
67
|
+
const PGP_MESSAGE_HEADER = '-----BEGIN PGP MESSAGE-----';
|
|
68
|
+
|
|
69
|
+
/** Matches exactly what `sentinel()` produces, and nothing else. */
|
|
70
|
+
const SENTINEL_RE = /^<encrypted:(sops|age|pgp):[0-9a-f]{12}>$/;
|
|
71
|
+
|
|
72
|
+
/** Key-provider groups SOPS writes into its metadata block, in its own order. */
|
|
73
|
+
const RECIPIENT_GROUPS = ['kms', 'gcp_kms', 'azure_kv', 'hc_vault', 'age', 'pgp'];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The fields that identify a recipient within its group. Every one of these is
|
|
77
|
+
* a public identifier — a key ARN, an age public key, a PGP fingerprint — so
|
|
78
|
+
* they stay visible in the diff. They are the answer to "who can read this?",
|
|
79
|
+
* which is the single most useful thing an encrypted file can tell you.
|
|
80
|
+
* @type {Record<string, string[]>}
|
|
81
|
+
*/
|
|
82
|
+
const RECIPIENT_ID_FIELDS = {
|
|
83
|
+
kms: ['arn'],
|
|
84
|
+
gcp_kms: ['resource_id'],
|
|
85
|
+
azure_kv: ['vault_url', 'name', 'version'],
|
|
86
|
+
hc_vault: ['vault_address', 'engine_path', 'key_name'],
|
|
87
|
+
age: ['recipient'],
|
|
88
|
+
pgp: ['fp'],
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Paths whose change means "the SOPS MAC moved", relative to the document that
|
|
93
|
+
* owns the metadata block — the file root for an ordinary file, one document
|
|
94
|
+
* down for a multi-document one.
|
|
95
|
+
*/
|
|
96
|
+
const MAC_PATHS = new Set(['sops.mac', 'sops_mac']);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {unknown} value
|
|
100
|
+
* @returns {value is Record<string, unknown>}
|
|
101
|
+
*/
|
|
102
|
+
function isPlainObject(value) {
|
|
103
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
104
|
+
const proto = Object.getPrototypeOf(value);
|
|
105
|
+
return proto === Object.prototype || proto === null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build the opaque stand-in for one ciphertext.
|
|
110
|
+
* @param {EncryptionScheme} scheme
|
|
111
|
+
* @param {string} ciphertext
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
function sentinel(scheme, ciphertext) {
|
|
115
|
+
const digest = createHash('sha256').update(String(ciphertext), 'utf8').digest('hex');
|
|
116
|
+
return `<encrypted:${scheme}:${digest.slice(0, DIGEST_CHARS)}>`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* True when a value is one of Flecto's encrypted-value sentinels.
|
|
121
|
+
* @param {unknown} value
|
|
122
|
+
* @returns {boolean}
|
|
123
|
+
*/
|
|
124
|
+
export function isEncryptedSentinel(value) {
|
|
125
|
+
return typeof value === 'string' && SENTINEL_RE.test(value);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The scheme recorded in a sentinel, or null when the value is not one.
|
|
130
|
+
* @param {unknown} value
|
|
131
|
+
* @returns {EncryptionScheme | null}
|
|
132
|
+
*/
|
|
133
|
+
export function sentinelScheme(value) {
|
|
134
|
+
if (typeof value !== 'string') return null;
|
|
135
|
+
const match = SENTINEL_RE.exec(value);
|
|
136
|
+
return match ? /** @type {EncryptionScheme} */ (match[1]) : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Classify a raw string as ciphertext, without decrypting it.
|
|
141
|
+
* @param {unknown} value
|
|
142
|
+
* @returns {EncryptionScheme | null}
|
|
143
|
+
*/
|
|
144
|
+
export function encryptedValueScheme(value) {
|
|
145
|
+
if (typeof value !== 'string') return null;
|
|
146
|
+
const trimmed = value.trim();
|
|
147
|
+
if (!trimmed) return null;
|
|
148
|
+
if (SOPS_ENC_TOKEN_RE.test(trimmed)) return 'sops';
|
|
149
|
+
if (trimmed.includes(AGE_ARMOR_HEADER)) return 'age';
|
|
150
|
+
if (trimmed.includes(PGP_MESSAGE_HEADER)) return 'pgp';
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* True when the whole file is one armored age blob rather than a config
|
|
156
|
+
* document. Such a file is not YAML or JSON in any useful sense, so the parser
|
|
157
|
+
* short-circuits it rather than letting js-yaml guess at the armor.
|
|
158
|
+
* @param {string} raw
|
|
159
|
+
* @returns {boolean}
|
|
160
|
+
*/
|
|
161
|
+
export function isArmoredAgeFile(raw) {
|
|
162
|
+
return String(raw).trimStart().startsWith(AGE_ARMOR_HEADER);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The parsed form of a file that is nothing but ciphertext: a single sentinel.
|
|
167
|
+
* Diffing two versions reports that the blob changed, and nothing else — which
|
|
168
|
+
* is genuinely all an opaque file can tell you.
|
|
169
|
+
* @param {string} raw
|
|
170
|
+
* @returns {string}
|
|
171
|
+
*/
|
|
172
|
+
export function opaqueFileState(raw) {
|
|
173
|
+
return sentinel('age', String(raw).trim());
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* True when a `sops:` value is really a SOPS metadata block.
|
|
178
|
+
*
|
|
179
|
+
* SOPS always writes `version` plus a MAC, a modification stamp, and at least
|
|
180
|
+
* one key group. Requiring `version` *and two of the other three* is what keeps
|
|
181
|
+
* an ordinary config that happens to pin `sops: { version: "3.9.0" }` from
|
|
182
|
+
* being mistaken for an encrypted file. Both the on-disk array shape and the
|
|
183
|
+
* normalized keyed-object shape are accepted, so this holds for a tree read
|
|
184
|
+
* back out of a snapshot too.
|
|
185
|
+
* @param {unknown} block
|
|
186
|
+
* @returns {boolean}
|
|
187
|
+
*/
|
|
188
|
+
export function looksLikeSopsMetadata(block) {
|
|
189
|
+
if (!isPlainObject(block)) return false;
|
|
190
|
+
if (typeof block.version !== 'string' && typeof block.version !== 'number') return false;
|
|
191
|
+
|
|
192
|
+
let signals = 0;
|
|
193
|
+
if (typeof block.mac === 'string' && block.mac.trim()) signals += 1;
|
|
194
|
+
if (typeof block.lastmodified === 'string' && block.lastmodified.trim()) signals += 1;
|
|
195
|
+
if (RECIPIENT_GROUPS.some((group) => hasRecipientEntries(block[group]))) signals += 1;
|
|
196
|
+
return signals >= 2;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* @param {unknown} group
|
|
201
|
+
* @returns {boolean}
|
|
202
|
+
*/
|
|
203
|
+
function hasRecipientEntries(group) {
|
|
204
|
+
if (Array.isArray(group)) return group.some(isPlainObject);
|
|
205
|
+
if (isPlainObject(group)) return Object.values(group).some(isPlainObject);
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* SOPS's flat-format metadata, used for dotenv and INI files, where the block
|
|
211
|
+
* is spelled as `sops_version` / `sops_mac` / `sops_lastmodified` top-level
|
|
212
|
+
* keys instead of a nested map.
|
|
213
|
+
* @param {Record<string, unknown>} tree
|
|
214
|
+
* @returns {boolean}
|
|
215
|
+
*/
|
|
216
|
+
function hasFlatSopsMetadata(tree) {
|
|
217
|
+
if (typeof tree.sops_version !== 'string' || !tree.sops_version.trim()) return false;
|
|
218
|
+
return typeof tree.sops_mac === 'string' || typeof tree.sops_lastmodified === 'string';
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Top-level keys whose value carries a SOPS metadata block of its own.
|
|
223
|
+
*
|
|
224
|
+
* This is what a multi-document file looks like once `parseYamlStream` has
|
|
225
|
+
* wrapped its documents: the `sops` block of each `kind: Secret` sits one level
|
|
226
|
+
* down, under the document's identity key. Detection is by content —
|
|
227
|
+
* {@link looksLikeSopsMetadata}, which wants a version plus two more signals —
|
|
228
|
+
* and never by the shape of the key, so a config whose top-level keys happen to
|
|
229
|
+
* read like `Kind/ns/name` is not affected unless it genuinely contains a SOPS
|
|
230
|
+
* metadata block, in which case calling it encrypted is the right answer.
|
|
231
|
+
*
|
|
232
|
+
* Content rather than the parser's own multi-document signal, because this runs
|
|
233
|
+
* at diff time on trees that may have been read back out of a JSON snapshot,
|
|
234
|
+
* where no in-memory signal can have survived. Both sides of every diff are
|
|
235
|
+
* therefore judged by the same rule, which is what keeps a snapshot baseline
|
|
236
|
+
* from disagreeing with a freshly parsed file about whether a file is
|
|
237
|
+
* encrypted.
|
|
238
|
+
* @param {unknown} tree
|
|
239
|
+
* @returns {string[]}
|
|
240
|
+
*/
|
|
241
|
+
function sopsDocumentKeys(tree) {
|
|
242
|
+
if (!isPlainObject(tree)) return [];
|
|
243
|
+
const keys = [];
|
|
244
|
+
for (const [key, doc] of Object.entries(tree)) {
|
|
245
|
+
if (isPlainObject(doc) && looksLikeSopsMetadata(doc.sops)) keys.push(key);
|
|
246
|
+
}
|
|
247
|
+
return keys;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The encryption state of an already-normalized tree.
|
|
252
|
+
*
|
|
253
|
+
* Cheap by construction: the root checks are O(1) and the per-document scan is
|
|
254
|
+
* one property read per top-level key, each of which bails out immediately
|
|
255
|
+
* unless that key holds a genuine SOPS metadata block. Every diff runs this on
|
|
256
|
+
* both sides, and an ordinary config must not pay for a feature it does not use.
|
|
257
|
+
*
|
|
258
|
+
* A multi-document file counts as encrypted when *any* of its documents is —
|
|
259
|
+
* the same reading as the single-document case, where one `sops` block makes
|
|
260
|
+
* the file encrypted. Losing it everywhere is what raises `<encryption>`.
|
|
261
|
+
* @param {unknown} tree
|
|
262
|
+
* @returns {EncryptionState}
|
|
263
|
+
*/
|
|
264
|
+
export function encryptionState(tree) {
|
|
265
|
+
if (typeof tree === 'string') return sentinelScheme(tree) ?? 'plaintext';
|
|
266
|
+
if (!isPlainObject(tree)) return 'plaintext';
|
|
267
|
+
if (looksLikeSopsMetadata(tree.sops)) return 'sops';
|
|
268
|
+
if (hasFlatSopsMetadata(tree)) return 'sops';
|
|
269
|
+
return sopsDocumentKeys(tree).length > 0 ? 'sops' : 'plaintext';
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Recursively map a tree, returning the *original reference* when nothing
|
|
274
|
+
* changed. That identity is load-bearing: it is how an unencrypted file proves
|
|
275
|
+
* it came out of the encryption pass untouched.
|
|
276
|
+
* @param {unknown} value
|
|
277
|
+
* @param {(leaf: string) => string} mapString
|
|
278
|
+
* @returns {unknown}
|
|
279
|
+
*/
|
|
280
|
+
function mapStrings(value, mapString) {
|
|
281
|
+
if (typeof value === 'string') return mapString(value);
|
|
282
|
+
|
|
283
|
+
if (Array.isArray(value)) {
|
|
284
|
+
let changed = false;
|
|
285
|
+
const out = value.map((item) => {
|
|
286
|
+
const next = mapStrings(item, mapString);
|
|
287
|
+
if (next !== item) changed = true;
|
|
288
|
+
return next;
|
|
289
|
+
});
|
|
290
|
+
return changed ? out : value;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (isPlainObject(value)) {
|
|
294
|
+
let changed = false;
|
|
295
|
+
/** @type {Record<string, unknown>} */
|
|
296
|
+
const out = {};
|
|
297
|
+
for (const [key, item] of Object.entries(value)) {
|
|
298
|
+
const next = mapStrings(item, mapString);
|
|
299
|
+
if (next !== item) changed = true;
|
|
300
|
+
out[key] = next;
|
|
301
|
+
}
|
|
302
|
+
return changed ? out : value;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return value;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Replace every ciphertext-bearing string in a tree with its sentinel.
|
|
310
|
+
* @param {unknown} tree
|
|
311
|
+
* @returns {unknown}
|
|
312
|
+
*/
|
|
313
|
+
function redactCiphertext(tree) {
|
|
314
|
+
return mapStrings(tree, (leaf) => {
|
|
315
|
+
const scheme = encryptedValueScheme(leaf);
|
|
316
|
+
return scheme ? sentinel(scheme, leaf) : leaf;
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The stable identity of one recipient entry, built from its public fields.
|
|
322
|
+
* @param {string} group
|
|
323
|
+
* @param {Record<string, unknown>} entry
|
|
324
|
+
* @returns {string | null}
|
|
325
|
+
*/
|
|
326
|
+
function recipientIdentity(group, entry) {
|
|
327
|
+
const parts = [];
|
|
328
|
+
for (const field of RECIPIENT_ID_FIELDS[group] ?? []) {
|
|
329
|
+
const value = entry[field];
|
|
330
|
+
if (value == null || typeof value === 'object') continue;
|
|
331
|
+
const text = String(value).trim();
|
|
332
|
+
if (text) parts.push(text);
|
|
333
|
+
}
|
|
334
|
+
return parts.length > 0 ? parts.join('|') : null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Redact the wrapped data key on a recipient entry. `enc` is the data key
|
|
339
|
+
* sealed to that recipient — ciphertext, and useless to a reader — while every
|
|
340
|
+
* other field on the entry identifies *who* the recipient is and stays.
|
|
341
|
+
* @param {string} group
|
|
342
|
+
* @param {Record<string, unknown>} entry
|
|
343
|
+
* @returns {Record<string, unknown>}
|
|
344
|
+
*/
|
|
345
|
+
function redactRecipientEntry(group, entry) {
|
|
346
|
+
const enc = entry.enc;
|
|
347
|
+
if (typeof enc !== 'string' || !enc.trim() || isEncryptedSentinel(enc)) return entry;
|
|
348
|
+
const scheme = group === 'age' || group === 'pgp' ? group : 'sops';
|
|
349
|
+
return { ...entry, enc: sentinel(/** @type {EncryptionScheme} */ (scheme), enc) };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Re-key a recipient array by recipient identity.
|
|
354
|
+
*
|
|
355
|
+
* On disk these are arrays, so index diffing would report a recipient inserted
|
|
356
|
+
* at the front as "every recipient changed" — the opposite of the truth, and
|
|
357
|
+
* exactly the case where a reviewer must not be misled. Recipient order carries
|
|
358
|
+
* no meaning, so keying by identity turns the real event into a single `added`
|
|
359
|
+
* or `removed` at a path that names the key involved.
|
|
360
|
+
*
|
|
361
|
+
* All-or-nothing: if any entry lacks an identity or two share one, the array
|
|
362
|
+
* shape is kept so the diff stays faithful to the file.
|
|
363
|
+
* @param {string} group
|
|
364
|
+
* @param {unknown} value
|
|
365
|
+
* @returns {unknown}
|
|
366
|
+
*/
|
|
367
|
+
function normalizeRecipientGroup(group, value) {
|
|
368
|
+
if (!Array.isArray(value) || value.length === 0 || !value.every(isPlainObject)) return value;
|
|
369
|
+
|
|
370
|
+
const redacted = value.map((entry) => redactRecipientEntry(group, entry));
|
|
371
|
+
const identities = value.map((entry) => recipientIdentity(group, entry));
|
|
372
|
+
const usable = identities.every((id) => id != null && id !== '__proto__')
|
|
373
|
+
&& new Set(identities).size === identities.length;
|
|
374
|
+
|
|
375
|
+
if (!usable) {
|
|
376
|
+
return redacted.some((entry, index) => entry !== value[index]) ? redacted : value;
|
|
377
|
+
}
|
|
378
|
+
return Object.fromEntries(identities.map((id, index) => [id, redacted[index]]));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Normalize the recipient groups inside a SOPS metadata block.
|
|
383
|
+
* @param {Record<string, unknown>} block
|
|
384
|
+
* @returns {Record<string, unknown>}
|
|
385
|
+
*/
|
|
386
|
+
function normalizeSopsBlock(block) {
|
|
387
|
+
let changed = false;
|
|
388
|
+
/** @type {Record<string, unknown>} */
|
|
389
|
+
const out = {};
|
|
390
|
+
for (const [key, value] of Object.entries(block)) {
|
|
391
|
+
const next = RECIPIENT_GROUPS.includes(key) ? normalizeRecipientGroup(key, value) : value;
|
|
392
|
+
if (next !== value) changed = true;
|
|
393
|
+
out[key] = next;
|
|
394
|
+
}
|
|
395
|
+
return changed ? out : block;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Replace `owner.sops` with its recipient-normalized form, or return `owner`
|
|
400
|
+
* itself when nothing needed re-keying.
|
|
401
|
+
* @param {Record<string, unknown>} owner
|
|
402
|
+
* @returns {Record<string, unknown>}
|
|
403
|
+
*/
|
|
404
|
+
function normalizeSopsOwner(owner) {
|
|
405
|
+
if (!looksLikeSopsMetadata(owner.sops)) return owner;
|
|
406
|
+
const sops = normalizeSopsBlock(/** @type {Record<string, unknown>} */ (owner.sops));
|
|
407
|
+
// Spreading first keeps `sops` in its original position among the keys.
|
|
408
|
+
return sops === owner.sops ? owner : { ...owner, sops };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* The parser's entry point: make a freshly parsed tree safe to carry.
|
|
413
|
+
*
|
|
414
|
+
* Returns the argument itself when the file holds no ciphertext, so an ordinary
|
|
415
|
+
* config is not merely equal to what it used to be — it is the same object.
|
|
416
|
+
*
|
|
417
|
+
* `documentKeys` are the synthetic keys `parseYamlStream` invented for a
|
|
418
|
+
* multi-document file. They are threaded in rather than rediscovered because
|
|
419
|
+
* this pass *rewrites diff paths* — it re-keys recipient arrays by identity —
|
|
420
|
+
* and that must happen for a document the parser really produced and nowhere
|
|
421
|
+
* else. A single-document config that merely embeds something SOPS-shaped keeps
|
|
422
|
+
* the paths it has always had.
|
|
423
|
+
* @param {unknown} tree
|
|
424
|
+
* @param {readonly string[]} [documentKeys]
|
|
425
|
+
* @returns {unknown}
|
|
426
|
+
*/
|
|
427
|
+
export function normalizeEncrypted(tree, documentKeys = []) {
|
|
428
|
+
const redacted = redactCiphertext(tree);
|
|
429
|
+
if (!isPlainObject(redacted)) return redacted;
|
|
430
|
+
|
|
431
|
+
let out = normalizeSopsOwner(redacted);
|
|
432
|
+
for (const key of documentKeys) {
|
|
433
|
+
if (!Object.prototype.hasOwnProperty.call(out, key)) continue;
|
|
434
|
+
const doc = out[key];
|
|
435
|
+
if (!isPlainObject(doc)) continue;
|
|
436
|
+
const normalized = normalizeSopsOwner(doc);
|
|
437
|
+
if (normalized === doc) continue;
|
|
438
|
+
if (out === redacted) out = { ...redacted };
|
|
439
|
+
out[key] = normalized;
|
|
440
|
+
}
|
|
441
|
+
return out;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Collapse sentinels to human-readable text for display. Used by the terminal
|
|
446
|
+
* renderer; machine-readable outputs keep the sentinel, which is stable across
|
|
447
|
+
* runs and therefore useful to correlate.
|
|
448
|
+
* @param {unknown} value
|
|
449
|
+
* @returns {unknown}
|
|
450
|
+
*/
|
|
451
|
+
export function displayEncrypted(value) {
|
|
452
|
+
return mapStrings(value, (leaf) => (isEncryptedSentinel(leaf) ? ENCRYPTED_DISPLAY : leaf));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* @param {string} path
|
|
457
|
+
* @returns {boolean}
|
|
458
|
+
*/
|
|
459
|
+
function isSopsMetadataPath(path) {
|
|
460
|
+
return path === 'sops'
|
|
461
|
+
|| path.startsWith('sops.')
|
|
462
|
+
|| path.startsWith('sops[')
|
|
463
|
+
|| path.startsWith('sops_');
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* The metadata-relative form of a diff path: the path as the document that owns
|
|
468
|
+
* the `sops` block sees it. `sops.mac` for an ordinary file,
|
|
469
|
+
* `ConfigMap/prod/billing.sops.mac` → `sops.mac` for a multi-document one.
|
|
470
|
+
*
|
|
471
|
+
* `prefixes` are the top-level keys that carry their own metadata block, so a
|
|
472
|
+
* document without one never has its paths rewritten.
|
|
473
|
+
* @param {string} path
|
|
474
|
+
* @param {string[]} prefixes
|
|
475
|
+
* @returns {string}
|
|
476
|
+
*/
|
|
477
|
+
function metadataRelativePath(path, prefixes) {
|
|
478
|
+
for (const prefix of prefixes) {
|
|
479
|
+
if (path.startsWith(prefix) && path[prefix.length] === '.') {
|
|
480
|
+
return path.slice(prefix.length + 1);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return path;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* @param {EncryptionState} before
|
|
488
|
+
* @param {EncryptionState} after
|
|
489
|
+
* @returns {string}
|
|
490
|
+
*/
|
|
491
|
+
function stateNote(before, after) {
|
|
492
|
+
if (after === 'plaintext') return 'file is no longer encrypted';
|
|
493
|
+
if (before === 'plaintext') return 'file is now encrypted';
|
|
494
|
+
return 'encryption scheme changed';
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Add the change events that only exist for an encrypted file, and strip a
|
|
499
|
+
* value that stopped being encrypted.
|
|
500
|
+
*
|
|
501
|
+
* Called by `diffTrees` for every diff. When neither side is encrypted it
|
|
502
|
+
* returns the exact array it was given, so an ordinary config sees no new
|
|
503
|
+
* events, no copied objects, and no measurable cost. "Encrypted" here covers a
|
|
504
|
+
* multi-document file in which any single document is — otherwise a `kind:
|
|
505
|
+
* Secret` shipped alongside a plain ConfigMap would lose every protection
|
|
506
|
+
* below.
|
|
507
|
+
*
|
|
508
|
+
* Three things are derived that a key-by-key walk cannot express on its own:
|
|
509
|
+
*
|
|
510
|
+
* 1. `<encryption>` — the file gained or lost encryption. Losing it means
|
|
511
|
+
* plaintext secrets were committed, which is why the `sops` pack raises it
|
|
512
|
+
* as an error rather than a note.
|
|
513
|
+
* 2. `<encryption.mac>` — the SOPS MAC moved while every value it covers,
|
|
514
|
+
* encrypted or not, stayed put. Normal edits move both together, so this
|
|
515
|
+
* combination is a hand-edited or tampered metadata block.
|
|
516
|
+
* 3. A value that was ciphertext and is now a plain scalar is replaced with
|
|
517
|
+
* a marker. The point of the diff is that the value was exposed; printing
|
|
518
|
+
* it into a CI log would expose it again, to a wider audience.
|
|
519
|
+
*
|
|
520
|
+
* @param {unknown} before
|
|
521
|
+
* @param {unknown} after
|
|
522
|
+
* @param {import('./differ.js').ChangeEvent[]} events
|
|
523
|
+
* @returns {import('./differ.js').ChangeEvent[]}
|
|
524
|
+
*/
|
|
525
|
+
export function annotateEncryptedChanges(before, after, events) {
|
|
526
|
+
const beforeState = encryptionState(before);
|
|
527
|
+
const afterState = encryptionState(after);
|
|
528
|
+
if (beforeState === 'plaintext' && afterState === 'plaintext') return events;
|
|
529
|
+
|
|
530
|
+
const out = events.map((event) => {
|
|
531
|
+
if (
|
|
532
|
+
event.type === 'changed'
|
|
533
|
+
&& isEncryptedSentinel(event.before)
|
|
534
|
+
&& !isEncryptedSentinel(event.after)
|
|
535
|
+
&& (event.after == null || typeof event.after !== 'object')
|
|
536
|
+
) {
|
|
537
|
+
return { ...event, after: DECRYPTED_DISPLAY, note: 'value is no longer encrypted' };
|
|
538
|
+
}
|
|
539
|
+
return event;
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
if (beforeState !== afterState) {
|
|
543
|
+
out.unshift({
|
|
544
|
+
type: 'changed',
|
|
545
|
+
path: ENCRYPTION_STATE_PATH,
|
|
546
|
+
before: beforeState,
|
|
547
|
+
after: afterState,
|
|
548
|
+
note: stateNote(beforeState, afterState),
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (beforeState !== 'plaintext' && afterState !== 'plaintext') {
|
|
553
|
+
// In a multi-document file the metadata blocks live under the document
|
|
554
|
+
// keys, so read every path relative to whichever document owns one.
|
|
555
|
+
const prefixes = [...new Set([...sopsDocumentKeys(before), ...sopsDocumentKeys(after)])];
|
|
556
|
+
const relative = (path) => metadataRelativePath(path, prefixes);
|
|
557
|
+
const macChanged = events.some(
|
|
558
|
+
(event) => event.type === 'changed' && MAC_PATHS.has(relative(event.path)),
|
|
559
|
+
);
|
|
560
|
+
const payloadChanged = events.some((event) => !isSopsMetadataPath(relative(event.path)));
|
|
561
|
+
if (macChanged && !payloadChanged) {
|
|
562
|
+
out.push({
|
|
563
|
+
type: 'changed',
|
|
564
|
+
path: ENCRYPTION_MAC_PATH,
|
|
565
|
+
before: 'consistent',
|
|
566
|
+
after: 'mac-only',
|
|
567
|
+
note: 'MAC changed but no value did',
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return out;
|
|
573
|
+
}
|