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
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/myselfsiddharth/Flecto/schemas/flecto-policy-pack-2.0.json",
|
|
4
|
+
"title": "FlectoPolicyPack",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["rules"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"id": { "type": "string", "minLength": 1 },
|
|
10
|
+
"rules": {
|
|
11
|
+
"type": "array",
|
|
12
|
+
"items": { "$ref": "#/$defs/rule" }
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"$defs": {
|
|
16
|
+
"rule": {
|
|
17
|
+
"type": "object",
|
|
18
|
+
"additionalProperties": false,
|
|
19
|
+
"required": ["id", "severity"],
|
|
20
|
+
"properties": {
|
|
21
|
+
"id": { "type": "string", "minLength": 1 },
|
|
22
|
+
"severity": { "enum": ["info", "warn", "error"] },
|
|
23
|
+
"when": {
|
|
24
|
+
"type": "array",
|
|
25
|
+
"minItems": 1,
|
|
26
|
+
"items": { "enum": ["added", "removed", "changed"] }
|
|
27
|
+
},
|
|
28
|
+
"match": {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"additionalProperties": false,
|
|
31
|
+
"properties": {
|
|
32
|
+
"path": { "type": "string" },
|
|
33
|
+
"pathFlags": { "type": "string" },
|
|
34
|
+
"pathEquals": { "type": "string" },
|
|
35
|
+
"pathPrefix": { "type": "string" }
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"beforeEquals": true,
|
|
39
|
+
"afterEquals": true,
|
|
40
|
+
"beforeIn": { "type": "array" },
|
|
41
|
+
"afterIn": { "type": "array" },
|
|
42
|
+
"beforeTruthy": { "const": true },
|
|
43
|
+
"afterTruthy": { "const": true },
|
|
44
|
+
"afterMatches": { "type": "string" },
|
|
45
|
+
"numericJump": {
|
|
46
|
+
"type": "object",
|
|
47
|
+
"additionalProperties": false,
|
|
48
|
+
"required": ["minMultiple"],
|
|
49
|
+
"properties": {
|
|
50
|
+
"minMultiple": {
|
|
51
|
+
"type": "number",
|
|
52
|
+
"exclusiveMinimum": 0
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"numericDelta": {
|
|
57
|
+
"type": "object",
|
|
58
|
+
"additionalProperties": false,
|
|
59
|
+
"required": ["min"],
|
|
60
|
+
"properties": {
|
|
61
|
+
"min": {
|
|
62
|
+
"type": "number",
|
|
63
|
+
"minimum": 0
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"allOf": {
|
|
68
|
+
"type": "array",
|
|
69
|
+
"minItems": 1,
|
|
70
|
+
"items": { "$ref": "#/$defs/clause" }
|
|
71
|
+
},
|
|
72
|
+
"anyOf": {
|
|
73
|
+
"type": "array",
|
|
74
|
+
"minItems": 1,
|
|
75
|
+
"items": { "$ref": "#/$defs/clause" }
|
|
76
|
+
},
|
|
77
|
+
"message": { "type": "string" },
|
|
78
|
+
"messageTemplate": { "type": "string" }
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
"clause": {
|
|
82
|
+
"type": "object",
|
|
83
|
+
"additionalProperties": false,
|
|
84
|
+
"properties": {
|
|
85
|
+
"match": { "$ref": "#/$defs/match" },
|
|
86
|
+
"beforeEquals": true,
|
|
87
|
+
"afterEquals": true,
|
|
88
|
+
"beforeIn": { "type": "array" },
|
|
89
|
+
"afterIn": { "type": "array" },
|
|
90
|
+
"beforeTruthy": { "const": true },
|
|
91
|
+
"afterTruthy": { "const": true },
|
|
92
|
+
"afterMatches": { "type": "string" },
|
|
93
|
+
"numericJump": { "$ref": "#/$defs/numericJump" },
|
|
94
|
+
"numericDelta": { "$ref": "#/$defs/numericDelta" }
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"match": {
|
|
98
|
+
"type": "object",
|
|
99
|
+
"additionalProperties": false,
|
|
100
|
+
"properties": {
|
|
101
|
+
"path": { "type": "string" },
|
|
102
|
+
"pathFlags": { "type": "string" },
|
|
103
|
+
"pathEquals": { "type": "string" },
|
|
104
|
+
"pathPrefix": { "type": "string" }
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
"numericJump": {
|
|
108
|
+
"type": "object",
|
|
109
|
+
"additionalProperties": false,
|
|
110
|
+
"required": ["minMultiple"],
|
|
111
|
+
"properties": {
|
|
112
|
+
"minMultiple": { "type": "number", "exclusiveMinimum": 0 }
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
"numericDelta": {
|
|
116
|
+
"type": "object",
|
|
117
|
+
"additionalProperties": false,
|
|
118
|
+
"required": ["min"],
|
|
119
|
+
"properties": {
|
|
120
|
+
"min": { "type": "number", "minimum": 0 }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
package/src/alerter.js
CHANGED
|
@@ -11,16 +11,17 @@ const MAX_ENV_CHANGES_CHARS = 16_000;
|
|
|
11
11
|
let alertQueue = Promise.resolve();
|
|
12
12
|
|
|
13
13
|
function enqueue(fn) {
|
|
14
|
-
|
|
15
|
-
.then(
|
|
14
|
+
const result = alertQueue
|
|
15
|
+
.then(() => fn());
|
|
16
|
+
alertQueue = result
|
|
16
17
|
.catch((err) => {
|
|
17
18
|
renderWarn(`Alert pipeline error: ${err?.message ?? String(err)}`);
|
|
18
19
|
});
|
|
19
|
-
return
|
|
20
|
+
return result;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
|
-
* @param {import('./envelope.js').
|
|
24
|
+
* @param {import('./envelope.js').FlectoEnvelope} envelope
|
|
24
25
|
*/
|
|
25
26
|
function buildCommandEnv(envelope) {
|
|
26
27
|
const json = JSON.stringify(envelope.changes);
|
|
@@ -54,7 +55,7 @@ function buildCommandEnv(envelope) {
|
|
|
54
55
|
|
|
55
56
|
/**
|
|
56
57
|
* @param {string} command
|
|
57
|
-
* @param {import('./envelope.js').
|
|
58
|
+
* @param {import('./envelope.js').FlectoEnvelope} envelope
|
|
58
59
|
* @returns {Promise<boolean>}
|
|
59
60
|
*/
|
|
60
61
|
export function runCommand(command, envelope) {
|
|
@@ -93,7 +94,7 @@ function sleep(ms) {
|
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
/**
|
|
96
|
-
* @param {import('./envelope.js').
|
|
97
|
+
* @param {import('./envelope.js').FlectoEnvelope} envelope
|
|
97
98
|
*/
|
|
98
99
|
function enqueuePersistent(envelope) {
|
|
99
100
|
mkdirSync(ALERT_QUEUE_DIR, { recursive: true });
|
|
@@ -102,7 +103,7 @@ function enqueuePersistent(envelope) {
|
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
/**
|
|
105
|
-
* @param {(envelope: import('./envelope.js').
|
|
106
|
+
* @param {(envelope: import('./envelope.js').FlectoEnvelope) => Promise<boolean>} deliver
|
|
106
107
|
*/
|
|
107
108
|
async function flushPersistentQueue(deliver) {
|
|
108
109
|
try {
|
|
@@ -133,7 +134,7 @@ async function flushPersistentQueue(deliver) {
|
|
|
133
134
|
|
|
134
135
|
/**
|
|
135
136
|
* @param {string} url
|
|
136
|
-
* @param {import('./envelope.js').
|
|
137
|
+
* @param {import('./envelope.js').FlectoEnvelope} envelope
|
|
137
138
|
* @param {{ headers?: Record<string, string>, timeoutMs?: number, retries?: number }} [options]
|
|
138
139
|
* @returns {Promise<boolean>}
|
|
139
140
|
*/
|
|
@@ -186,7 +187,7 @@ export async function postWebhook(url, envelope, options = {}) {
|
|
|
186
187
|
|
|
187
188
|
/**
|
|
188
189
|
* @param {{ webhook?: string, webhookHeaders?: Record<string, string>, webhookTimeoutMs?: number, webhookRetries?: number }} options
|
|
189
|
-
* @param {import('./envelope.js').
|
|
190
|
+
* @param {import('./envelope.js').FlectoEnvelope} envelope
|
|
190
191
|
*/
|
|
191
192
|
async function deliverWebhook(options, envelope) {
|
|
192
193
|
if (!options.webhook) return true;
|
|
@@ -219,7 +220,7 @@ function applyFailurePolicy(options, ok) {
|
|
|
219
220
|
* deliveryMode?: 'best-effort' | 'at-least-once',
|
|
220
221
|
* onAlertFailure?: 'warn' | 'exit' | 'retry'
|
|
221
222
|
* }} options
|
|
222
|
-
* @param {import('./envelope.js').
|
|
223
|
+
* @param {import('./envelope.js').FlectoEnvelope} envelope
|
|
223
224
|
* @returns {Promise<{ ok: boolean }>}
|
|
224
225
|
*/
|
|
225
226
|
export async function fireAlerts(options, envelope) {
|
package/src/config.js
CHANGED
|
@@ -43,6 +43,17 @@ export function loadRcConfig(cwd = process.cwd()) {
|
|
|
43
43
|
return { path: null, config: null };
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Resolve profile name: CLI > FLECTO_PROFILE > none.
|
|
48
|
+
* @param {string | undefined} cliProfile
|
|
49
|
+
* @returns {string | undefined}
|
|
50
|
+
*/
|
|
51
|
+
export function resolveProfileName(cliProfile) {
|
|
52
|
+
if (cliProfile) return String(cliProfile);
|
|
53
|
+
if (process.env.FLECTO_PROFILE) return String(process.env.FLECTO_PROFILE);
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
46
57
|
/**
|
|
47
58
|
* Resolve effective options with optional profile and CLI overrides.
|
|
48
59
|
* @param {FlectoRc | null} config
|
|
@@ -55,6 +66,42 @@ export function resolveEffectiveOptions(config, profile, cliOverrides = {}) {
|
|
|
55
66
|
return { ...defaults, ...profileOptions, ...cliOverrides };
|
|
56
67
|
}
|
|
57
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Normalize policy-related effective options.
|
|
71
|
+
* @param {Record<string, unknown>} effective
|
|
72
|
+
*/
|
|
73
|
+
export function resolvePolicyOptions(effective) {
|
|
74
|
+
const policiesRaw = effective.policies;
|
|
75
|
+
const pluginsRaw = effective.plugins;
|
|
76
|
+
const severityRemapRaw = effective.severityRemap;
|
|
77
|
+
const policies = Array.isArray(policiesRaw)
|
|
78
|
+
? policiesRaw.map(String)
|
|
79
|
+
: typeof policiesRaw === 'string'
|
|
80
|
+
? String(policiesRaw).split(',').map((s) => s.trim()).filter(Boolean)
|
|
81
|
+
: ['default'];
|
|
82
|
+
const plugins = Array.isArray(pluginsRaw)
|
|
83
|
+
? pluginsRaw.map(String)
|
|
84
|
+
: typeof pluginsRaw === 'string'
|
|
85
|
+
? String(pluginsRaw).split(',').map((s) => s.trim()).filter(Boolean)
|
|
86
|
+
: [];
|
|
87
|
+
if (
|
|
88
|
+
severityRemapRaw !== undefined
|
|
89
|
+
&& (severityRemapRaw === null || Array.isArray(severityRemapRaw) || typeof severityRemapRaw !== 'object')
|
|
90
|
+
) {
|
|
91
|
+
throw new Error('severityRemap must be an object mapping rule ids to info, warn, error, or off');
|
|
92
|
+
}
|
|
93
|
+
const severityRemap = {};
|
|
94
|
+
for (const [ruleId, severity] of Object.entries(severityRemapRaw ?? {})) {
|
|
95
|
+
if (!['info', 'warn', 'error', 'off'].includes(severity)) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`severityRemap for "${ruleId}" must be one of: info, warn, error, off`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
severityRemap[ruleId] = severity;
|
|
101
|
+
}
|
|
102
|
+
return { policies, plugins, severityRemap };
|
|
103
|
+
}
|
|
104
|
+
|
|
58
105
|
/**
|
|
59
106
|
* Expand file patterns from rc include/files and direct CLI inputs.
|
|
60
107
|
* @param {{ cwd?: string, files?: string[], include?: string[], exclude?: string[] }} input
|
|
@@ -93,15 +140,25 @@ export function initRcFile(cwd = process.cwd()) {
|
|
|
93
140
|
ignore: ['**.updated_at'],
|
|
94
141
|
deliveryMode: 'best-effort',
|
|
95
142
|
onAlertFailure: 'warn',
|
|
143
|
+
policies: ['default'],
|
|
144
|
+
plugins: [],
|
|
145
|
+
arrayIdKey: null,
|
|
146
|
+
arrayId: true,
|
|
147
|
+
arrayIgnoreOrder: false,
|
|
148
|
+
maskSecrets: false,
|
|
96
149
|
},
|
|
97
150
|
profiles: {
|
|
98
151
|
dev: { mode: 'verbose' },
|
|
99
152
|
ci: { failOn: 'policy,error' },
|
|
153
|
+
prod: {
|
|
154
|
+
policies: ['default', 'strict-prod'],
|
|
155
|
+
severityRemap: { 'pool-size-jump': 'error' },
|
|
156
|
+
maskSecrets: true,
|
|
157
|
+
},
|
|
100
158
|
},
|
|
101
|
-
files: ['config/**/*.yaml', '.env'],
|
|
159
|
+
files: ['config/**/*.{yaml,yml,json,toml,ini}', '.env', '.env.*', '*.env'],
|
|
102
160
|
exclude: ['**/node_modules/**'],
|
|
103
161
|
};
|
|
104
162
|
writeFileSync(path, JSON.stringify(starter, null, 2), 'utf8');
|
|
105
163
|
return path;
|
|
106
164
|
}
|
|
107
|
-
|
package/src/differ.js
CHANGED
|
@@ -61,10 +61,9 @@ function makeIgnoreMatcher(patterns) {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
if (keyAnywhere.size > 0) {
|
|
64
|
+
const pathParts = splitPathParts(path);
|
|
64
65
|
for (const key of keyAnywhere) {
|
|
65
|
-
if (
|
|
66
|
-
if (path.includes(`.${key}`)) return true;
|
|
67
|
-
if (path.includes(`].${key}`)) return true;
|
|
66
|
+
if (pathParts.includes(key)) return true;
|
|
68
67
|
}
|
|
69
68
|
}
|
|
70
69
|
|
|
@@ -139,8 +138,9 @@ function matchParts(patternParts, pathParts) {
|
|
|
139
138
|
* @param {unknown} after
|
|
140
139
|
* @param {string} path
|
|
141
140
|
* @param {ChangeEvent[]} events accumulator
|
|
141
|
+
* @param {{ arrayIdKey?: string | null, arrayIdentity?: boolean, arrayIgnoreOrder?: boolean }} [options]
|
|
142
142
|
*/
|
|
143
|
-
function diffValues(before, after, path, events) {
|
|
143
|
+
function diffValues(before, after, path, events, options = {}) {
|
|
144
144
|
const beforeIsObj = isPlainObject(before);
|
|
145
145
|
const afterIsObj = isPlainObject(after);
|
|
146
146
|
const beforeIsArr = Array.isArray(before);
|
|
@@ -148,13 +148,13 @@ function diffValues(before, after, path, events) {
|
|
|
148
148
|
|
|
149
149
|
// Both plain objects → recurse into keys
|
|
150
150
|
if (beforeIsObj && afterIsObj) {
|
|
151
|
-
diffObjects(before, after, path, events);
|
|
151
|
+
diffObjects(before, after, path, events, options);
|
|
152
152
|
return;
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
// Both arrays → diff by index
|
|
155
|
+
// Both arrays → diff by index or identity key
|
|
156
156
|
if (beforeIsArr && afterIsArr) {
|
|
157
|
-
diffArrays(before, after, path, events);
|
|
157
|
+
diffArrays(before, after, path, events, options);
|
|
158
158
|
return;
|
|
159
159
|
}
|
|
160
160
|
|
|
@@ -199,8 +199,9 @@ function diffValues(before, after, path, events) {
|
|
|
199
199
|
* @param {Record<string, unknown>} after
|
|
200
200
|
* @param {string} basePath
|
|
201
201
|
* @param {ChangeEvent[]} events
|
|
202
|
+
* @param {{ arrayIdKey?: string | null, arrayIdentity?: boolean, arrayIgnoreOrder?: boolean }} [options]
|
|
202
203
|
*/
|
|
203
|
-
function diffObjects(before, after, basePath, events) {
|
|
204
|
+
function diffObjects(before, after, basePath, events, options = {}) {
|
|
204
205
|
const beforeKeys = new Set(Object.keys(before));
|
|
205
206
|
const afterKeys = new Set(Object.keys(after));
|
|
206
207
|
|
|
@@ -224,19 +225,150 @@ function diffObjects(before, after, basePath, events) {
|
|
|
224
225
|
for (const key of beforeKeys) {
|
|
225
226
|
if (afterKeys.has(key)) {
|
|
226
227
|
const childPath = basePath ? `${basePath}.${key}` : key;
|
|
227
|
-
diffValues(before[key], after[key], childPath, events);
|
|
228
|
+
diffValues(before[key], after[key], childPath, events, options);
|
|
228
229
|
}
|
|
229
230
|
}
|
|
230
231
|
}
|
|
231
232
|
|
|
232
233
|
/**
|
|
233
|
-
*
|
|
234
|
+
* @param {unknown} item
|
|
235
|
+
* @param {string} idKey
|
|
236
|
+
* @returns {string | null}
|
|
237
|
+
*/
|
|
238
|
+
function identityKey(item, idKey) {
|
|
239
|
+
if (!isPlainObject(item)) return null;
|
|
240
|
+
if (!Object.prototype.hasOwnProperty.call(item, idKey)) return null;
|
|
241
|
+
const v = item[idKey];
|
|
242
|
+
if (v == null || typeof v === 'object') return null;
|
|
243
|
+
return String(v);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* @param {unknown[]} items
|
|
248
|
+
* @param {string} idKey
|
|
249
|
+
* @returns {Map<string, { value: unknown, index: number }> | null}
|
|
250
|
+
*/
|
|
251
|
+
function identityMap(items, idKey) {
|
|
252
|
+
/** @type {Map<string, { value: unknown, index: number }>} */
|
|
253
|
+
const map = new Map();
|
|
254
|
+
for (let i = 0; i < items.length; i++) {
|
|
255
|
+
const key = identityKey(items[i], idKey);
|
|
256
|
+
if (key == null || map.has(key)) return null;
|
|
257
|
+
map.set(key, { value: items[i], index: i });
|
|
258
|
+
}
|
|
259
|
+
return map;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Select a configured identity key, or auto-detect id then name.
|
|
264
|
+
* @param {unknown[]} before
|
|
265
|
+
* @param {unknown[]} after
|
|
266
|
+
* @param {{ arrayIdKey?: string | null, arrayIdentity?: boolean }} options
|
|
267
|
+
* @returns {string | null}
|
|
268
|
+
*/
|
|
269
|
+
function resolveArrayIdKey(before, after, options) {
|
|
270
|
+
// An explicit key always enables identity matching, even when arrayIdentity
|
|
271
|
+
// is off (e.g. .flectorc arrayId:false plus CLI --array-id-key).
|
|
272
|
+
const configured = options.arrayIdKey ? String(options.arrayIdKey) : null;
|
|
273
|
+
if (configured) return configured;
|
|
274
|
+
|
|
275
|
+
if (options.arrayIdentity === false) return null;
|
|
276
|
+
|
|
277
|
+
for (const candidate of ['id', 'name']) {
|
|
278
|
+
if (identityMap(before, candidate) && identityMap(after, candidate)) {
|
|
279
|
+
return candidate;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Produce a stable JSON signature for order-insensitive array comparison.
|
|
287
|
+
* Object keys are sorted so their insertion order does not affect equality.
|
|
288
|
+
* Non-JSON values fall back to their identity, avoiding serialization errors.
|
|
289
|
+
* @param {unknown} value
|
|
290
|
+
* @returns {unknown}
|
|
291
|
+
*/
|
|
292
|
+
function arraySignature(value) {
|
|
293
|
+
try {
|
|
294
|
+
const canonicalize = (item) => {
|
|
295
|
+
if (Array.isArray(item)) return item.map(canonicalize);
|
|
296
|
+
if (
|
|
297
|
+
!isPlainObject(item) ||
|
|
298
|
+
(Object.getPrototypeOf(item) !== Object.prototype && Object.getPrototypeOf(item) !== null)
|
|
299
|
+
) {
|
|
300
|
+
return item;
|
|
301
|
+
}
|
|
302
|
+
return Object.fromEntries(
|
|
303
|
+
Object.keys(item)
|
|
304
|
+
.sort()
|
|
305
|
+
.map((key) => [key, canonicalize(item[key])])
|
|
306
|
+
);
|
|
307
|
+
};
|
|
308
|
+
const signature = JSON.stringify(canonicalize(value));
|
|
309
|
+
return signature === undefined ? value : signature;
|
|
310
|
+
} catch {
|
|
311
|
+
return value;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Diff arrays by configured or auto-detected identity key; otherwise by index.
|
|
234
317
|
* @param {unknown[]} before
|
|
235
318
|
* @param {unknown[]} after
|
|
236
319
|
* @param {string} basePath
|
|
237
320
|
* @param {ChangeEvent[]} events
|
|
321
|
+
* @param {{ arrayIdKey?: string | null, arrayIdentity?: boolean, arrayIgnoreOrder?: boolean }} [options]
|
|
238
322
|
*/
|
|
239
|
-
function diffArrays(before, after, basePath, events) {
|
|
323
|
+
function diffArrays(before, after, basePath, events, options = {}) {
|
|
324
|
+
const idKey = resolveArrayIdKey(before, after, options);
|
|
325
|
+
|
|
326
|
+
if (idKey) {
|
|
327
|
+
const beforeMap = identityMap(before, idKey);
|
|
328
|
+
const afterMap = identityMap(after, idKey);
|
|
329
|
+
|
|
330
|
+
if (beforeMap && afterMap) {
|
|
331
|
+
for (const [key, afterItem] of afterMap) {
|
|
332
|
+
const childPath = `${basePath}[${JSON.stringify(key)}]`;
|
|
333
|
+
if (!beforeMap.has(key)) {
|
|
334
|
+
events.push({ type: 'added', path: childPath, after: afterItem.value });
|
|
335
|
+
} else {
|
|
336
|
+
diffValues(beforeMap.get(key).value, afterItem.value, childPath, events, options);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
for (const [key, beforeItem] of beforeMap) {
|
|
340
|
+
if (!afterMap.has(key)) {
|
|
341
|
+
const childPath = `${basePath}[${JSON.stringify(key)}]`;
|
|
342
|
+
events.push({ type: 'removed', path: childPath, before: beforeItem.value });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (options.arrayIgnoreOrder) {
|
|
350
|
+
// Order-insensitive without id key: multiset compare via JSON signatures
|
|
351
|
+
/** @type {Map<unknown, { count: number, value: unknown }>} */
|
|
352
|
+
const counts = new Map();
|
|
353
|
+
for (const value of before) {
|
|
354
|
+
const signature = arraySignature(value);
|
|
355
|
+
const entry = counts.get(signature);
|
|
356
|
+
if (entry) entry.count++;
|
|
357
|
+
else counts.set(signature, { count: 1, value });
|
|
358
|
+
}
|
|
359
|
+
for (const value of after) {
|
|
360
|
+
const entry = counts.get(arraySignature(value));
|
|
361
|
+
if (entry?.count > 0) entry.count--;
|
|
362
|
+
else events.push({ type: 'added', path: `${basePath}[*]`, after: value });
|
|
363
|
+
}
|
|
364
|
+
for (const entry of counts.values()) {
|
|
365
|
+
for (let i = 0; i < entry.count; i++) {
|
|
366
|
+
events.push({ type: 'removed', path: `${basePath}[*]`, before: entry.value });
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
240
372
|
const maxLen = Math.max(before.length, after.length);
|
|
241
373
|
for (let i = 0; i < maxLen; i++) {
|
|
242
374
|
const childPath = `${basePath}[${i}]`;
|
|
@@ -245,7 +377,7 @@ function diffArrays(before, after, basePath, events) {
|
|
|
245
377
|
} else if (i >= after.length) {
|
|
246
378
|
events.push({ type: 'removed', path: childPath, before: before[i] });
|
|
247
379
|
} else {
|
|
248
|
-
diffValues(before[i], after[i], childPath, events);
|
|
380
|
+
diffValues(before[i], after[i], childPath, events, options);
|
|
249
381
|
}
|
|
250
382
|
}
|
|
251
383
|
}
|
|
@@ -255,22 +387,27 @@ function diffArrays(before, after, basePath, events) {
|
|
|
255
387
|
* Accepts any JSON-like values at the root (object/array/scalar/null).
|
|
256
388
|
* @param {unknown} before
|
|
257
389
|
* @param {unknown} after
|
|
258
|
-
* @param {{ ignorePaths?: string[] }} [options]
|
|
390
|
+
* @param {{ ignorePaths?: string[], arrayIdKey?: string | null, arrayIdentity?: boolean, arrayIgnoreOrder?: boolean }} [options]
|
|
259
391
|
* @returns {ChangeEvent[]}
|
|
260
392
|
*/
|
|
261
393
|
export function diffTrees(before, after, options = {}) {
|
|
262
394
|
/** @type {ChangeEvent[]} */
|
|
263
395
|
const events = [];
|
|
264
396
|
const ignore = makeIgnoreMatcher(options.ignorePaths ?? []);
|
|
397
|
+
const diffOpts = {
|
|
398
|
+
arrayIdKey: options.arrayIdKey ?? null,
|
|
399
|
+
arrayIdentity: options.arrayIdentity !== false,
|
|
400
|
+
arrayIgnoreOrder: Boolean(options.arrayIgnoreOrder),
|
|
401
|
+
};
|
|
265
402
|
|
|
266
403
|
// Root handling: avoid assuming object roots.
|
|
267
404
|
if (isPlainObject(before) && isPlainObject(after)) {
|
|
268
|
-
diffObjects(before, after, '', events);
|
|
405
|
+
diffObjects(before, after, '', events, diffOpts);
|
|
269
406
|
} else if (Array.isArray(before) && Array.isArray(after)) {
|
|
270
|
-
diffArrays(before, after, '', events);
|
|
407
|
+
diffArrays(before, after, '', events, diffOpts);
|
|
271
408
|
} else {
|
|
272
409
|
// Compare as a single root value. Use "<root>" so we can still ignore it if desired.
|
|
273
|
-
diffValues(before, after, '<root>', events);
|
|
410
|
+
diffValues(before, after, '<root>', events, diffOpts);
|
|
274
411
|
}
|
|
275
412
|
|
|
276
413
|
return events.filter(e => !ignore(e.path));
|
package/src/envelope.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'crypto';
|
|
2
2
|
|
|
3
|
-
export const EVENT_SCHEMA_VERSION = '
|
|
3
|
+
export const EVENT_SCHEMA_VERSION = '2.0';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @typedef {'watch' | 'ci' | 'diff'} EventSource
|
|
@@ -15,8 +15,9 @@ export const EVENT_SCHEMA_VERSION = '1.1';
|
|
|
15
15
|
* emitted_at: string,
|
|
16
16
|
* file: string,
|
|
17
17
|
* changes: import('./differ.js').ChangeEvent[],
|
|
18
|
+
* policies?: import('./policy.js').PolicyFinding[],
|
|
18
19
|
* lifecycle?: { type: string, message: string }
|
|
19
|
-
* }}
|
|
20
|
+
* }} FlectoEnvelope
|
|
20
21
|
*/
|
|
21
22
|
|
|
22
23
|
/**
|
|
@@ -25,10 +26,11 @@ export const EVENT_SCHEMA_VERSION = '1.1';
|
|
|
25
26
|
* file: string,
|
|
26
27
|
* source: EventSource,
|
|
27
28
|
* changes?: import('./differ.js').ChangeEvent[],
|
|
29
|
+
* policies?: import('./policy.js').PolicyFinding[],
|
|
28
30
|
* lifecycle?: { type: string, message: string },
|
|
29
31
|
* batchId?: string
|
|
30
32
|
* }} input
|
|
31
|
-
* @returns {
|
|
33
|
+
* @returns {FlectoEnvelope}
|
|
32
34
|
*/
|
|
33
35
|
export function createEnvelope(input) {
|
|
34
36
|
const batchId = input.batchId ?? randomUUID();
|
|
@@ -41,7 +43,7 @@ export function createEnvelope(input) {
|
|
|
41
43
|
emitted_at: new Date().toISOString(),
|
|
42
44
|
file: input.file,
|
|
43
45
|
changes: input.changes ?? [],
|
|
46
|
+
policies: input.policies ?? [],
|
|
44
47
|
lifecycle: input.lifecycle,
|
|
45
48
|
};
|
|
46
49
|
}
|
|
47
|
-
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "compose",
|
|
3
|
+
"rules": [
|
|
4
|
+
{
|
|
5
|
+
"id": "compose-privileged-service",
|
|
6
|
+
"severity": "error",
|
|
7
|
+
"when": ["added", "changed"],
|
|
8
|
+
"match": {
|
|
9
|
+
"path": "(^|\\.)privileged$"
|
|
10
|
+
},
|
|
11
|
+
"afterEquals": true,
|
|
12
|
+
"message": "Docker Compose service is privileged. Remove privileged mode or document the required host access."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"id": "compose-host-network",
|
|
16
|
+
"severity": "error",
|
|
17
|
+
"when": ["added", "changed"],
|
|
18
|
+
"match": {
|
|
19
|
+
"path": "(^|\\.)network_mode$"
|
|
20
|
+
},
|
|
21
|
+
"afterIn": ["host", "host:"],
|
|
22
|
+
"message": "Docker Compose service uses the host network. Confirm the service needs to bypass network isolation."
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"id": "compose-docker-socket-bind",
|
|
26
|
+
"severity": "error",
|
|
27
|
+
"when": ["added", "changed"],
|
|
28
|
+
"match": {
|
|
29
|
+
"path": "(^|\\.)volumes\\[\\d+\\](\\.source)?$"
|
|
30
|
+
},
|
|
31
|
+
"afterMatches": "^/var/run/docker\\.sock(?::|$)",
|
|
32
|
+
"message": "Docker Compose service mounts the Docker socket. This grants control over the host Docker daemon."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"id": "compose-sensitive-host-bind",
|
|
36
|
+
"severity": "warn",
|
|
37
|
+
"when": ["added", "changed"],
|
|
38
|
+
"match": {
|
|
39
|
+
"path": "(^|\\.)volumes\\[\\d+\\](\\.source)?$"
|
|
40
|
+
},
|
|
41
|
+
"afterMatches": "^/(?:etc|root|home|var/lib)(?::|$)",
|
|
42
|
+
"message": "Docker Compose service bind-mounts a sensitive host directory. Confirm the container needs this host access."
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "default",
|
|
3
|
+
"rules": [
|
|
4
|
+
{
|
|
5
|
+
"id": "secret-key-changed",
|
|
6
|
+
"severity": "error",
|
|
7
|
+
"when": ["added", "changed"],
|
|
8
|
+
"match": {
|
|
9
|
+
"path": "(secret|token|password|api[_-]?key|private[_-]?key)",
|
|
10
|
+
"pathFlags": "i"
|
|
11
|
+
},
|
|
12
|
+
"message": "Sensitive-looking key added or changed. Confirm secret storage, rotation, and access controls."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"id": "dangerous-toggle-enabled",
|
|
16
|
+
"severity": "error",
|
|
17
|
+
"when": ["changed"],
|
|
18
|
+
"match": {
|
|
19
|
+
"path": "(debug|allow_insecure|disable_tls|skip_tls_verify)",
|
|
20
|
+
"pathFlags": "i"
|
|
21
|
+
},
|
|
22
|
+
"afterTruthy": true,
|
|
23
|
+
"message": "Potentially dangerous toggle enabled."
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"id": "pool-size-jump",
|
|
27
|
+
"severity": "warn",
|
|
28
|
+
"when": ["changed"],
|
|
29
|
+
"match": {
|
|
30
|
+
"path": "pool_size$",
|
|
31
|
+
"pathFlags": "i"
|
|
32
|
+
},
|
|
33
|
+
"numericJump": { "minMultiple": 2 },
|
|
34
|
+
"messageTemplate": "Pool size increased from {before} to {after} (>=2x)."
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|