flecto 2.0.0 → 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/src/config.js CHANGED
@@ -73,6 +73,7 @@ export function resolveEffectiveOptions(config, profile, cliOverrides = {}) {
73
73
  export function resolvePolicyOptions(effective) {
74
74
  const policiesRaw = effective.policies;
75
75
  const pluginsRaw = effective.plugins;
76
+ const severityRemapRaw = effective.severityRemap;
76
77
  const policies = Array.isArray(policiesRaw)
77
78
  ? policiesRaw.map(String)
78
79
  : typeof policiesRaw === 'string'
@@ -83,7 +84,22 @@ export function resolvePolicyOptions(effective) {
83
84
  : typeof pluginsRaw === 'string'
84
85
  ? String(pluginsRaw).split(',').map((s) => s.trim()).filter(Boolean)
85
86
  : [];
86
- return { policies, plugins };
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 };
87
103
  }
88
104
 
89
105
  /**
@@ -127,13 +143,18 @@ export function initRcFile(cwd = process.cwd()) {
127
143
  policies: ['default'],
128
144
  plugins: [],
129
145
  arrayIdKey: null,
146
+ arrayId: true,
130
147
  arrayIgnoreOrder: false,
131
148
  maskSecrets: false,
132
149
  },
133
150
  profiles: {
134
151
  dev: { mode: 'verbose' },
135
152
  ci: { failOn: 'policy,error' },
136
- prod: { policies: ['default', 'strict-prod'], maskSecrets: true },
153
+ prod: {
154
+ policies: ['default', 'strict-prod'],
155
+ severityRemap: { 'pool-size-jump': 'error' },
156
+ maskSecrets: true,
157
+ },
137
158
  },
138
159
  files: ['config/**/*.{yaml,yml,json,toml,ini}', '.env', '.env.*', '*.env'],
139
160
  exclude: ['**/node_modules/**'],
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 (path === key) return true;
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,7 +138,7 @@ function matchParts(patternParts, pathParts) {
139
138
  * @param {unknown} after
140
139
  * @param {string} path
141
140
  * @param {ChangeEvent[]} events accumulator
142
- * @param {{ arrayIdKey?: string | null, arrayIgnoreOrder?: boolean }} [options]
141
+ * @param {{ arrayIdKey?: string | null, arrayIdentity?: boolean, arrayIgnoreOrder?: boolean }} [options]
143
142
  */
144
143
  function diffValues(before, after, path, events, options = {}) {
145
144
  const beforeIsObj = isPlainObject(before);
@@ -200,7 +199,7 @@ function diffValues(before, after, path, events, options = {}) {
200
199
  * @param {Record<string, unknown>} after
201
200
  * @param {string} basePath
202
201
  * @param {ChangeEvent[]} events
203
- * @param {{ arrayIdKey?: string | null, arrayIgnoreOrder?: boolean }} [options]
202
+ * @param {{ arrayIdKey?: string | null, arrayIdentity?: boolean, arrayIgnoreOrder?: boolean }} [options]
204
203
  */
205
204
  function diffObjects(before, after, basePath, events, options = {}) {
206
205
  const beforeKeys = new Set(Object.keys(before));
@@ -245,43 +244,90 @@ function identityKey(item, idKey) {
245
244
  }
246
245
 
247
246
  /**
248
- * Diff arrays by identity key when configured; otherwise by index.
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.
249
317
  * @param {unknown[]} before
250
318
  * @param {unknown[]} after
251
319
  * @param {string} basePath
252
320
  * @param {ChangeEvent[]} events
253
- * @param {{ arrayIdKey?: string | null, arrayIgnoreOrder?: boolean }} [options]
321
+ * @param {{ arrayIdKey?: string | null, arrayIdentity?: boolean, arrayIgnoreOrder?: boolean }} [options]
254
322
  */
255
323
  function diffArrays(before, after, basePath, events, options = {}) {
256
- const idKey = options.arrayIdKey ? String(options.arrayIdKey) : null;
324
+ const idKey = resolveArrayIdKey(before, after, options);
257
325
 
258
326
  if (idKey) {
259
- /** @type {Map<string, { value: unknown, index: number }>} */
260
- const beforeMap = new Map();
261
- /** @type {Map<string, { value: unknown, index: number }>} */
262
- const afterMap = new Map();
263
- let canUseIdentity = true;
264
-
265
- for (let i = 0; i < before.length; i++) {
266
- const key = identityKey(before[i], idKey);
267
- if (key == null || beforeMap.has(key)) {
268
- canUseIdentity = false;
269
- break;
270
- }
271
- beforeMap.set(key, { value: before[i], index: i });
272
- }
273
- if (canUseIdentity) {
274
- for (let i = 0; i < after.length; i++) {
275
- const key = identityKey(after[i], idKey);
276
- if (key == null || afterMap.has(key)) {
277
- canUseIdentity = false;
278
- break;
279
- }
280
- afterMap.set(key, { value: after[i], index: i });
281
- }
282
- }
327
+ const beforeMap = identityMap(before, idKey);
328
+ const afterMap = identityMap(after, idKey);
283
329
 
284
- if (canUseIdentity) {
330
+ if (beforeMap && afterMap) {
285
331
  for (const [key, afterItem] of afterMap) {
286
332
  const childPath = `${basePath}[${JSON.stringify(key)}]`;
287
333
  if (!beforeMap.has(key)) {
@@ -302,19 +348,22 @@ function diffArrays(before, after, basePath, events, options = {}) {
302
348
 
303
349
  if (options.arrayIgnoreOrder) {
304
350
  // Order-insensitive without id key: multiset compare via JSON signatures
305
- const beforeSigs = before.map((v) => JSON.stringify(v));
306
- const afterSigs = after.map((v) => JSON.stringify(v));
307
- /** @type {Map<string, number>} */
351
+ /** @type {Map<unknown, { count: number, value: unknown }>} */
308
352
  const counts = new Map();
309
- for (const s of beforeSigs) counts.set(s, (counts.get(s) ?? 0) + 1);
310
- for (const s of afterSigs) {
311
- const n = counts.get(s) ?? 0;
312
- if (n > 0) counts.set(s, n - 1);
313
- else events.push({ type: 'added', path: `${basePath}[*]`, after: JSON.parse(s) });
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 });
314
363
  }
315
- for (const [s, n] of counts) {
316
- for (let i = 0; i < n; i++) {
317
- events.push({ type: 'removed', path: `${basePath}[*]`, before: JSON.parse(s) });
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 });
318
367
  }
319
368
  }
320
369
  return;
@@ -338,7 +387,7 @@ function diffArrays(before, after, basePath, events, options = {}) {
338
387
  * Accepts any JSON-like values at the root (object/array/scalar/null).
339
388
  * @param {unknown} before
340
389
  * @param {unknown} after
341
- * @param {{ ignorePaths?: string[], arrayIdKey?: string | null, arrayIgnoreOrder?: boolean }} [options]
390
+ * @param {{ ignorePaths?: string[], arrayIdKey?: string | null, arrayIdentity?: boolean, arrayIgnoreOrder?: boolean }} [options]
342
391
  * @returns {ChangeEvent[]}
343
392
  */
344
393
  export function diffTrees(before, after, options = {}) {
@@ -347,6 +396,7 @@ export function diffTrees(before, after, options = {}) {
347
396
  const ignore = makeIgnoreMatcher(options.ignorePaths ?? []);
348
397
  const diffOpts = {
349
398
  arrayIdKey: options.arrayIdKey ?? null,
399
+ arrayIdentity: options.arrayIdentity !== false,
350
400
  arrayIgnoreOrder: Boolean(options.arrayIgnoreOrder),
351
401
  };
352
402
 
@@ -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
+ }
@@ -19,7 +19,7 @@
19
19
  "path": "(debug|allow_insecure|disable_tls|skip_tls_verify)",
20
20
  "pathFlags": "i"
21
21
  },
22
- "afterEquals": true,
22
+ "afterTruthy": true,
23
23
  "message": "Potentially dangerous toggle enabled."
24
24
  },
25
25
  {
@@ -0,0 +1,44 @@
1
+ {
2
+ "id": "node-runtime",
3
+ "rules": [
4
+ {
5
+ "id": "node-runtime-engine-removed",
6
+ "severity": "warn",
7
+ "when": ["removed"],
8
+ "match": {
9
+ "pathEquals": "engines.node"
10
+ },
11
+ "message": "Node.js engine requirement was removed. Keep a supported runtime floor to avoid accidental runtime downgrades."
12
+ },
13
+ {
14
+ "id": "node-runtime-tls-verification-disabled",
15
+ "severity": "error",
16
+ "when": ["added", "changed"],
17
+ "match": {
18
+ "path": "(^|\\.)NODE_TLS_REJECT_UNAUTHORIZED$"
19
+ },
20
+ "afterIn": [0, "0"],
21
+ "message": "NODE_TLS_REJECT_UNAUTHORIZED disables TLS certificate verification. Remove it and fix the certificate chain."
22
+ },
23
+ {
24
+ "id": "node-runtime-debug-enabled",
25
+ "severity": "warn",
26
+ "when": ["added", "changed"],
27
+ "match": {
28
+ "path": "(^|\\.)NODE_DEBUG$"
29
+ },
30
+ "afterMatches": ".+",
31
+ "message": "NODE_DEBUG is enabled. Confirm verbose runtime debugging is appropriate for this environment."
32
+ },
33
+ {
34
+ "id": "node-runtime-inspector-enabled",
35
+ "severity": "warn",
36
+ "when": ["added", "changed"],
37
+ "match": {
38
+ "path": "(^|\\.)NODE_OPTIONS$"
39
+ },
40
+ "afterMatches": "(^|\\s)--inspect(?:-brk)?(?:=|\\s|$)",
41
+ "message": "Node.js inspector is enabled through NODE_OPTIONS. Avoid exposing debug ports outside trusted development environments."
42
+ }
43
+ ]
44
+ }
@@ -19,7 +19,7 @@
19
19
  "path": "(debug|allow_insecure|disable_tls|skip_tls_verify|permit_all)",
20
20
  "pathFlags": "i"
21
21
  },
22
- "afterEquals": true,
22
+ "afterTruthy": true,
23
23
  "message": "Dangerous toggle enabled in production profile."
24
24
  },
25
25
  {
@@ -0,0 +1,124 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join, resolve } from 'path';
3
+
4
+ import { diffTrees } from './differ.js';
5
+ import { parseFile } from './parser.js';
6
+ import { evaluatePolicies } from './policy.js';
7
+
8
+ const DEFAULT_CONFIG_NAME = 'flecto-policy-test.json';
9
+
10
+ function readJson(path, label) {
11
+ if (!existsSync(path)) {
12
+ throw new Error(`Policy fixture ${label} not found: ${path}`);
13
+ }
14
+ try {
15
+ return JSON.parse(readFileSync(path, 'utf8'));
16
+ } catch (err) {
17
+ throw new Error(`Policy fixture ${label} is not valid JSON: ${path}: ${err.message}`);
18
+ }
19
+ }
20
+
21
+ function validateExpectedFinding(finding, index) {
22
+ if (!finding || typeof finding !== 'object') {
23
+ throw new Error(`Policy fixture expected[${index}] must be an object`);
24
+ }
25
+ for (const field of ['id', 'severity', 'path']) {
26
+ if (typeof finding[field] !== 'string' || !finding[field]) {
27
+ throw new Error(`Policy fixture expected[${index}].${field} must be a non-empty string`);
28
+ }
29
+ }
30
+ }
31
+
32
+ function findingKey(finding) {
33
+ return `${finding.id}\u0000${finding.severity}\u0000${finding.path}`;
34
+ }
35
+
36
+ function displayFinding(finding) {
37
+ return `${finding.severity} ${finding.id} at ${finding.path}`;
38
+ }
39
+
40
+ /**
41
+ * Compare findings by id, severity, and path, ignoring messages and pack labels.
42
+ * @param {import('./policy.js').PolicyFinding[]} actual
43
+ * @param {Array<{id: string, severity: string, path: string}>} expected
44
+ */
45
+ export function assertExpectedFindings(actual, expected) {
46
+ const expectedByKey = new Map();
47
+ const actualByKey = new Map();
48
+ for (const finding of expected) {
49
+ const key = findingKey(finding);
50
+ expectedByKey.set(key, (expectedByKey.get(key) ?? 0) + 1);
51
+ }
52
+ for (const finding of actual) {
53
+ const key = findingKey(finding);
54
+ actualByKey.set(key, (actualByKey.get(key) ?? 0) + 1);
55
+ }
56
+
57
+ const missing = [];
58
+ const unexpected = [];
59
+ for (const finding of expected) {
60
+ const key = findingKey(finding);
61
+ if ((actualByKey.get(key) ?? 0) > 0) {
62
+ actualByKey.set(key, actualByKey.get(key) - 1);
63
+ } else {
64
+ missing.push(finding);
65
+ }
66
+ }
67
+ for (const finding of actual) {
68
+ const key = findingKey(finding);
69
+ if ((expectedByKey.get(key) ?? 0) > 0) {
70
+ expectedByKey.set(key, expectedByKey.get(key) - 1);
71
+ } else {
72
+ unexpected.push(finding);
73
+ }
74
+ }
75
+
76
+ if (missing.length === 0 && unexpected.length === 0) return;
77
+
78
+ const lines = ['Policy fixture findings did not match.'];
79
+ if (missing.length > 0) {
80
+ lines.push('Missing findings:');
81
+ lines.push(...missing.map((finding) => ` - ${displayFinding(finding)}`));
82
+ }
83
+ if (unexpected.length > 0) {
84
+ lines.push('Unexpected findings:');
85
+ lines.push(...unexpected.map((finding) => ` - ${displayFinding(finding)}`));
86
+ }
87
+ throw new Error(lines.join('\n'));
88
+ }
89
+
90
+ /**
91
+ * Run a policy fixture stored in a directory.
92
+ * @param {string} fixtureDir
93
+ * @param {{ configName?: string }} [options]
94
+ */
95
+ export async function testPolicyFixture(fixtureDir, options = {}) {
96
+ const dir = resolve(fixtureDir);
97
+ const configName = options.configName ?? DEFAULT_CONFIG_NAME;
98
+ const configPath = join(dir, configName);
99
+ const config = readJson(configPath, 'config');
100
+ if (!Array.isArray(config.expected)) {
101
+ throw new Error(`Policy fixture config must contain an expected array: ${configPath}`);
102
+ }
103
+ config.expected.forEach(validateExpectedFinding);
104
+
105
+ const baselinePath = resolve(dir, config.baseline ?? 'baseline.json');
106
+ const currentPath = resolve(dir, config.current ?? 'current.json');
107
+ const baseline = readJson(baselinePath, 'baseline');
108
+ if (!existsSync(currentPath)) {
109
+ throw new Error(`Policy fixture current file not found: ${currentPath}`);
110
+ }
111
+
112
+ const changes = diffTrees(baseline.state ?? baseline, parseFile(currentPath));
113
+ const findings = await evaluatePolicies(changes, {
114
+ cwd: dir,
115
+ file: currentPath,
116
+ profile: config.profile ?? null,
117
+ source: config.source ?? 'ci',
118
+ policies: config.policies,
119
+ plugins: config.plugins,
120
+ });
121
+ assertExpectedFindings(findings, config.expected);
122
+
123
+ return { fixtureDir: dir, changes, findings };
124
+ }