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.
- package/CHANGELOG.md +533 -0
- package/README.md +345 -211
- package/index.js +826 -77
- package/package.json +9 -7
- package/schemas/flecto-policy-pack-2.0.json +129 -0
- package/src/alerter.js +24 -6
- package/src/config.js +135 -9
- package/src/differ.js +154 -47
- package/src/documents.js +106 -0
- package/src/encrypted.js +573 -0
- package/src/notifiers.js +430 -0
- package/src/packs/compose.json +45 -0
- package/src/packs/default.json +23 -1
- package/src/packs/kubernetes.json +112 -0
- package/src/packs/node-runtime.json +44 -0
- package/src/packs/sops.json +61 -0
- package/src/packs/strict-prod.json +11 -1
- package/src/packs/terraform.json +120 -0
- package/src/parser.js +189 -20
- package/src/policy-test.js +124 -0
- package/src/policy.js +815 -30
- package/src/pr-comment.js +480 -0
- package/src/renderer.js +75 -18
- package/src/report.js +653 -0
- package/src/secrets.js +316 -0
- package/src/terraform.js +500 -0
- package/src/watcher.js +27 -15
package/src/notifiers.js
ADDED
|
@@ -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('&', '&')
|
|
196
|
+
.replaceAll('<', '<')
|
|
197
|
+
.replaceAll('>', '>');
|
|
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
|
+
}
|
package/src/packs/default.json
CHANGED
|
@@ -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
|
-
"
|
|
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
|
+
}
|