flecto 1.0.2 → 2.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/README.md +52 -2
- package/index.js +121 -25
- package/package.json +2 -1
- package/schemas/flecto-envelope-2.0.json +65 -0
- package/src/alerter.js +7 -7
- package/src/config.js +38 -2
- package/src/differ.js +100 -13
- package/src/envelope.js +6 -4
- package/src/packs/default.json +37 -0
- package/src/packs/strict-prod.json +37 -0
- package/src/parser.js +80 -14
- package/src/policy.js +244 -35
- package/src/renderer.js +59 -13
- package/src/watcher.js +11 -4
package/src/policy.js
CHANGED
|
@@ -1,57 +1,267 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from 'path';
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
4
|
+
import yaml from 'js-yaml';
|
|
5
|
+
|
|
1
6
|
/**
|
|
2
7
|
* @typedef {'info' | 'warn' | 'error'} PolicySeverity
|
|
3
|
-
* @typedef {{
|
|
8
|
+
* @typedef {{
|
|
9
|
+
* id: string,
|
|
10
|
+
* severity: PolicySeverity,
|
|
11
|
+
* path: string,
|
|
12
|
+
* message: string,
|
|
13
|
+
* pack?: string
|
|
14
|
+
* }} PolicyFinding
|
|
15
|
+
*
|
|
16
|
+
* @typedef {{
|
|
17
|
+
* id: string,
|
|
18
|
+
* severity: PolicySeverity,
|
|
19
|
+
* when?: Array<'added' | 'removed' | 'changed'>,
|
|
20
|
+
* match?: { path?: string, pathFlags?: string },
|
|
21
|
+
* afterEquals?: unknown,
|
|
22
|
+
* numericJump?: { minMultiple: number },
|
|
23
|
+
* message?: string,
|
|
24
|
+
* messageTemplate?: string
|
|
25
|
+
* }} PolicyRule
|
|
26
|
+
*
|
|
27
|
+
* @typedef {{ id: string, rules: PolicyRule[] }} PolicyPack
|
|
28
|
+
*
|
|
29
|
+
* @typedef {{
|
|
30
|
+
* cwd?: string,
|
|
31
|
+
* file?: string,
|
|
32
|
+
* profile?: string | null,
|
|
33
|
+
* source?: 'watch' | 'ci' | 'diff',
|
|
34
|
+
* policies?: string[],
|
|
35
|
+
* plugins?: string[]
|
|
36
|
+
* }} PolicyEvalOptions
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const SEVERITY_RANK = { info: 1, warn: 2, error: 3 };
|
|
40
|
+
const PACKS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'packs');
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {string} cwd
|
|
44
|
+
* @param {string} packId
|
|
45
|
+
* @returns {string | null}
|
|
4
46
|
*/
|
|
47
|
+
function resolvePackPath(cwd, packId) {
|
|
48
|
+
const localJson = resolve(cwd, 'policies', `${packId}.json`);
|
|
49
|
+
const localYaml = resolve(cwd, 'policies', `${packId}.yaml`);
|
|
50
|
+
const localYml = resolve(cwd, 'policies', `${packId}.yml`);
|
|
51
|
+
if (existsSync(localJson)) return localJson;
|
|
52
|
+
if (existsSync(localYaml)) return localYaml;
|
|
53
|
+
if (existsSync(localYml)) return localYml;
|
|
5
54
|
|
|
6
|
-
const
|
|
7
|
-
|
|
55
|
+
const builtinJson = join(PACKS_DIR, `${packId}.json`);
|
|
56
|
+
if (existsSync(builtinJson)) return builtinJson;
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
8
59
|
|
|
9
60
|
/**
|
|
10
|
-
*
|
|
61
|
+
* @param {string} path
|
|
62
|
+
* @returns {PolicyPack}
|
|
63
|
+
*/
|
|
64
|
+
function readPackFile(path) {
|
|
65
|
+
const raw = readFileSync(path, 'utf8');
|
|
66
|
+
const parsed = path.endsWith('.json') ? JSON.parse(raw) : yaml.load(raw);
|
|
67
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.rules)) {
|
|
68
|
+
throw new Error(`Invalid policy pack at ${path}: expected { id, rules[] }`);
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
id: String(parsed.id ?? ''),
|
|
72
|
+
rules: parsed.rules,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Load a pack by id from policies/ then built-ins.
|
|
78
|
+
* @param {string} packId
|
|
79
|
+
* @param {string} [cwd]
|
|
80
|
+
* @returns {PolicyPack}
|
|
81
|
+
*/
|
|
82
|
+
export function loadPack(packId, cwd = process.cwd()) {
|
|
83
|
+
const id = String(packId ?? '').trim();
|
|
84
|
+
if (!id) throw new Error('Policy pack id is required');
|
|
85
|
+
const path = resolvePackPath(cwd, id);
|
|
86
|
+
if (!path) {
|
|
87
|
+
throw new Error(`Unknown policy pack "${id}". Add policies/${id}.json or use a built-in pack.`);
|
|
88
|
+
}
|
|
89
|
+
const pack = readPackFile(path);
|
|
90
|
+
if (!pack.id) pack.id = id;
|
|
91
|
+
return pack;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* List built-in pack ids.
|
|
96
|
+
* @returns {string[]}
|
|
97
|
+
*/
|
|
98
|
+
export function listBuiltinPackIds() {
|
|
99
|
+
if (!existsSync(PACKS_DIR)) return [];
|
|
100
|
+
return readdirSync(PACKS_DIR)
|
|
101
|
+
.filter((f) => f.endsWith('.json'))
|
|
102
|
+
.map((f) => f.replace(/\.json$/, ''));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {PolicyRule} rule
|
|
107
|
+
* @param {import('./differ.js').ChangeEvent} change
|
|
108
|
+
* @returns {boolean}
|
|
109
|
+
*/
|
|
110
|
+
function ruleMatches(rule, change) {
|
|
111
|
+
const when = rule.when ?? ['added', 'removed', 'changed'];
|
|
112
|
+
if (!when.includes(change.type)) return false;
|
|
113
|
+
|
|
114
|
+
if (rule.match?.path) {
|
|
115
|
+
const flags = rule.match.pathFlags ?? '';
|
|
116
|
+
const re = new RegExp(rule.match.path, flags);
|
|
117
|
+
if (!re.test(change.path ?? '')) return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (Object.prototype.hasOwnProperty.call(rule, 'afterEquals')) {
|
|
121
|
+
if (change.after !== rule.afterEquals) return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (rule.numericJump) {
|
|
125
|
+
const before = change.before;
|
|
126
|
+
const after = change.after;
|
|
127
|
+
if (typeof before !== 'number' || typeof after !== 'number') return false;
|
|
128
|
+
if (!(before > 0 && after >= before * rule.numericJump.minMultiple)) return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {PolicyRule} rule
|
|
136
|
+
* @param {import('./differ.js').ChangeEvent} change
|
|
137
|
+
* @returns {string}
|
|
138
|
+
*/
|
|
139
|
+
function formatMessage(rule, change) {
|
|
140
|
+
if (rule.messageTemplate) {
|
|
141
|
+
return rule.messageTemplate
|
|
142
|
+
.replaceAll('{before}', String(change.before))
|
|
143
|
+
.replaceAll('{after}', String(change.after))
|
|
144
|
+
.replaceAll('{path}', String(change.path ?? ''));
|
|
145
|
+
}
|
|
146
|
+
return rule.message ?? `Policy ${rule.id} matched`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @param {PolicyPack} pack
|
|
11
151
|
* @param {import('./differ.js').ChangeEvent[]} changes
|
|
12
152
|
* @returns {PolicyFinding[]}
|
|
13
153
|
*/
|
|
14
|
-
export function
|
|
154
|
+
export function evaluatePack(pack, changes) {
|
|
15
155
|
/** @type {PolicyFinding[]} */
|
|
16
156
|
const findings = [];
|
|
17
|
-
|
|
18
157
|
for (const change of changes) {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (SECRET_KEY_RE.test(pathLower) && (change.type === 'changed' || change.type === 'added')) {
|
|
158
|
+
for (const rule of pack.rules ?? []) {
|
|
159
|
+
if (!ruleMatches(rule, change)) continue;
|
|
23
160
|
findings.push({
|
|
24
|
-
id:
|
|
25
|
-
severity: '
|
|
26
|
-
path,
|
|
27
|
-
message: change
|
|
28
|
-
|
|
29
|
-
: 'Sensitive-looking key changed. Confirm secret rotation and access controls.',
|
|
161
|
+
id: String(rule.id),
|
|
162
|
+
severity: rule.severity ?? 'warn',
|
|
163
|
+
path: change.path ?? '',
|
|
164
|
+
message: formatMessage(rule, change),
|
|
165
|
+
pack: pack.id,
|
|
30
166
|
});
|
|
31
167
|
}
|
|
168
|
+
}
|
|
169
|
+
return findings;
|
|
170
|
+
}
|
|
32
171
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
172
|
+
/**
|
|
173
|
+
* Merge findings: same id+path keeps highest severity; ties keep first.
|
|
174
|
+
* @param {PolicyFinding[]} findings
|
|
175
|
+
* @returns {PolicyFinding[]}
|
|
176
|
+
*/
|
|
177
|
+
export function mergeFindings(findings) {
|
|
178
|
+
/** @type {Map<string, PolicyFinding>} */
|
|
179
|
+
const byKey = new Map();
|
|
180
|
+
for (const finding of findings) {
|
|
181
|
+
const key = `${finding.id}::${finding.path}`;
|
|
182
|
+
const existing = byKey.get(key);
|
|
183
|
+
if (!existing) {
|
|
184
|
+
byKey.set(key, finding);
|
|
185
|
+
continue;
|
|
40
186
|
}
|
|
187
|
+
const nextRank = SEVERITY_RANK[finding.severity] ?? 0;
|
|
188
|
+
const prevRank = SEVERITY_RANK[existing.severity] ?? 0;
|
|
189
|
+
if (nextRank > prevRank) byKey.set(key, finding);
|
|
190
|
+
}
|
|
191
|
+
return [...byKey.values()];
|
|
192
|
+
}
|
|
41
193
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
}
|
|
194
|
+
/**
|
|
195
|
+
* @param {string} pluginPath
|
|
196
|
+
* @param {string} cwd
|
|
197
|
+
* @returns {string}
|
|
198
|
+
*/
|
|
199
|
+
function resolvePluginPath(pluginPath, cwd) {
|
|
200
|
+
if (/^https?:\/\//i.test(pluginPath)) {
|
|
201
|
+
throw new Error(`Remote plugins are not allowed: ${pluginPath}`);
|
|
52
202
|
}
|
|
203
|
+
return isAbsolute(pluginPath) ? pluginPath : resolve(cwd, pluginPath);
|
|
204
|
+
}
|
|
53
205
|
|
|
54
|
-
|
|
206
|
+
/**
|
|
207
|
+
* @param {string} pluginPath
|
|
208
|
+
* @param {import('./differ.js').ChangeEvent[]} changes
|
|
209
|
+
* @param {Required<Pick<PolicyEvalOptions, 'cwd' | 'file' | 'profile' | 'source'>> & { packIds: string[] }} ctx
|
|
210
|
+
* @returns {Promise<PolicyFinding[]>}
|
|
211
|
+
*/
|
|
212
|
+
async function runPlugin(pluginPath, changes, ctx) {
|
|
213
|
+
const abs = resolvePluginPath(pluginPath, ctx.cwd);
|
|
214
|
+
if (!existsSync(abs)) {
|
|
215
|
+
throw new Error(`Policy plugin not found: ${pluginPath}`);
|
|
216
|
+
}
|
|
217
|
+
const mod = await import(pathToFileURL(abs).href);
|
|
218
|
+
if (typeof mod.evaluate !== 'function') {
|
|
219
|
+
throw new Error(`Policy plugin missing export evaluate(): ${pluginPath}`);
|
|
220
|
+
}
|
|
221
|
+
const result = await mod.evaluate(changes, ctx);
|
|
222
|
+
if (!Array.isArray(result)) {
|
|
223
|
+
throw new Error(`Policy plugin must return PolicyFinding[]: ${pluginPath}`);
|
|
224
|
+
}
|
|
225
|
+
return result.map((f) => ({
|
|
226
|
+
id: String(f.id),
|
|
227
|
+
severity: f.severity,
|
|
228
|
+
path: String(f.path ?? ''),
|
|
229
|
+
message: String(f.message ?? ''),
|
|
230
|
+
pack: f.pack ?? `plugin:${pluginPath}`,
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Evaluate active packs then plugins.
|
|
236
|
+
* @param {import('./differ.js').ChangeEvent[]} changes
|
|
237
|
+
* @param {PolicyEvalOptions} [options]
|
|
238
|
+
* @returns {Promise<PolicyFinding[]>}
|
|
239
|
+
*/
|
|
240
|
+
export async function evaluatePolicies(changes, options = {}) {
|
|
241
|
+
const cwd = options.cwd ?? process.cwd();
|
|
242
|
+
const packIds = options.policies?.length ? options.policies : ['default'];
|
|
243
|
+
const plugins = options.plugins ?? [];
|
|
244
|
+
|
|
245
|
+
/** @type {PolicyFinding[]} */
|
|
246
|
+
const findings = [];
|
|
247
|
+
for (const packId of packIds) {
|
|
248
|
+
const pack = loadPack(packId, cwd);
|
|
249
|
+
findings.push(...evaluatePack(pack, changes));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const ctx = {
|
|
253
|
+
cwd,
|
|
254
|
+
file: options.file ?? '',
|
|
255
|
+
profile: options.profile ?? null,
|
|
256
|
+
source: options.source ?? 'watch',
|
|
257
|
+
packIds,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
for (const pluginPath of plugins) {
|
|
261
|
+
findings.push(...await runPlugin(pluginPath, changes, ctx));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return mergeFindings(findings);
|
|
55
265
|
}
|
|
56
266
|
|
|
57
267
|
/**
|
|
@@ -64,4 +274,3 @@ export function highestSeverity(findings) {
|
|
|
64
274
|
if (findings.some((f) => f.severity === 'warn')) return 'warn';
|
|
65
275
|
return 'info';
|
|
66
276
|
}
|
|
67
|
-
|
package/src/renderer.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
|
|
3
|
+
const SECRET_PATH_RE = /(secret|token|password|api[_-]?key|private[_-]?key|credential)/i;
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* Format a scalar value for display. Strings get quoted; others are JSON-stringified.
|
|
5
7
|
* @param {unknown} v
|
|
8
|
+
* @param {{ maskSecrets?: boolean, path?: string }} [opts]
|
|
6
9
|
* @returns {string}
|
|
7
10
|
*/
|
|
8
|
-
function fmt(v) {
|
|
11
|
+
function fmt(v, opts = {}) {
|
|
9
12
|
if (v === undefined) return '';
|
|
13
|
+
if (opts.maskSecrets && opts.path && SECRET_PATH_RE.test(opts.path)) {
|
|
14
|
+
return chalk.dim('"***"');
|
|
15
|
+
}
|
|
10
16
|
if (typeof v === 'string') return JSON.stringify(v);
|
|
11
17
|
if (typeof v === 'object' && v !== null) return JSON.stringify(v);
|
|
12
18
|
return String(v);
|
|
@@ -24,35 +30,36 @@ function timestamp() {
|
|
|
24
30
|
* Render a single change event as a colored string.
|
|
25
31
|
* @param {import('./differ.js').ChangeEvent} event
|
|
26
32
|
* @param {'compact' | 'verbose'} mode
|
|
33
|
+
* @param {{ maskSecrets?: boolean }} [opts]
|
|
27
34
|
* @returns {string}
|
|
28
35
|
*/
|
|
29
|
-
function renderEvent(event, mode) {
|
|
36
|
+
function renderEvent(event, mode, opts = {}) {
|
|
30
37
|
const { type, path, before, after, note } = event;
|
|
38
|
+
const maskOpts = { maskSecrets: Boolean(opts.maskSecrets), path };
|
|
31
39
|
|
|
32
40
|
if (type === 'added') {
|
|
33
|
-
const line = ` ${chalk.green('+')} ${chalk.green(path)}: ${chalk.green(fmt(after))}`;
|
|
41
|
+
const line = ` ${chalk.green('+')} ${chalk.green(path)}: ${chalk.green(fmt(after, maskOpts))}`;
|
|
34
42
|
return mode === 'verbose'
|
|
35
43
|
? `${line}\n ${chalk.dim('(key added)')}`
|
|
36
44
|
: line;
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
if (type === 'removed') {
|
|
40
|
-
const line = ` ${chalk.red('-')} ${chalk.red(path)}: ${chalk.red(fmt(before))}`;
|
|
48
|
+
const line = ` ${chalk.red('-')} ${chalk.red(path)}: ${chalk.red(fmt(before, maskOpts))}`;
|
|
41
49
|
return mode === 'verbose'
|
|
42
50
|
? `${line}\n ${chalk.dim('(key removed)')}`
|
|
43
51
|
: line;
|
|
44
52
|
}
|
|
45
53
|
|
|
46
|
-
// changed
|
|
47
54
|
const noteStr = note ? chalk.dim(` [${note}]`) : '';
|
|
48
55
|
if (mode === 'verbose') {
|
|
49
56
|
return [
|
|
50
57
|
` ${chalk.yellow('~')} ${chalk.yellow(path)}${noteStr}`,
|
|
51
|
-
` ${chalk.dim('before:')} ${chalk.red(fmt(before))}`,
|
|
52
|
-
` ${chalk.dim('after: ')} ${chalk.green(fmt(after))}`,
|
|
58
|
+
` ${chalk.dim('before:')} ${chalk.red(fmt(before, maskOpts))}`,
|
|
59
|
+
` ${chalk.dim('after: ')} ${chalk.green(fmt(after, maskOpts))}`,
|
|
53
60
|
].join('\n');
|
|
54
61
|
}
|
|
55
|
-
return ` ${chalk.yellow('~')} ${chalk.yellow(path)}: ${chalk.red(fmt(before))} ${chalk.dim('→')} ${chalk.green(fmt(after))}${noteStr}`;
|
|
62
|
+
return ` ${chalk.yellow('~')} ${chalk.yellow(path)}: ${chalk.red(fmt(before, maskOpts))} ${chalk.dim('→')} ${chalk.green(fmt(after, maskOpts))}${noteStr}`;
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
/**
|
|
@@ -60,15 +67,16 @@ function renderEvent(event, mode) {
|
|
|
60
67
|
* @param {string} filepath
|
|
61
68
|
* @param {import('./differ.js').ChangeEvent[]} events
|
|
62
69
|
* @param {'compact' | 'verbose'} mode
|
|
70
|
+
* @param {{ maskSecrets?: boolean }} [opts]
|
|
63
71
|
*/
|
|
64
|
-
export function renderChanges(filepath, events, mode = 'compact') {
|
|
72
|
+
export function renderChanges(filepath, events, mode = 'compact', opts = {}) {
|
|
65
73
|
const ts = chalk.dim(`[${timestamp()}]`);
|
|
66
74
|
const file = chalk.cyan(filepath);
|
|
67
75
|
const count = `${events.length} change${events.length !== 1 ? 's' : ''}`;
|
|
68
76
|
|
|
69
77
|
console.log(`${ts} ${file} — ${count}`);
|
|
70
78
|
for (const event of events) {
|
|
71
|
-
console.log(renderEvent(event, mode));
|
|
79
|
+
console.log(renderEvent(event, mode, opts));
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
if (mode === 'verbose') {
|
|
@@ -80,8 +88,9 @@ export function renderChanges(filepath, events, mode = 'compact') {
|
|
|
80
88
|
* Print a diff result (for --diff mode) to stdout.
|
|
81
89
|
* @param {string} filepath
|
|
82
90
|
* @param {import('./differ.js').ChangeEvent[]} events
|
|
91
|
+
* @param {{ maskSecrets?: boolean }} [opts]
|
|
83
92
|
*/
|
|
84
|
-
export function renderDiff(filepath, events) {
|
|
93
|
+
export function renderDiff(filepath, events, opts = {}) {
|
|
85
94
|
if (events.length === 0) {
|
|
86
95
|
console.log(chalk.green(`✓ ${filepath} matches snapshot — no changes`));
|
|
87
96
|
return;
|
|
@@ -89,7 +98,7 @@ export function renderDiff(filepath, events) {
|
|
|
89
98
|
|
|
90
99
|
console.log(chalk.cyan(`${filepath}`) + ` — ${events.length} change${events.length !== 1 ? 's' : ''} from snapshot:`);
|
|
91
100
|
for (const event of events) {
|
|
92
|
-
console.log(renderEvent(event, 'compact'));
|
|
101
|
+
console.log(renderEvent(event, 'compact', opts));
|
|
93
102
|
}
|
|
94
103
|
}
|
|
95
104
|
|
|
@@ -129,6 +138,43 @@ export function renderPolicyFindings(findings) {
|
|
|
129
138
|
: f.severity === 'warn'
|
|
130
139
|
? chalk.yellow('! policy(warn)')
|
|
131
140
|
: chalk.blue('! policy(info)');
|
|
132
|
-
|
|
141
|
+
const pack = f.pack ? chalk.dim(` [${f.pack}]`) : '';
|
|
142
|
+
console.log(` ${prefix}${pack} ${chalk.cyan(f.path)}: ${f.message}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Mask secret-like values in a plain object tree for CI output.
|
|
148
|
+
* @param {unknown} value
|
|
149
|
+
* @param {string} [path]
|
|
150
|
+
* @returns {unknown}
|
|
151
|
+
*/
|
|
152
|
+
export function maskSensitiveValue(value, path = '') {
|
|
153
|
+
if (SECRET_PATH_RE.test(path)) return '***';
|
|
154
|
+
if (Array.isArray(value)) {
|
|
155
|
+
return value.map((v, i) => maskSensitiveValue(v, `${path}[${i}]`));
|
|
133
156
|
}
|
|
157
|
+
if (value && typeof value === 'object') {
|
|
158
|
+
/** @type {Record<string, unknown>} */
|
|
159
|
+
const out = {};
|
|
160
|
+
for (const [k, v] of Object.entries(value)) {
|
|
161
|
+
const child = path ? `${path}.${k}` : k;
|
|
162
|
+
out[k] = maskSensitiveValue(v, child);
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
return value;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* @param {import('./differ.js').ChangeEvent} event
|
|
171
|
+
* @returns {import('./differ.js').ChangeEvent}
|
|
172
|
+
*/
|
|
173
|
+
export function maskChangeEvent(event) {
|
|
174
|
+
if (!SECRET_PATH_RE.test(event.path ?? '')) return event;
|
|
175
|
+
return {
|
|
176
|
+
...event,
|
|
177
|
+
before: event.before === undefined ? undefined : '***',
|
|
178
|
+
after: event.after === undefined ? undefined : '***',
|
|
179
|
+
};
|
|
134
180
|
}
|
package/src/watcher.js
CHANGED
|
@@ -11,6 +11,8 @@ import { renderWarn, renderInfo } from './renderer.js';
|
|
|
11
11
|
* @property {boolean} [polling] Force polling mode (default: false)
|
|
12
12
|
* @property {string} [mode] Output mode: 'compact' | 'verbose'
|
|
13
13
|
* @property {string[]} [ignorePaths] Key paths to suppress in diffs
|
|
14
|
+
* @property {string | null} [arrayIdKey]
|
|
15
|
+
* @property {boolean} [arrayIgnoreOrder]
|
|
14
16
|
*/
|
|
15
17
|
|
|
16
18
|
/**
|
|
@@ -25,6 +27,11 @@ export function startWatcher(filepath, options = {}, onEvent) {
|
|
|
25
27
|
const interval = options.interval ?? 100;
|
|
26
28
|
const ignorePaths = options.ignorePaths ?? [];
|
|
27
29
|
const polling = options.polling ?? false;
|
|
30
|
+
const diffOpts = {
|
|
31
|
+
ignorePaths,
|
|
32
|
+
arrayIdKey: options.arrayIdKey ?? null,
|
|
33
|
+
arrayIgnoreOrder: Boolean(options.arrayIgnoreOrder),
|
|
34
|
+
};
|
|
28
35
|
|
|
29
36
|
/** @type {unknown | null} */
|
|
30
37
|
let lastGoodState = null;
|
|
@@ -60,7 +67,7 @@ export function startWatcher(filepath, options = {}, onEvent) {
|
|
|
60
67
|
const scheduleRead = (reason) => {
|
|
61
68
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
62
69
|
debounceTimer = setTimeout(() => {
|
|
63
|
-
handleChange(filepath,
|
|
70
|
+
handleChange(filepath, diffOpts, lastGoodState, (newState, events, lifecycle) => {
|
|
64
71
|
if (newState !== null) {
|
|
65
72
|
lastGoodState = newState;
|
|
66
73
|
}
|
|
@@ -111,11 +118,11 @@ export function startWatcher(filepath, options = {}, onEvent) {
|
|
|
111
118
|
/**
|
|
112
119
|
* Internal: re-parse the file and diff against the previous state.
|
|
113
120
|
* @param {string} filepath
|
|
114
|
-
* @param {string[]}
|
|
121
|
+
* @param {{ ignorePaths?: string[], arrayIdKey?: string | null, arrayIgnoreOrder?: boolean }} diffOpts
|
|
115
122
|
* @param {unknown | null} lastGoodState
|
|
116
123
|
* @param {(newState: unknown | null, events: ChangeEvent[], lifecycle: { type: string, message: string } | null) => void} callback
|
|
117
124
|
*/
|
|
118
|
-
function handleChange(filepath,
|
|
125
|
+
function handleChange(filepath, diffOpts, lastGoodState, callback) {
|
|
119
126
|
let newState;
|
|
120
127
|
try {
|
|
121
128
|
newState = parseFile(filepath);
|
|
@@ -132,6 +139,6 @@ function handleChange(filepath, ignorePaths, lastGoodState, callback) {
|
|
|
132
139
|
return;
|
|
133
140
|
}
|
|
134
141
|
|
|
135
|
-
const events = diffTrees(lastGoodState, newState,
|
|
142
|
+
const events = diffTrees(lastGoodState, newState, diffOpts);
|
|
136
143
|
callback(newState, events, null);
|
|
137
144
|
}
|