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.
@@ -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
- alertQueue = alertQueue
15
- .then(async () => { await fn(); })
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 alertQueue;
20
+ return result;
20
21
  }
21
22
 
22
23
  /**
23
- * @param {import('./envelope.js').SentinelEnvelope} envelope
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').SentinelEnvelope} envelope
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').SentinelEnvelope} envelope
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').SentinelEnvelope) => Promise<boolean>} deliver
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').SentinelEnvelope} envelope
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').SentinelEnvelope} envelope
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').SentinelEnvelope} envelope
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 (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,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
- * Diff two arrays by index.
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 = '1.1';
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
- * }} SentinelEnvelope
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 {SentinelEnvelope}
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
+ }