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/CHANGELOG.md +103 -0
- package/README.md +258 -120
- package/index.js +269 -38
- package/package.json +7 -6
- package/schemas/flecto-policy-pack-2.0.json +124 -0
- package/src/alerter.js +4 -3
- package/src/config.js +23 -2
- package/src/differ.js +95 -45
- package/src/packs/compose.json +45 -0
- package/src/packs/default.json +1 -1
- package/src/packs/node-runtime.json +44 -0
- package/src/packs/strict-prod.json +1 -1
- package/src/policy-test.js +124 -0
- package/src/policy.js +325 -27
- package/src/renderer.js +7 -4
- package/src/watcher.js +18 -8
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
|
-
|
|
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: {
|
|
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 (
|
|
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
|
-
*
|
|
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 =
|
|
324
|
+
const idKey = resolveArrayIdKey(before, after, options);
|
|
257
325
|
|
|
258
326
|
if (idKey) {
|
|
259
|
-
|
|
260
|
-
const
|
|
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 (
|
|
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
|
-
|
|
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
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
if (
|
|
313
|
-
else
|
|
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
|
|
316
|
-
for (let i = 0; i <
|
|
317
|
-
events.push({ type: 'removed', path: `${basePath}[*]`, before:
|
|
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
|
+
}
|
package/src/packs/default.json
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|