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/package.json
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"access": "public",
|
|
5
5
|
"provenance": true
|
|
6
6
|
},
|
|
7
|
-
"version": "
|
|
8
|
-
"description": "Flecto
|
|
7
|
+
"version": "3.0.1",
|
|
8
|
+
"description": "Flecto \u2014 semantic config watcher that reports meaningful changes in plain English",
|
|
9
9
|
"license": "MIT",
|
|
10
10
|
"keywords": [
|
|
11
11
|
"flecto",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"scripts": {
|
|
44
44
|
"test": "node --test test/*.test.js",
|
|
45
45
|
"test:watch": "node --test --watch test/*.test.js",
|
|
46
|
+
"bench": "node bench/run.js",
|
|
46
47
|
"pack:check": "npm pack --dry-run"
|
|
47
48
|
},
|
|
48
49
|
"dependencies": {
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"required": ["rules"],
|
|
8
8
|
"properties": {
|
|
9
9
|
"id": { "type": "string", "minLength": 1 },
|
|
10
|
+
"expandSubtrees": { "type": "boolean" },
|
|
10
11
|
"rules": {
|
|
11
12
|
"type": "array",
|
|
12
13
|
"items": { "$ref": "#/$defs/rule" }
|
|
@@ -41,6 +42,8 @@
|
|
|
41
42
|
"afterIn": { "type": "array" },
|
|
42
43
|
"beforeTruthy": { "const": true },
|
|
43
44
|
"afterTruthy": { "const": true },
|
|
45
|
+
"beforeLooksSecret": { "const": true },
|
|
46
|
+
"afterLooksSecret": { "const": true },
|
|
44
47
|
"afterMatches": { "type": "string" },
|
|
45
48
|
"numericJump": {
|
|
46
49
|
"type": "object",
|
|
@@ -89,6 +92,8 @@
|
|
|
89
92
|
"afterIn": { "type": "array" },
|
|
90
93
|
"beforeTruthy": { "const": true },
|
|
91
94
|
"afterTruthy": { "const": true },
|
|
95
|
+
"beforeLooksSecret": { "const": true },
|
|
96
|
+
"afterLooksSecret": { "const": true },
|
|
92
97
|
"afterMatches": { "type": "string" },
|
|
93
98
|
"numericJump": { "$ref": "#/$defs/numericJump" },
|
|
94
99
|
"numericDelta": { "$ref": "#/$defs/numericDelta" }
|
package/src/alerter.js
CHANGED
|
@@ -2,6 +2,7 @@ import { spawn } from 'child_process';
|
|
|
2
2
|
import { mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync } from 'fs';
|
|
3
3
|
import { resolve } from 'path';
|
|
4
4
|
import { renderWarn } from './renderer.js';
|
|
5
|
+
import { formatWebhookPayload } from './notifiers.js';
|
|
5
6
|
|
|
6
7
|
const ALERT_TMP_DIR = '.flecto-tmp';
|
|
7
8
|
const ALERT_QUEUE_DIR = '.flecto-queue';
|
|
@@ -135,11 +136,19 @@ async function flushPersistentQueue(deliver) {
|
|
|
135
136
|
/**
|
|
136
137
|
* @param {string} url
|
|
137
138
|
* @param {import('./envelope.js').FlectoEnvelope} envelope
|
|
138
|
-
* @param {{
|
|
139
|
+
* @param {{
|
|
140
|
+
* headers?: Record<string, string>,
|
|
141
|
+
* timeoutMs?: number,
|
|
142
|
+
* retries?: number,
|
|
143
|
+
* format?: import('./notifiers.js').WebhookFormat
|
|
144
|
+
* }} [options]
|
|
139
145
|
* @returns {Promise<boolean>}
|
|
140
146
|
*/
|
|
141
147
|
export async function postWebhook(url, envelope, options = {}) {
|
|
142
|
-
|
|
148
|
+
// `flecto` (the default) returns the envelope untouched, so the body is
|
|
149
|
+
// byte-identical to what has always been posted. Chat formats reshape only
|
|
150
|
+
// the body: headers, retries, and delivery modes are unchanged.
|
|
151
|
+
const body = JSON.stringify(formatWebhookPayload(envelope, options.format ?? 'flecto'));
|
|
143
152
|
const timeoutMs = options.timeoutMs ?? 5_000;
|
|
144
153
|
const retries = options.retries ?? 2;
|
|
145
154
|
const headers = {
|
|
@@ -186,7 +195,13 @@ export async function postWebhook(url, envelope, options = {}) {
|
|
|
186
195
|
}
|
|
187
196
|
|
|
188
197
|
/**
|
|
189
|
-
* @param {{
|
|
198
|
+
* @param {{
|
|
199
|
+
* webhook?: string,
|
|
200
|
+
* webhookHeaders?: Record<string, string>,
|
|
201
|
+
* webhookTimeoutMs?: number,
|
|
202
|
+
* webhookRetries?: number,
|
|
203
|
+
* webhookFormat?: import('./notifiers.js').WebhookFormat
|
|
204
|
+
* }} options
|
|
190
205
|
* @param {import('./envelope.js').FlectoEnvelope} envelope
|
|
191
206
|
*/
|
|
192
207
|
async function deliverWebhook(options, envelope) {
|
|
@@ -195,6 +210,7 @@ async function deliverWebhook(options, envelope) {
|
|
|
195
210
|
headers: options.webhookHeaders,
|
|
196
211
|
timeoutMs: options.webhookTimeoutMs,
|
|
197
212
|
retries: options.webhookRetries,
|
|
213
|
+
format: options.webhookFormat,
|
|
198
214
|
});
|
|
199
215
|
}
|
|
200
216
|
|
|
@@ -217,6 +233,7 @@ function applyFailurePolicy(options, ok) {
|
|
|
217
233
|
* webhookHeaders?: Record<string, string>,
|
|
218
234
|
* webhookTimeoutMs?: number,
|
|
219
235
|
* webhookRetries?: number,
|
|
236
|
+
* webhookFormat?: import('./notifiers.js').WebhookFormat,
|
|
220
237
|
* deliveryMode?: 'best-effort' | 'at-least-once',
|
|
221
238
|
* onAlertFailure?: 'warn' | 'exit' | 'retry'
|
|
222
239
|
* }} options
|
package/src/config.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
-
import { resolve } from 'path';
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs';
|
|
2
|
+
import { resolve, sep } from 'path';
|
|
3
3
|
import fg from 'fast-glob';
|
|
4
4
|
import yaml from 'js-yaml';
|
|
5
|
+
import { isEnvFilename } from './parser.js';
|
|
5
6
|
|
|
6
7
|
const RC_CANDIDATES = ['.flectorc', '.flectorc.json', '.flectorc.yaml', '.flectorc.yml'];
|
|
8
|
+
const COMPOSE_FILENAMES = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'];
|
|
9
|
+
const CONFIG_DIR_PATTERN = 'config/**/*.{yaml,yml,json,toml,ini}';
|
|
10
|
+
const ENV_FILE_PATTERNS = ['.env', '.env.*', '*.env'];
|
|
11
|
+
const GENERIC_FILE_PATTERNS = [CONFIG_DIR_PATTERN, ...ENV_FILE_PATTERNS];
|
|
7
12
|
|
|
8
13
|
/**
|
|
9
14
|
* @typedef {{
|
|
@@ -13,6 +18,15 @@ const RC_CANDIDATES = ['.flectorc', '.flectorc.json', '.flectorc.yaml', '.flecto
|
|
|
13
18
|
* include?: string[],
|
|
14
19
|
* exclude?: string[]
|
|
15
20
|
* }} FlectoRc
|
|
21
|
+
*
|
|
22
|
+
* @typedef {{
|
|
23
|
+
* id: string,
|
|
24
|
+
* evidence: string[],
|
|
25
|
+
* pack: string | null,
|
|
26
|
+
* summary: string
|
|
27
|
+
* }} StackSignal
|
|
28
|
+
*
|
|
29
|
+
* @typedef {{ signals: StackSignal[], packs: string[], files: string[] }} StackDetection
|
|
16
30
|
*/
|
|
17
31
|
|
|
18
32
|
/**
|
|
@@ -66,24 +80,70 @@ export function resolveEffectiveOptions(config, profile, cliOverrides = {}) {
|
|
|
66
80
|
return { ...defaults, ...profileOptions, ...cliOverrides };
|
|
67
81
|
}
|
|
68
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Has the operator explicitly opted in to plugins declared in `.flectorc`?
|
|
85
|
+
* @returns {boolean}
|
|
86
|
+
*/
|
|
87
|
+
function rcPluginsAllowed() {
|
|
88
|
+
const raw = process.env.FLECTO_ALLOW_RC_PLUGINS;
|
|
89
|
+
return raw === '1' || String(raw).toLowerCase() === 'true';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Split a policy list that may arrive as an array or a comma-separated string.
|
|
94
|
+
* @param {unknown} raw
|
|
95
|
+
* @param {string[]} fallback
|
|
96
|
+
* @returns {string[]}
|
|
97
|
+
*/
|
|
98
|
+
function toList(raw, fallback) {
|
|
99
|
+
if (Array.isArray(raw)) return raw.map(String);
|
|
100
|
+
if (typeof raw === 'string') return raw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
101
|
+
return fallback;
|
|
102
|
+
}
|
|
103
|
+
|
|
69
104
|
/**
|
|
70
105
|
* Normalize policy-related effective options.
|
|
106
|
+
*
|
|
107
|
+
* Plugins execute code, and `.flectorc` is attacker-controlled on an untrusted
|
|
108
|
+
* pull request, so a plugin that came from the rc file rather than an explicit
|
|
109
|
+
* `--plugins` flag is refused unless the operator opts in with
|
|
110
|
+
* `FLECTO_ALLOW_RC_PLUGINS=1`. Refusing loudly rather than skipping silently is
|
|
111
|
+
* deliberate: a plugin that stops running without saying so would weaken a
|
|
112
|
+
* policy gate the operator believes is in place.
|
|
71
113
|
* @param {Record<string, unknown>} effective
|
|
114
|
+
* @param {{ pluginsFromCli?: boolean, cwd?: string }} [provenance]
|
|
72
115
|
*/
|
|
73
|
-
export function resolvePolicyOptions(effective) {
|
|
74
|
-
const policiesRaw = effective.policies;
|
|
75
|
-
const pluginsRaw = effective.plugins;
|
|
116
|
+
export function resolvePolicyOptions(effective, provenance = {}) {
|
|
76
117
|
const severityRemapRaw = effective.severityRemap;
|
|
77
|
-
const policies =
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
118
|
+
const policies = toList(effective.policies, ['default']);
|
|
119
|
+
const plugins = toList(effective.plugins, []);
|
|
120
|
+
|
|
121
|
+
if (plugins.length > 0 && !provenance.pluginsFromCli) {
|
|
122
|
+
if (!rcPluginsAllowed()) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
'Refusing to load policy plugins declared in .flectorc: plugins execute code, '
|
|
125
|
+
+ 'and a config file can come from an untrusted pull request.\n'
|
|
126
|
+
+ `Declared: ${plugins.join(', ')}\n`
|
|
127
|
+
+ 'Pass them on the command line with --plugins instead, or set '
|
|
128
|
+
+ 'FLECTO_ALLOW_RC_PLUGINS=1 if this config is trusted.',
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
// Opted in, but the rc file may still be attacker-authored. Keep rc-declared
|
|
132
|
+
// plugins inside the project so `../../../../tmp/x.mjs` cannot reach a module
|
|
133
|
+
// planted elsewhere on the runner. An explicit --plugins is operator intent
|
|
134
|
+
// and stays unrestricted: shared policies outside the cwd are a real setup.
|
|
135
|
+
const root = resolve(provenance.cwd ?? process.cwd());
|
|
136
|
+
for (const pluginPath of plugins) {
|
|
137
|
+
const abs = resolve(root, pluginPath);
|
|
138
|
+
if (abs !== root && !abs.startsWith(root + sep)) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`Policy plugin declared in .flectorc is outside the project: ${pluginPath}\n`
|
|
141
|
+
+ 'Plugins execute code, so an rc-declared plugin must live inside the '
|
|
142
|
+
+ 'directory Flecto is running in.',
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
87
147
|
if (
|
|
88
148
|
severityRemapRaw !== undefined
|
|
89
149
|
&& (severityRemapRaw === null || Array.isArray(severityRemapRaw) || typeof severityRemapRaw !== 'object')
|
|
@@ -126,13 +186,104 @@ export async function resolveFiles(input) {
|
|
|
126
186
|
}
|
|
127
187
|
|
|
128
188
|
/**
|
|
129
|
-
*
|
|
189
|
+
* Detect stack signals in a directory and map them to policy packs and file
|
|
190
|
+
* patterns. Only built-in pack ids and patterns Flecto can actually parse are
|
|
191
|
+
* ever returned, so a config built from this stays loadable by every command.
|
|
192
|
+
* Terraform files are reported as context only: `.tf` is not a supported parse
|
|
193
|
+
* format, and the `terraform` pack reads plan JSON rather than `.tf` sources,
|
|
194
|
+
* so neither belongs in a config built from a directory listing.
|
|
130
195
|
* @param {string} cwd
|
|
131
|
-
* @returns {
|
|
196
|
+
* @returns {StackDetection}
|
|
197
|
+
*/
|
|
198
|
+
export function detectStack(cwd = process.cwd()) {
|
|
199
|
+
/** @type {StackSignal[]} */
|
|
200
|
+
const signals = [];
|
|
201
|
+
const packs = ['default'];
|
|
202
|
+
/** @type {string[]} */
|
|
203
|
+
const files = [];
|
|
204
|
+
|
|
205
|
+
let entries = [];
|
|
206
|
+
try {
|
|
207
|
+
entries = readdirSync(cwd, { withFileTypes: true });
|
|
208
|
+
} catch {
|
|
209
|
+
return { signals, packs, files };
|
|
210
|
+
}
|
|
211
|
+
const fileNames = entries.filter((entry) => entry.isFile()).map((entry) => entry.name);
|
|
212
|
+
const dirNames = new Set(entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name));
|
|
213
|
+
const fileNameSet = new Set(fileNames);
|
|
214
|
+
|
|
215
|
+
const composeFiles = COMPOSE_FILENAMES.filter((name) => fileNameSet.has(name));
|
|
216
|
+
if (composeFiles.length > 0) {
|
|
217
|
+
packs.push('compose');
|
|
218
|
+
files.push(...composeFiles);
|
|
219
|
+
signals.push({
|
|
220
|
+
id: 'compose',
|
|
221
|
+
evidence: composeFiles,
|
|
222
|
+
pack: 'compose',
|
|
223
|
+
summary: `Detected ${composeFiles.join(', ')} → enabled the \`compose\` policy pack and watched ${composeFiles.length === 1 ? 'it' : 'them'}`,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (fileNameSet.has('package.json')) {
|
|
228
|
+
packs.push('node-runtime');
|
|
229
|
+
files.push('package.json');
|
|
230
|
+
signals.push({
|
|
231
|
+
id: 'node',
|
|
232
|
+
evidence: ['package.json'],
|
|
233
|
+
pack: 'node-runtime',
|
|
234
|
+
summary: 'Detected package.json → enabled the `node-runtime` policy pack and watched it',
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const terraformFiles = fileNames.filter((name) => name.toLowerCase().endsWith('.tf')).sort();
|
|
239
|
+
if (terraformFiles.length > 0) {
|
|
240
|
+
signals.push({
|
|
241
|
+
id: 'terraform',
|
|
242
|
+
evidence: terraformFiles,
|
|
243
|
+
pack: null,
|
|
244
|
+
summary: `Detected Terraform files (${terraformFiles.join(', ')}) → .tf is not a parseable format, so nothing was enabled; run "flecto plan" on "terraform show -json" output to use the terraform pack`,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (dirNames.has('config')) {
|
|
249
|
+
files.push(CONFIG_DIR_PATTERN);
|
|
250
|
+
signals.push({
|
|
251
|
+
id: 'config-dir',
|
|
252
|
+
evidence: ['config/'],
|
|
253
|
+
pack: null,
|
|
254
|
+
summary: `Detected config/ → watched ${CONFIG_DIR_PATTERN}`,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const envFiles = fileNames.filter((name) => isEnvFilename(name)).sort();
|
|
259
|
+
if (envFiles.length > 0) {
|
|
260
|
+
files.push(...ENV_FILE_PATTERNS);
|
|
261
|
+
signals.push({
|
|
262
|
+
id: 'dotenv',
|
|
263
|
+
evidence: envFiles,
|
|
264
|
+
pack: null,
|
|
265
|
+
summary: `Detected ${envFiles.join(', ')} → watched ${ENV_FILE_PATTERNS.join(', ')}`,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return { signals, packs, files: [...new Set(files)] };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Scaffold a starter rc file if missing, pre-selecting policy packs and file
|
|
274
|
+
* patterns from the stack signals found in `cwd`. Never overwrites an existing
|
|
275
|
+
* config: any of the four `.flectorc` candidates is reported back untouched.
|
|
276
|
+
* @param {string} cwd
|
|
277
|
+
* @returns {{ path: string, created: boolean, detection: StackDetection }}
|
|
132
278
|
*/
|
|
133
279
|
export function initRcFile(cwd = process.cwd()) {
|
|
280
|
+
const detection = detectStack(cwd);
|
|
281
|
+
const existingPath = RC_CANDIDATES
|
|
282
|
+
.map((candidate) => resolve(cwd, candidate))
|
|
283
|
+
.find((candidate) => existsSync(candidate));
|
|
284
|
+
if (existingPath) return { path: existingPath, created: false, detection };
|
|
285
|
+
|
|
134
286
|
const path = resolve(cwd, '.flectorc.json');
|
|
135
|
-
if (existsSync(path)) return path;
|
|
136
287
|
const starter = {
|
|
137
288
|
defaults: {
|
|
138
289
|
mode: 'compact',
|
|
@@ -140,7 +291,7 @@ export function initRcFile(cwd = process.cwd()) {
|
|
|
140
291
|
ignore: ['**.updated_at'],
|
|
141
292
|
deliveryMode: 'best-effort',
|
|
142
293
|
onAlertFailure: 'warn',
|
|
143
|
-
policies:
|
|
294
|
+
policies: detection.packs,
|
|
144
295
|
plugins: [],
|
|
145
296
|
arrayIdKey: null,
|
|
146
297
|
arrayId: true,
|
|
@@ -151,14 +302,14 @@ export function initRcFile(cwd = process.cwd()) {
|
|
|
151
302
|
dev: { mode: 'verbose' },
|
|
152
303
|
ci: { failOn: 'policy,error' },
|
|
153
304
|
prod: {
|
|
154
|
-
policies: [
|
|
305
|
+
policies: [...detection.packs, 'strict-prod'],
|
|
155
306
|
severityRemap: { 'pool-size-jump': 'error' },
|
|
156
307
|
maskSecrets: true,
|
|
157
308
|
},
|
|
158
309
|
},
|
|
159
|
-
files:
|
|
310
|
+
files: detection.files.length > 0 ? detection.files : [...GENERIC_FILE_PATTERNS],
|
|
160
311
|
exclude: ['**/node_modules/**'],
|
|
161
312
|
};
|
|
162
313
|
writeFileSync(path, JSON.stringify(starter, null, 2), 'utf8');
|
|
163
|
-
return path;
|
|
314
|
+
return { path, created: true, detection };
|
|
164
315
|
}
|
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
|
/**
|
|
@@ -410,5 +457,15 @@ export function diffTrees(before, after, options = {}) {
|
|
|
410
457
|
diffValues(before, after, '<root>', events, diffOpts);
|
|
411
458
|
}
|
|
412
459
|
|
|
413
|
-
|
|
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));
|
|
414
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
|
+
}
|