flecto 1.0.2 → 2.1.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 +103 -0
- package/README.md +297 -109
- package/index.js +362 -35
- package/package.json +8 -6
- package/schemas/flecto-envelope-2.0.json +65 -0
- package/schemas/flecto-policy-pack-2.0.json +124 -0
- package/src/alerter.js +11 -10
- package/src/config.js +59 -2
- package/src/differ.js +153 -16
- package/src/envelope.js +6 -4
- package/src/packs/compose.json +45 -0
- package/src/packs/default.json +37 -0
- package/src/packs/node-runtime.json +44 -0
- package/src/packs/strict-prod.json +37 -0
- package/src/parser.js +80 -14
- package/src/policy-test.js +124 -0
- package/src/policy.js +541 -34
- package/src/renderer.js +62 -13
- package/src/watcher.js +28 -11
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,46 @@ 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 (
|
|
158
|
+
value
|
|
159
|
+
&& typeof value === 'object'
|
|
160
|
+
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)
|
|
161
|
+
) {
|
|
162
|
+
/** @type {Record<string, unknown>} */
|
|
163
|
+
const out = {};
|
|
164
|
+
for (const [k, v] of Object.entries(value)) {
|
|
165
|
+
const child = path ? `${path}.${k}` : k;
|
|
166
|
+
out[k] = maskSensitiveValue(v, child);
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
return value;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @param {import('./differ.js').ChangeEvent} event
|
|
175
|
+
* @returns {import('./differ.js').ChangeEvent}
|
|
176
|
+
*/
|
|
177
|
+
export function maskChangeEvent(event) {
|
|
178
|
+
return {
|
|
179
|
+
...event,
|
|
180
|
+
before: event.before === undefined ? undefined : maskSensitiveValue(event.before, event.path),
|
|
181
|
+
after: event.after === undefined ? undefined : maskSensitiveValue(event.after, event.path),
|
|
182
|
+
};
|
|
134
183
|
}
|
package/src/watcher.js
CHANGED
|
@@ -11,6 +11,9 @@ 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} [arrayIdentity]
|
|
16
|
+
* @property {boolean} [arrayIgnoreOrder]
|
|
14
17
|
*/
|
|
15
18
|
|
|
16
19
|
/**
|
|
@@ -18,13 +21,19 @@ import { renderWarn, renderInfo } from './renderer.js';
|
|
|
18
21
|
*
|
|
19
22
|
* @param {string} filepath
|
|
20
23
|
* @param {WatcherOptions} options
|
|
21
|
-
* @param {(event: { kind: 'changes', filepath: string, events: ChangeEvent[] } | { kind: 'lifecycle', filepath: string, lifecycle: { type: string, message: string } }) => void} onEvent
|
|
24
|
+
* @param {(event: { kind: 'changes', filepath: string, events: ChangeEvent[] } | { kind: 'lifecycle', filepath: string, lifecycle: { type: string, message: string } }) => void | Promise<void>} onEvent
|
|
22
25
|
* @returns {import('chokidar').FSWatcher}
|
|
23
26
|
*/
|
|
24
27
|
export function startWatcher(filepath, options = {}, onEvent) {
|
|
25
28
|
const interval = options.interval ?? 100;
|
|
26
29
|
const ignorePaths = options.ignorePaths ?? [];
|
|
27
30
|
const polling = options.polling ?? false;
|
|
31
|
+
const diffOpts = {
|
|
32
|
+
ignorePaths,
|
|
33
|
+
arrayIdKey: options.arrayIdKey ?? null,
|
|
34
|
+
arrayIdentity: options.arrayIdentity !== false,
|
|
35
|
+
arrayIgnoreOrder: Boolean(options.arrayIgnoreOrder),
|
|
36
|
+
};
|
|
28
37
|
|
|
29
38
|
/** @type {unknown | null} */
|
|
30
39
|
let lastGoodState = null;
|
|
@@ -35,7 +44,7 @@ export function startWatcher(filepath, options = {}, onEvent) {
|
|
|
35
44
|
} catch (err) {
|
|
36
45
|
renderWarn(`Could not parse initial state of "${filepath}": ${err.message}`);
|
|
37
46
|
renderWarn('Watching anyway — will use first successful parse as baseline.');
|
|
38
|
-
onEvent
|
|
47
|
+
safelyEmit(onEvent, {
|
|
39
48
|
kind: 'lifecycle',
|
|
40
49
|
filepath,
|
|
41
50
|
lifecycle: { type: 'initial-parse-failed', message: err.message },
|
|
@@ -60,17 +69,17 @@ export function startWatcher(filepath, options = {}, onEvent) {
|
|
|
60
69
|
const scheduleRead = (reason) => {
|
|
61
70
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
62
71
|
debounceTimer = setTimeout(() => {
|
|
63
|
-
handleChange(filepath,
|
|
72
|
+
handleChange(filepath, diffOpts, lastGoodState, (newState, events, lifecycle) => {
|
|
64
73
|
if (newState !== null) {
|
|
65
74
|
lastGoodState = newState;
|
|
66
75
|
}
|
|
67
76
|
if (lifecycle) {
|
|
68
|
-
onEvent
|
|
77
|
+
safelyEmit(onEvent, { kind: 'lifecycle', filepath, lifecycle });
|
|
69
78
|
}
|
|
70
79
|
if (events.length > 0) {
|
|
71
|
-
onEvent
|
|
80
|
+
safelyEmit(onEvent, { kind: 'changes', filepath, events });
|
|
72
81
|
} else if (reason === 'add') {
|
|
73
|
-
onEvent
|
|
82
|
+
safelyEmit(onEvent, {
|
|
74
83
|
kind: 'lifecycle',
|
|
75
84
|
filepath,
|
|
76
85
|
lifecycle: { type: 'file-restored', message: 'File content reloaded after add event.' },
|
|
@@ -89,7 +98,7 @@ export function startWatcher(filepath, options = {}, onEvent) {
|
|
|
89
98
|
watcher.on('unlink', () => {
|
|
90
99
|
// File temporarily missing; keep last good state and wait for add.
|
|
91
100
|
renderWarn(`File disappeared: "${filepath}" (waiting for it to reappear)`);
|
|
92
|
-
onEvent
|
|
101
|
+
safelyEmit(onEvent, {
|
|
93
102
|
kind: 'lifecycle',
|
|
94
103
|
filepath,
|
|
95
104
|
lifecycle: { type: 'file-missing', message: 'File disappeared; waiting for restore.' },
|
|
@@ -98,7 +107,7 @@ export function startWatcher(filepath, options = {}, onEvent) {
|
|
|
98
107
|
|
|
99
108
|
watcher.on('error', (err) => {
|
|
100
109
|
renderWarn(`Watcher error: ${err.message}`);
|
|
101
|
-
onEvent
|
|
110
|
+
safelyEmit(onEvent, {
|
|
102
111
|
kind: 'lifecycle',
|
|
103
112
|
filepath,
|
|
104
113
|
lifecycle: { type: 'watcher-error', message: err.message },
|
|
@@ -108,14 +117,22 @@ export function startWatcher(filepath, options = {}, onEvent) {
|
|
|
108
117
|
return watcher;
|
|
109
118
|
}
|
|
110
119
|
|
|
120
|
+
function safelyEmit(onEvent, event) {
|
|
121
|
+
Promise.resolve()
|
|
122
|
+
.then(() => onEvent(event))
|
|
123
|
+
.catch((err) => {
|
|
124
|
+
renderWarn(`Watcher event handler error: ${err?.message ?? String(err)}`);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
111
128
|
/**
|
|
112
129
|
* Internal: re-parse the file and diff against the previous state.
|
|
113
130
|
* @param {string} filepath
|
|
114
|
-
* @param {string[]}
|
|
131
|
+
* @param {{ ignorePaths?: string[], arrayIdKey?: string | null, arrayIdentity?: boolean, arrayIgnoreOrder?: boolean }} diffOpts
|
|
115
132
|
* @param {unknown | null} lastGoodState
|
|
116
133
|
* @param {(newState: unknown | null, events: ChangeEvent[], lifecycle: { type: string, message: string } | null) => void} callback
|
|
117
134
|
*/
|
|
118
|
-
function handleChange(filepath,
|
|
135
|
+
function handleChange(filepath, diffOpts, lastGoodState, callback) {
|
|
119
136
|
let newState;
|
|
120
137
|
try {
|
|
121
138
|
newState = parseFile(filepath);
|
|
@@ -132,6 +149,6 @@ function handleChange(filepath, ignorePaths, lastGoodState, callback) {
|
|
|
132
149
|
return;
|
|
133
150
|
}
|
|
134
151
|
|
|
135
|
-
const events = diffTrees(lastGoodState, newState,
|
|
152
|
+
const events = diffTrees(lastGoodState, newState, diffOpts);
|
|
136
153
|
callback(newState, events, null);
|
|
137
154
|
}
|