flecto 2.0.0 → 3.0.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,430 @@
1
+ import { highestSeverity } from './policy.js';
2
+
3
+ /**
4
+ * Chat payload formatters.
5
+ *
6
+ * These are pure functions: envelope in, service-shaped JSON body out. They are
7
+ * applied at delivery time in `alerter.js`, so the existing webhook machinery
8
+ * (headers, timeout, retries, delivery modes, persistent queue) is reused
9
+ * unchanged — only the serialized body differs.
10
+ *
11
+ * Limits coded against (service-documented maximums):
12
+ * - Slack: 3000 chars per section `text`, 150 chars per `header` plain_text,
13
+ * 50 blocks per message.
14
+ * - Discord: 4096 chars per embed `description`, 256 per `title`,
15
+ * 6000 chars across one embed.
16
+ * - Microsoft Teams: 28 KB per incoming-webhook message.
17
+ *
18
+ * @typedef {'flecto' | 'slack' | 'discord' | 'teams'} WebhookFormat
19
+ * @typedef {'error' | 'warn' | 'info' | 'none'} AlertSeverity
20
+ */
21
+
22
+ /** Payload formats that can be selected directly. @type {WebhookFormat[]} */
23
+ export const WEBHOOK_FORMATS = ['flecto', 'slack', 'discord', 'teams'];
24
+
25
+ /** Accepted `--webhook-format` values, including URL auto-detection. */
26
+ export const WEBHOOK_FORMAT_CHOICES = [...WEBHOOK_FORMATS, 'auto'];
27
+
28
+ /**
29
+ * Presentation per severity: emoji for text-first services, an integer color
30
+ * for Discord embeds, and a hex string for the Teams card theme.
31
+ */
32
+ const SEVERITY_STYLE = {
33
+ error: { emoji: '🔴', color: 0xd92d20, themeColor: 'D92D20' },
34
+ warn: { emoji: '🟡', color: 0xf79009, themeColor: 'F79009' },
35
+ info: { emoji: '🔵', color: 0x2e90fa, themeColor: '2E90FA' },
36
+ none: { emoji: '🟢', color: 0x12b76a, themeColor: '12B76A' },
37
+ };
38
+
39
+ /** Per-service budgets, kept under the documented hard limits above. */
40
+ const LIMITS = {
41
+ slack: { title: 150, fallback: 300, body: 2_800, lines: 20 },
42
+ discord: { title: 256, body: 3_800, lines: 20 },
43
+ teams: { title: 256, body: 8_000, lines: 20 },
44
+ };
45
+
46
+ const VALUE_CHARS = 80;
47
+ const MESSAGE_CHARS = 200;
48
+
49
+ /**
50
+ * @param {string} text
51
+ * @param {number} max
52
+ * @returns {string}
53
+ */
54
+ function truncate(text, max) {
55
+ const value = String(text);
56
+ return value.length <= max ? value : `${value.slice(0, Math.max(0, max - 1))}…`;
57
+ }
58
+
59
+ /**
60
+ * Last path segment of a file path, POSIX or Windows.
61
+ * @param {string} file
62
+ * @returns {string}
63
+ */
64
+ function baseName(file) {
65
+ const parts = String(file ?? '').split(/[\\/]/).filter(Boolean);
66
+ return parts.length > 0 ? parts[parts.length - 1] : String(file ?? '');
67
+ }
68
+
69
+ /**
70
+ * @param {unknown} value
71
+ * @returns {string}
72
+ */
73
+ function formatValue(value) {
74
+ if (value === undefined) return 'undefined';
75
+ if (value === null) return 'null';
76
+ if (typeof value === 'string' || typeof value === 'object') {
77
+ try {
78
+ return truncate(JSON.stringify(value), VALUE_CHARS);
79
+ } catch {
80
+ return truncate(String(value), VALUE_CHARS);
81
+ }
82
+ }
83
+ return truncate(String(value), VALUE_CHARS);
84
+ }
85
+
86
+ /**
87
+ * Highest severity across the envelope's policy findings.
88
+ * @param {import('./envelope.js').FlectoEnvelope} envelope
89
+ * @returns {AlertSeverity}
90
+ */
91
+ export function envelopeSeverity(envelope) {
92
+ return highestSeverity(envelope?.policies ?? []) ?? 'none';
93
+ }
94
+
95
+ /**
96
+ * One-line description of what happened, used in every service's title.
97
+ * @param {import('./envelope.js').FlectoEnvelope} envelope
98
+ * @returns {string}
99
+ */
100
+ function summarize(envelope) {
101
+ const name = baseName(envelope?.file) || 'config';
102
+ if (envelope?.lifecycle) {
103
+ return `${envelope.lifecycle.type ?? 'lifecycle'} — ${name}`;
104
+ }
105
+ const changes = envelope?.changes?.length ?? 0;
106
+ const findings = envelope?.policies?.length ?? 0;
107
+ const base = `${changes} change${changes === 1 ? '' : 's'} in ${name}`;
108
+ return findings > 0
109
+ ? `${base} · ${findings} policy finding${findings === 1 ? '' : 's'}`
110
+ : base;
111
+ }
112
+
113
+ /**
114
+ * Render change events as one line each.
115
+ * @param {import('./envelope.js').FlectoEnvelope} envelope
116
+ * @returns {string[]}
117
+ */
118
+ export function changeLines(envelope) {
119
+ const changes = Array.isArray(envelope?.changes) ? envelope.changes : [];
120
+ return changes.map((change) => {
121
+ if (change.type === 'added') return `+ ${change.path}: ${formatValue(change.after)}`;
122
+ if (change.type === 'removed') return `- ${change.path}: ${formatValue(change.before)}`;
123
+ const note = change.note ? ` [${change.note}]` : '';
124
+ return `~ ${change.path}: ${formatValue(change.before)} → ${formatValue(change.after)}${note}`;
125
+ });
126
+ }
127
+
128
+ /**
129
+ * Render policy findings as one line each.
130
+ * @param {import('./envelope.js').FlectoEnvelope} envelope
131
+ * @returns {string[]}
132
+ */
133
+ export function policyLines(envelope) {
134
+ const findings = Array.isArray(envelope?.policies) ? envelope.policies : [];
135
+ return findings.map((finding) => {
136
+ const pack = finding.pack ? ` [${finding.pack}]` : '';
137
+ return `[${finding.severity}] ${finding.id}${pack} ${finding.path}: ${truncate(finding.message ?? '', MESSAGE_CHARS)}`;
138
+ });
139
+ }
140
+
141
+ /**
142
+ * Join lines within a line count and character budget, appending a "+N more"
143
+ * marker instead of posting a wall of text.
144
+ * @param {string[]} lines
145
+ * @param {{ maxLines: number, maxChars: number, label: string, separator?: string }} limits
146
+ * @returns {string}
147
+ */
148
+ export function fitLines(lines, limits) {
149
+ if (!lines || lines.length === 0) return '';
150
+ const separator = limits.separator ?? '\n';
151
+ let kept = lines.slice(0, limits.maxLines);
152
+ const render = () => {
153
+ const omitted = lines.length - kept.length;
154
+ const body = kept.join(separator);
155
+ return omitted > 0 ? `${body}${separator}… +${omitted} more ${limits.label}` : body;
156
+ };
157
+
158
+ let text = render();
159
+ while (text.length > limits.maxChars && kept.length > 1) {
160
+ kept = kept.slice(0, -1);
161
+ text = render();
162
+ }
163
+ return truncate(text, limits.maxChars);
164
+ }
165
+
166
+ /**
167
+ * Body lines for an envelope: change lines, or the lifecycle message when the
168
+ * envelope carries no changes.
169
+ * @param {import('./envelope.js').FlectoEnvelope} envelope
170
+ * @returns {string[]}
171
+ */
172
+ function bodyLines(envelope) {
173
+ if (envelope?.lifecycle) {
174
+ return [`${envelope.lifecycle.type ?? 'lifecycle'}: ${envelope.lifecycle.message ?? ''}`.trim()];
175
+ }
176
+ return changeLines(envelope);
177
+ }
178
+
179
+ /**
180
+ * Neutralize fence sequences so a value can never break out of a code block.
181
+ * @param {string} text
182
+ * @returns {string}
183
+ */
184
+ function sanitizeFence(text) {
185
+ return text.replaceAll('```', "'''");
186
+ }
187
+
188
+ /**
189
+ * Escape the characters Slack reserves in mrkdwn text.
190
+ * @param {string} text
191
+ * @returns {string}
192
+ */
193
+ function escapeSlack(text) {
194
+ return String(text)
195
+ .replaceAll('&', '&amp;')
196
+ .replaceAll('<', '&lt;')
197
+ .replaceAll('>', '&gt;');
198
+ }
199
+
200
+ /**
201
+ * Format an envelope as a Slack Block Kit message. `text` carries the same
202
+ * summary as a notification fallback for clients that do not render blocks.
203
+ * @param {import('./envelope.js').FlectoEnvelope} envelope
204
+ * @returns {Record<string, unknown>}
205
+ */
206
+ export function formatSlackMessage(envelope) {
207
+ const severity = envelopeSeverity(envelope);
208
+ const style = SEVERITY_STYLE[severity];
209
+ const summary = summarize(envelope);
210
+ const title = `${style.emoji} Flecto — ${summary}`;
211
+ const limits = LIMITS.slack;
212
+
213
+ const blocks = [
214
+ {
215
+ type: 'header',
216
+ text: { type: 'plain_text', text: truncate(title, limits.title), emoji: true },
217
+ },
218
+ {
219
+ type: 'section',
220
+ text: {
221
+ type: 'mrkdwn',
222
+ text: [
223
+ `*File:* \`${escapeSlack(envelope?.file ?? '')}\``,
224
+ `*Source:* \`${escapeSlack(envelope?.source ?? '')}\` · *Severity:* \`${severity}\``,
225
+ ].join('\n'),
226
+ },
227
+ },
228
+ ];
229
+
230
+ const changes = fitLines(bodyLines(envelope).map((line) => escapeSlack(line)), {
231
+ maxLines: limits.lines,
232
+ maxChars: limits.body,
233
+ label: 'changes',
234
+ });
235
+ if (changes) {
236
+ blocks.push({
237
+ type: 'section',
238
+ text: { type: 'mrkdwn', text: `\`\`\`\n${sanitizeFence(changes)}\n\`\`\`` },
239
+ });
240
+ }
241
+
242
+ const findings = fitLines(policyLines(envelope).map((line) => escapeSlack(line)), {
243
+ maxLines: limits.lines,
244
+ maxChars: limits.body,
245
+ label: 'findings',
246
+ });
247
+ if (findings) {
248
+ blocks.push({
249
+ type: 'section',
250
+ text: { type: 'mrkdwn', text: `*Policy findings*\n\`\`\`\n${sanitizeFence(findings)}\n\`\`\`` },
251
+ });
252
+ }
253
+
254
+ blocks.push({
255
+ type: 'context',
256
+ elements: [{
257
+ type: 'mrkdwn',
258
+ text: `event \`${escapeSlack(envelope?.event_id ?? '')}\` · ${escapeSlack(envelope?.emitted_at ?? '')}`,
259
+ }],
260
+ });
261
+
262
+ return { text: truncate(escapeSlack(title), limits.fallback), blocks };
263
+ }
264
+
265
+ /**
266
+ * Format an envelope as a Discord webhook message with one embed, colored by
267
+ * the highest policy severity.
268
+ * @param {import('./envelope.js').FlectoEnvelope} envelope
269
+ * @returns {Record<string, unknown>}
270
+ */
271
+ export function formatDiscordMessage(envelope) {
272
+ const severity = envelopeSeverity(envelope);
273
+ const style = SEVERITY_STYLE[severity];
274
+ const summary = summarize(envelope);
275
+ const limits = LIMITS.discord;
276
+
277
+ const sections = [];
278
+ const changes = fitLines(bodyLines(envelope), {
279
+ maxLines: limits.lines,
280
+ maxChars: Math.floor(limits.body * 0.65),
281
+ label: 'changes',
282
+ });
283
+ if (changes) sections.push(`\`\`\`\n${sanitizeFence(changes)}\n\`\`\``);
284
+
285
+ const findings = fitLines(policyLines(envelope), {
286
+ maxLines: limits.lines,
287
+ maxChars: Math.floor(limits.body * 0.3),
288
+ label: 'findings',
289
+ });
290
+ if (findings) sections.push(`**Policy findings**\n\`\`\`\n${sanitizeFence(findings)}\n\`\`\``);
291
+
292
+ /** @type {Record<string, unknown>} */
293
+ const embed = {
294
+ title: truncate(`${style.emoji} Flecto — ${summary}`, limits.title),
295
+ color: style.color,
296
+ fields: [
297
+ { name: 'File', value: truncate(`\`${envelope?.file ?? ''}\``, 1_024), inline: false },
298
+ { name: 'Source', value: String(envelope?.source ?? 'watch'), inline: true },
299
+ { name: 'Severity', value: severity, inline: true },
300
+ ],
301
+ footer: { text: truncate(`event ${envelope?.event_id ?? ''}`, 2_048) },
302
+ };
303
+ const description = truncate(sections.join('\n'), limits.body);
304
+ if (description) embed.description = description;
305
+ if (envelope?.emitted_at) embed.timestamp = envelope.emitted_at;
306
+
307
+ return { embeds: [embed] };
308
+ }
309
+
310
+ /**
311
+ * Format an envelope as a Microsoft Teams MessageCard (the shape Office 365
312
+ * connector webhooks accept). Card text uses blank-line separators because
313
+ * MessageCard markdown collapses single newlines.
314
+ * @param {import('./envelope.js').FlectoEnvelope} envelope
315
+ * @returns {Record<string, unknown>}
316
+ */
317
+ export function formatTeamsMessage(envelope) {
318
+ const severity = envelopeSeverity(envelope);
319
+ const style = SEVERITY_STYLE[severity];
320
+ const summary = summarize(envelope);
321
+ const limits = LIMITS.teams;
322
+
323
+ const sections = [{
324
+ facts: [
325
+ { name: 'File', value: String(envelope?.file ?? '') },
326
+ { name: 'Source', value: String(envelope?.source ?? 'watch') },
327
+ { name: 'Severity', value: severity },
328
+ { name: 'Event', value: String(envelope?.event_id ?? '') },
329
+ ],
330
+ markdown: true,
331
+ }];
332
+
333
+ const changes = fitLines(bodyLines(envelope), {
334
+ maxLines: limits.lines,
335
+ maxChars: limits.body,
336
+ label: 'changes',
337
+ separator: '\n\n',
338
+ });
339
+ if (changes) {
340
+ sections.push({
341
+ title: envelope?.lifecycle ? 'Lifecycle' : 'Changes',
342
+ text: changes,
343
+ markdown: true,
344
+ });
345
+ }
346
+
347
+ const findings = fitLines(policyLines(envelope), {
348
+ maxLines: limits.lines,
349
+ maxChars: limits.body,
350
+ label: 'findings',
351
+ separator: '\n\n',
352
+ });
353
+ if (findings) sections.push({ title: 'Policy findings', text: findings, markdown: true });
354
+
355
+ return {
356
+ '@type': 'MessageCard',
357
+ '@context': 'https://schema.org/extensions',
358
+ summary: truncate(`Flecto — ${summary}`, limits.title),
359
+ themeColor: style.themeColor,
360
+ title: truncate(`${style.emoji} Flecto — ${summary}`, limits.title),
361
+ sections,
362
+ };
363
+ }
364
+
365
+ /**
366
+ * Guess a payload format from the webhook host. Only used when the format is
367
+ * explicitly requested as `auto` — the default stays `flecto` so existing
368
+ * webhook receivers keep getting the raw envelope.
369
+ * @param {string | undefined} url
370
+ * @returns {WebhookFormat}
371
+ */
372
+ export function detectWebhookFormat(url) {
373
+ if (!url) return 'flecto';
374
+ let parsed;
375
+ try {
376
+ parsed = new URL(String(url));
377
+ } catch {
378
+ return 'flecto';
379
+ }
380
+ const host = parsed.hostname.toLowerCase();
381
+ const path = parsed.pathname.toLowerCase();
382
+ const hostIs = (domain) => host === domain || host.endsWith(`.${domain}`);
383
+
384
+ if (hostIs('slack.com')) return 'slack';
385
+ if ((hostIs('discord.com') || hostIs('discordapp.com')) && path.includes('/api/webhooks')) {
386
+ return 'discord';
387
+ }
388
+ if (hostIs('office.com') || hostIs('office365.com')) return 'teams';
389
+ return 'flecto';
390
+ }
391
+
392
+ /**
393
+ * Normalize a requested webhook format. Unset means `flecto`; `auto` inspects
394
+ * the webhook URL.
395
+ * @param {unknown} requested
396
+ * @param {string} [url]
397
+ * @returns {WebhookFormat}
398
+ */
399
+ export function resolveWebhookFormat(requested, url) {
400
+ if (requested === undefined || requested === null || requested === '') return 'flecto';
401
+ const value = String(requested).trim().toLowerCase();
402
+ if (value === 'auto') return detectWebhookFormat(url);
403
+ if (!WEBHOOK_FORMATS.includes(/** @type {WebhookFormat} */ (value))) {
404
+ throw new Error(`--webhook-format must be one of: ${WEBHOOK_FORMAT_CHOICES.join(', ')}`);
405
+ }
406
+ return /** @type {WebhookFormat} */ (value);
407
+ }
408
+
409
+ /**
410
+ * Shape an envelope for the target service. `flecto` returns the envelope
411
+ * itself — the exact object that has always been posted — so the default
412
+ * delivery path is byte-for-byte unchanged.
413
+ * @param {import('./envelope.js').FlectoEnvelope} envelope
414
+ * @param {WebhookFormat} [format]
415
+ * @returns {unknown}
416
+ */
417
+ export function formatWebhookPayload(envelope, format = 'flecto') {
418
+ switch (format) {
419
+ case 'slack':
420
+ return formatSlackMessage(envelope);
421
+ case 'discord':
422
+ return formatDiscordMessage(envelope);
423
+ case 'teams':
424
+ return formatTeamsMessage(envelope);
425
+ case 'flecto':
426
+ return envelope;
427
+ default:
428
+ throw new Error(`Unknown webhook format: ${format}`);
429
+ }
430
+ }
@@ -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
+ }
@@ -11,6 +11,28 @@
11
11
  },
12
12
  "message": "Sensitive-looking key added or changed. Confirm secret storage, rotation, and access controls."
13
13
  },
14
+ {
15
+ "id": "secret-value-detected",
16
+ "severity": "error",
17
+ "when": ["added", "changed"],
18
+ "afterLooksSecret": true,
19
+ "message": "Value looks like a credential (known token format or high-entropy string) regardless of its key name. Move it to a secret store and rotate it."
20
+ },
21
+ {
22
+ "id": "sops-file-decrypted",
23
+ "severity": "error",
24
+ "when": ["changed"],
25
+ "match": { "pathEquals": "<encryption>" },
26
+ "afterEquals": "plaintext",
27
+ "message": "This file was encrypted and is now plaintext. Treat every value in it as exposed: rotate the secrets, then restore encryption before merging."
28
+ },
29
+ {
30
+ "id": "sops-value-decrypted",
31
+ "severity": "error",
32
+ "when": ["changed"],
33
+ "afterEquals": "<no longer encrypted>",
34
+ "messageTemplate": "{path} was an encrypted value and is now stored in the clear. Rotate it and re-encrypt the file."
35
+ },
14
36
  {
15
37
  "id": "dangerous-toggle-enabled",
16
38
  "severity": "error",
@@ -19,7 +41,7 @@
19
41
  "path": "(debug|allow_insecure|disable_tls|skip_tls_verify)",
20
42
  "pathFlags": "i"
21
43
  },
22
- "afterEquals": true,
44
+ "afterTruthy": true,
23
45
  "message": "Potentially dangerous toggle enabled."
24
46
  },
25
47
  {
@@ -0,0 +1,112 @@
1
+ {
2
+ "id": "kubernetes",
3
+ "expandSubtrees": true,
4
+ "rules": [
5
+ {
6
+ "id": "k8s-privileged-container",
7
+ "severity": "error",
8
+ "when": ["added", "changed"],
9
+ "match": {
10
+ "path": "(^|\\.)securityContext\\.privileged$"
11
+ },
12
+ "afterTruthy": true,
13
+ "message": "Container runs privileged. A privileged container has effectively full access to the host. Drop privileged and grant only the specific capabilities the workload needs."
14
+ },
15
+ {
16
+ "id": "k8s-host-namespace-shared",
17
+ "severity": "error",
18
+ "when": ["added", "changed"],
19
+ "match": {
20
+ "path": "(^|\\.)(hostNetwork|hostPID|hostIPC)$"
21
+ },
22
+ "afterTruthy": true,
23
+ "messageTemplate": "Pod shares a host namespace ({path} = {after}). This removes the isolation boundary between the pod and the node. Confirm the workload genuinely needs host-level access."
24
+ },
25
+ {
26
+ "id": "k8s-run-as-non-root-weakened",
27
+ "severity": "error",
28
+ "when": ["added", "changed", "removed"],
29
+ "match": {
30
+ "path": "(^|\\.)runAsNonRoot$"
31
+ },
32
+ "anyOf": [
33
+ { "afterEquals": false },
34
+ { "beforeTruthy": true }
35
+ ],
36
+ "message": "runAsNonRoot was disabled or dropped, so the container may run as UID 0. Set runAsNonRoot: true and give the image a non-root user."
37
+ },
38
+ {
39
+ "id": "k8s-allow-privilege-escalation",
40
+ "severity": "error",
41
+ "when": ["added", "changed", "removed"],
42
+ "match": {
43
+ "path": "(^|\\.)allowPrivilegeEscalation$"
44
+ },
45
+ "anyOf": [
46
+ { "afterTruthy": true },
47
+ { "beforeEquals": false }
48
+ ],
49
+ "message": "allowPrivilegeEscalation was enabled or its explicit false was removed. A process can then gain more privileges than its parent (setuid binaries, file capabilities). Pin it back to false."
50
+ },
51
+ {
52
+ "id": "k8s-dangerous-capability-added",
53
+ "severity": "error",
54
+ "when": ["added", "changed"],
55
+ "match": {
56
+ "path": "(^|\\.)capabilities\\.add\\[[^\\]]*\\]$"
57
+ },
58
+ "afterIn": ["SYS_ADMIN", "NET_ADMIN", "ALL"],
59
+ "messageTemplate": "Container adds the {after} Linux capability, which is close to full host privilege. Add only the narrow capability the workload needs, or drop the change."
60
+ },
61
+ {
62
+ "id": "k8s-image-tag-unpinned",
63
+ "severity": "error",
64
+ "when": ["added", "changed"],
65
+ "match": {
66
+ "path": "containers\\[[^\\]]*\\]\\.image$"
67
+ },
68
+ "afterMatches": "(?::latest$)|(?:^[^:@]*$)|(?:^[^@]*/[^/:@]*$)",
69
+ "messageTemplate": "Container image \"{after}\" is not pinned: it resolves to :latest, so what actually runs can change without a manifest change. Pin an immutable tag or a @sha256 digest."
70
+ },
71
+ {
72
+ "id": "k8s-image-pull-policy-always",
73
+ "severity": "warn",
74
+ "when": ["changed"],
75
+ "match": {
76
+ "path": "containers\\[[^\\]]*\\]\\.imagePullPolicy$"
77
+ },
78
+ "afterEquals": "Always",
79
+ "messageTemplate": "imagePullPolicy moved from {before} to Always, so a restart can silently pull different bits behind the same tag. Keep IfNotPresent with a pinned tag or digest."
80
+ },
81
+ {
82
+ "id": "k8s-replica-count-jump",
83
+ "severity": "warn",
84
+ "when": ["changed"],
85
+ "match": {
86
+ "path": "(^|\\.)spec\\.replicas$"
87
+ },
88
+ "numericJump": { "minMultiple": 3 },
89
+ "numericDelta": { "min": 3 },
90
+ "messageTemplate": "Replica count jumped from {before} to {after} (>=3x). Confirm the cluster has the capacity and that the cost is intended."
91
+ },
92
+ {
93
+ "id": "k8s-resource-limits-removed",
94
+ "severity": "warn",
95
+ "when": ["removed"],
96
+ "match": {
97
+ "path": "containers\\[[^\\]]*\\]\\.resources\\.limits\\.[^.]+$"
98
+ },
99
+ "messageTemplate": "Resource limit {path} was removed. An unbounded container can starve everything else scheduled on the node."
100
+ },
101
+ {
102
+ "id": "k8s-service-externally-exposed",
103
+ "severity": "error",
104
+ "when": ["added", "changed"],
105
+ "match": {
106
+ "path": "(^|\\.)spec\\.type$"
107
+ },
108
+ "afterIn": ["LoadBalancer", "NodePort"],
109
+ "messageTemplate": "Service type is {after}, which exposes the workload outside the cluster (a public load balancer, or a port open on every node). Confirm the exposure is intended and covered by network policy."
110
+ }
111
+ ]
112
+ }
@@ -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
+ }