flecto 2.1.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 +431 -1
- package/README.md +305 -309
- package/index.js +574 -56
- package/package.json +3 -2
- package/schemas/flecto-policy-pack-2.0.json +5 -0
- package/src/alerter.js +20 -3
- package/src/config.js +113 -8
- package/src/differ.js +59 -2
- package/src/documents.js +106 -0
- package/src/encrypted.js +573 -0
- package/src/notifiers.js +430 -0
- package/src/packs/default.json +22 -0
- package/src/packs/kubernetes.json +112 -0
- package/src/packs/sops.json +61 -0
- package/src/packs/strict-prod.json +10 -0
- package/src/packs/terraform.json +120 -0
- package/src/parser.js +189 -20
- package/src/policy.js +498 -11
- package/src/pr-comment.js +480 -0
- package/src/renderer.js +70 -16
- package/src/report.js +653 -0
- package/src/secrets.js +316 -0
- package/src/terraform.js +500 -0
- package/src/watcher.js +9 -7
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { isAbsolute, relative } from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hidden marker embedded in every rendered body. Flecto finds its own comment by
|
|
6
|
+
* searching for this string, so repeated runs update one sticky comment instead
|
|
7
|
+
* of appending a new one on every push.
|
|
8
|
+
*/
|
|
9
|
+
export const PR_COMMENT_MARKER = '<!-- flecto:pr-comment -->';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_API_URL = 'https://api.github.com';
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
13
|
+
/** GitHub rejects comment bodies longer than 65536 characters. */
|
|
14
|
+
const MAX_BODY_CHARS = 60_000;
|
|
15
|
+
const MAX_INLINE_CHANGES = 10;
|
|
16
|
+
const MAX_VALUE_CHARS = 120;
|
|
17
|
+
const MAX_COMMENT_PAGES = 10;
|
|
18
|
+
const COMMENTS_PER_PAGE = 100;
|
|
19
|
+
const SEVERITY_ORDER = ['error', 'warn', 'info'];
|
|
20
|
+
const SEVERITY_HEADINGS = { error: 'Errors', warn: 'Warnings', info: 'Notices' };
|
|
21
|
+
const SEVERITY_NOUNS = { error: 'error', warn: 'warning', info: 'notice' };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {{ file: string, envelope: import('./envelope.js').FlectoEnvelope, policies?: import('./policy.js').PolicyFinding[] }} CiResult
|
|
25
|
+
* @typedef {{ repo: string, prNumber: number, token: string, apiUrl: string }} PrCommentContext
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Render a value inside a markdown code span, widening the fence so embedded
|
|
30
|
+
* backticks cannot terminate it early.
|
|
31
|
+
* @param {string} text
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
function inlineCode(text) {
|
|
35
|
+
const value = String(text);
|
|
36
|
+
const runs = value.match(/`+/g) ?? [];
|
|
37
|
+
const fence = '`'.repeat(Math.max(0, ...runs.map((run) => run.length)) + 1);
|
|
38
|
+
const pad = value.startsWith('`') || value.endsWith('`') ? ' ' : '';
|
|
39
|
+
return `${fence}${pad}${value}${pad}${fence}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Make a string safe for a markdown table cell: pipes end the cell (even inside
|
|
44
|
+
* a code span) and newlines end the row.
|
|
45
|
+
* @param {string} text
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
function tableCell(text) {
|
|
49
|
+
return String(text).replaceAll(/\r?\n/gu, ' ').replaceAll('|', '\\|');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {string} text
|
|
54
|
+
* @param {number} max
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
function truncate(text, max) {
|
|
58
|
+
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Format a change value for display, or return null when the side is absent.
|
|
63
|
+
* @param {unknown} value
|
|
64
|
+
* @returns {string | null}
|
|
65
|
+
*/
|
|
66
|
+
function formatValue(value) {
|
|
67
|
+
if (value === undefined) return null;
|
|
68
|
+
const json = JSON.stringify(value);
|
|
69
|
+
return truncate(json === undefined ? String(value) : json, MAX_VALUE_CHARS);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Show repository-relative paths where possible; absolute runner paths are
|
|
74
|
+
* noise in a PR comment.
|
|
75
|
+
* @param {string} file
|
|
76
|
+
* @param {string} [cwd]
|
|
77
|
+
* @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
function displayPath(file, cwd) {
|
|
80
|
+
const value = String(file);
|
|
81
|
+
if (!cwd || !isAbsolute(value)) return value;
|
|
82
|
+
const rel = relative(cwd, value);
|
|
83
|
+
if (!rel || rel.startsWith('..') || isAbsolute(rel)) return value;
|
|
84
|
+
return rel.replaceAll('\\', '/');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {number} count
|
|
89
|
+
* @param {string} noun
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
function plural(count, noun) {
|
|
93
|
+
return `${count} ${noun}${count === 1 ? '' : 's'}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @param {CiResult} result
|
|
98
|
+
* @returns {import('./differ.js').ChangeEvent[]}
|
|
99
|
+
*/
|
|
100
|
+
function changesOf(result) {
|
|
101
|
+
return result?.envelope?.changes ?? [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param {CiResult} result
|
|
106
|
+
* @returns {import('./policy.js').PolicyFinding[]}
|
|
107
|
+
*/
|
|
108
|
+
function findingsOf(result) {
|
|
109
|
+
return result?.policies ?? result?.envelope?.policies ?? [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Render the markdown body of the sticky pull request comment.
|
|
114
|
+
*
|
|
115
|
+
* Pure: it reads nothing but its arguments, so the exact body posted to GitHub
|
|
116
|
+
* is the body printed to stdout.
|
|
117
|
+
* @param {CiResult[]} results
|
|
118
|
+
* @param {{ cwd?: string, failed?: boolean, marker?: string, maxInlineChanges?: number, maxBodyChars?: number }} [options]
|
|
119
|
+
* @returns {string} Markdown, always beginning with the sticky marker
|
|
120
|
+
*/
|
|
121
|
+
export function renderPrComment(results, options = {}) {
|
|
122
|
+
const list = Array.isArray(results) ? results : [];
|
|
123
|
+
const marker = options.marker ?? PR_COMMENT_MARKER;
|
|
124
|
+
const maxInlineChanges = options.maxInlineChanges ?? MAX_INLINE_CHANGES;
|
|
125
|
+
const maxBodyChars = options.maxBodyChars ?? MAX_BODY_CHARS;
|
|
126
|
+
const cwd = options.cwd;
|
|
127
|
+
|
|
128
|
+
const counts = { changed: 0, added: 0, removed: 0 };
|
|
129
|
+
const severityCounts = { error: 0, warn: 0, info: 0 };
|
|
130
|
+
/** @type {Record<string, Array<{ file: string, finding: import('./policy.js').PolicyFinding }>>} */
|
|
131
|
+
const bySeverity = { error: [], warn: [], info: [] };
|
|
132
|
+
let totalChanges = 0;
|
|
133
|
+
|
|
134
|
+
for (const result of list) {
|
|
135
|
+
const file = displayPath(result.file, cwd);
|
|
136
|
+
for (const change of changesOf(result)) {
|
|
137
|
+
totalChanges += 1;
|
|
138
|
+
if (change.type in counts) counts[change.type] += 1;
|
|
139
|
+
}
|
|
140
|
+
for (const finding of findingsOf(result)) {
|
|
141
|
+
const severity = SEVERITY_ORDER.includes(finding.severity) ? finding.severity : 'info';
|
|
142
|
+
severityCounts[severity] += 1;
|
|
143
|
+
bySeverity[severity].push({ file, finding });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const totalFindings = SEVERITY_ORDER.reduce((sum, s) => sum + severityCounts[s], 0);
|
|
148
|
+
const lines = [marker, '', '## Flecto — config change report', ''];
|
|
149
|
+
|
|
150
|
+
const status = options.failed === true
|
|
151
|
+
? '❌ **Check failing** — '
|
|
152
|
+
: options.failed === false
|
|
153
|
+
? '✅ **Check passing** — '
|
|
154
|
+
: '';
|
|
155
|
+
|
|
156
|
+
if (list.length === 0) {
|
|
157
|
+
lines.push(`${status}no files were diffed.`);
|
|
158
|
+
} else if (totalChanges === 0) {
|
|
159
|
+
lines.push(`${status}no semantic changes in ${plural(list.length, 'file')}.`);
|
|
160
|
+
} else {
|
|
161
|
+
lines.push(
|
|
162
|
+
`${status}${plural(totalChanges, 'change')} in ${plural(list.length, 'file')}` +
|
|
163
|
+
` — ${counts.changed} changed, ${counts.added} added, ${counts.removed} removed.`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
lines.push('');
|
|
168
|
+
lines.push(totalFindings === 0
|
|
169
|
+
? '**Policy:** no findings.'
|
|
170
|
+
: `**Policy:** ${SEVERITY_ORDER
|
|
171
|
+
.filter((severity) => severityCounts[severity] > 0)
|
|
172
|
+
.map((severity) => plural(severityCounts[severity], SEVERITY_NOUNS[severity]))
|
|
173
|
+
.join(', ')}.`);
|
|
174
|
+
|
|
175
|
+
if (totalFindings > 0) {
|
|
176
|
+
lines.push('', '### Policy findings');
|
|
177
|
+
for (const severity of SEVERITY_ORDER) {
|
|
178
|
+
const entries = bySeverity[severity];
|
|
179
|
+
if (entries.length === 0) continue;
|
|
180
|
+
lines.push('', `#### ${SEVERITY_HEADINGS[severity]} (${entries.length})`, '');
|
|
181
|
+
lines.push('| File | Path | Rule | Message |', '|---|---|---|---|');
|
|
182
|
+
for (const { file, finding } of entries) {
|
|
183
|
+
const rule = finding.pack
|
|
184
|
+
? `${inlineCode(finding.id)} (${inlineCode(finding.pack)})`
|
|
185
|
+
: inlineCode(finding.id);
|
|
186
|
+
lines.push(
|
|
187
|
+
`| ${tableCell(inlineCode(file))} | ${tableCell(inlineCode(finding.path))}` +
|
|
188
|
+
` | ${tableCell(rule)} | ${tableCell(finding.message ?? '')} |`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (totalChanges > 0) {
|
|
195
|
+
const collapse = totalChanges > maxInlineChanges;
|
|
196
|
+
lines.push('', '### Changes');
|
|
197
|
+
if (collapse) {
|
|
198
|
+
lines.push('', '<details>', `<summary>Show all ${plural(totalChanges, 'change')}</summary>`);
|
|
199
|
+
}
|
|
200
|
+
for (const result of list) {
|
|
201
|
+
const changes = changesOf(result);
|
|
202
|
+
if (changes.length === 0) continue;
|
|
203
|
+
const file = displayPath(result.file, cwd);
|
|
204
|
+
lines.push('', `**${inlineCode(file)}** — ${plural(changes.length, 'change')}`, '');
|
|
205
|
+
lines.push('| Change | Path | Before | After |', '|---|---|---|---|');
|
|
206
|
+
for (const change of changes) {
|
|
207
|
+
const kind = change.note ? `${change.type} (${change.note})` : String(change.type);
|
|
208
|
+
const before = formatValue(change.before);
|
|
209
|
+
const after = formatValue(change.after);
|
|
210
|
+
lines.push(
|
|
211
|
+
`| ${tableCell(kind)} | ${tableCell(inlineCode(change.path))}` +
|
|
212
|
+
` | ${before === null ? '—' : tableCell(inlineCode(before))}` +
|
|
213
|
+
` | ${after === null ? '—' : tableCell(inlineCode(after))} |`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (collapse) lines.push('', '</details>');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
lines.push(
|
|
221
|
+
'',
|
|
222
|
+
'---',
|
|
223
|
+
'',
|
|
224
|
+
'<sub>Posted by [Flecto](https://github.com/myselfsiddharth/Flecto) —' +
|
|
225
|
+
' this comment is updated in place on every run.</sub>',
|
|
226
|
+
'',
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const body = lines.join('\n');
|
|
230
|
+
if (body.length <= maxBodyChars) return body;
|
|
231
|
+
const notice = '\n\n_Report truncated to fit GitHub\'s comment size limit._\n';
|
|
232
|
+
return `${body.slice(0, Math.max(marker.length, maxBodyChars - notice.length))}${notice}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* @param {Record<string, string | undefined>} env
|
|
237
|
+
* @param {(path: string) => string} readEventFile
|
|
238
|
+
* @returns {number | null}
|
|
239
|
+
*/
|
|
240
|
+
function resolvePrNumber(env, readEventFile) {
|
|
241
|
+
const fromRef = /^refs\/pull\/(\d+)\/(?:merge|head)$/u.exec(env.GITHUB_REF ?? '');
|
|
242
|
+
if (fromRef) return Number(fromRef[1]);
|
|
243
|
+
|
|
244
|
+
const eventPath = env.GITHUB_EVENT_PATH;
|
|
245
|
+
if (!eventPath) return null;
|
|
246
|
+
|
|
247
|
+
let event;
|
|
248
|
+
try {
|
|
249
|
+
event = JSON.parse(readEventFile(eventPath));
|
|
250
|
+
} catch {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const candidates = [
|
|
255
|
+
event?.pull_request?.number,
|
|
256
|
+
// issue_comment events on a PR carry the PR number under `issue`, but a
|
|
257
|
+
// plain issue must not be mistaken for one.
|
|
258
|
+
event?.issue?.pull_request ? event?.issue?.number : undefined,
|
|
259
|
+
event?.number,
|
|
260
|
+
];
|
|
261
|
+
for (const candidate of candidates) {
|
|
262
|
+
const value = Number(candidate);
|
|
263
|
+
if (Number.isInteger(value) && value > 0) return value;
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Resolve the GitHub API context needed to post a pull request comment.
|
|
270
|
+
*
|
|
271
|
+
* Only `GITHUB_TOKEN` is honored — `GH_TOKEN` is deliberately ignored because
|
|
272
|
+
* `gh auth login` exports it on developer machines, where posting would be a
|
|
273
|
+
* surprise.
|
|
274
|
+
* @param {Record<string, string | undefined>} [env]
|
|
275
|
+
* @param {{ readEventFile?: (path: string) => string }} [options]
|
|
276
|
+
* @returns {{ ok: true, context: PrCommentContext } | { ok: false, reason: string }}
|
|
277
|
+
*/
|
|
278
|
+
export function resolvePrCommentContext(env = process.env, options = {}) {
|
|
279
|
+
const readEventFile = options.readEventFile ?? ((path) => readFileSync(path, 'utf8'));
|
|
280
|
+
const token = String(env.GITHUB_TOKEN ?? '').trim();
|
|
281
|
+
if (!token) {
|
|
282
|
+
return { ok: false, reason: 'GITHUB_TOKEN is not set' };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const repo = String(env.GITHUB_REPOSITORY ?? '').trim();
|
|
286
|
+
if (!/^[^/\s]+\/[^/\s]+$/u.test(repo)) {
|
|
287
|
+
return { ok: false, reason: 'GITHUB_REPOSITORY is not set to "owner/repo"' };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const prNumber = resolvePrNumber(env, readEventFile);
|
|
291
|
+
if (!prNumber) {
|
|
292
|
+
return {
|
|
293
|
+
ok: false,
|
|
294
|
+
reason: 'no pull request number in GITHUB_REF or GITHUB_EVENT_PATH (not a pull request run)',
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const apiUrl = String(env.GITHUB_API_URL || DEFAULT_API_URL).replace(/\/+$/u, '');
|
|
299
|
+
return { ok: true, context: { repo, prNumber, token, apiUrl } };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Remove the token from text that may be surfaced to the user.
|
|
304
|
+
* @param {string} text
|
|
305
|
+
* @param {string} token
|
|
306
|
+
* @returns {string}
|
|
307
|
+
*/
|
|
308
|
+
function redact(text, token) {
|
|
309
|
+
return token ? String(text).replaceAll(token, '***') : String(text);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* @param {Response} response
|
|
314
|
+
* @param {string} token
|
|
315
|
+
* @returns {Promise<string>}
|
|
316
|
+
*/
|
|
317
|
+
async function errorDetail(response, token) {
|
|
318
|
+
let text = '';
|
|
319
|
+
try {
|
|
320
|
+
text = await response.text();
|
|
321
|
+
} catch {
|
|
322
|
+
return '';
|
|
323
|
+
}
|
|
324
|
+
let message = text;
|
|
325
|
+
try {
|
|
326
|
+
const parsed = JSON.parse(text);
|
|
327
|
+
if (typeof parsed?.message === 'string') message = parsed.message;
|
|
328
|
+
} catch {
|
|
329
|
+
// Non-JSON error bodies are used as-is.
|
|
330
|
+
}
|
|
331
|
+
const safe = truncate(redact(message, token).replaceAll(/\s+/gu, ' ').trim(), 200);
|
|
332
|
+
return safe ? `: ${safe}` : '';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* @param {{ fetchImpl: typeof fetch, url: string, method: string, token: string, body?: unknown, timeoutMs: number }} request
|
|
337
|
+
* @returns {Promise<Response>}
|
|
338
|
+
*/
|
|
339
|
+
async function githubRequest({ fetchImpl, url, method, token, body, timeoutMs }) {
|
|
340
|
+
const controller = new AbortController();
|
|
341
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
342
|
+
let response;
|
|
343
|
+
try {
|
|
344
|
+
response = await fetchImpl(url, {
|
|
345
|
+
method,
|
|
346
|
+
headers: {
|
|
347
|
+
Accept: 'application/vnd.github+json',
|
|
348
|
+
Authorization: `Bearer ${token}`,
|
|
349
|
+
'Content-Type': 'application/json',
|
|
350
|
+
'User-Agent': 'flecto',
|
|
351
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
352
|
+
},
|
|
353
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
354
|
+
signal: controller.signal,
|
|
355
|
+
});
|
|
356
|
+
} catch (err) {
|
|
357
|
+
const reason = err?.name === 'AbortError'
|
|
358
|
+
? `timed out after ${timeoutMs}ms`
|
|
359
|
+
: redact(err?.message ?? String(err), token);
|
|
360
|
+
throw new Error(`GitHub API ${method} failed: ${reason}`);
|
|
361
|
+
} finally {
|
|
362
|
+
clearTimeout(timer);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (!response.ok) {
|
|
366
|
+
throw new Error(
|
|
367
|
+
`GitHub API ${method} returned HTTP ${response.status}${await errorDetail(response, token)}`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
return response;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Find the sticky Flecto comment on a pull request, if one exists.
|
|
375
|
+
* @param {PrCommentContext} context
|
|
376
|
+
* @param {{ fetchImpl: typeof fetch, marker: string, timeoutMs: number }} options
|
|
377
|
+
* @returns {Promise<{ id: number, body?: string, html_url?: string } | null>}
|
|
378
|
+
*/
|
|
379
|
+
async function findStickyComment(context, { fetchImpl, marker, timeoutMs }) {
|
|
380
|
+
for (let page = 1; page <= MAX_COMMENT_PAGES; page += 1) {
|
|
381
|
+
const url = `${context.apiUrl}/repos/${context.repo}/issues/${context.prNumber}`
|
|
382
|
+
+ `/comments?per_page=${COMMENTS_PER_PAGE}&page=${page}`;
|
|
383
|
+
const response = await githubRequest({
|
|
384
|
+
fetchImpl, url, method: 'GET', token: context.token, timeoutMs,
|
|
385
|
+
});
|
|
386
|
+
const comments = await response.json();
|
|
387
|
+
if (!Array.isArray(comments) || comments.length === 0) return null;
|
|
388
|
+
const match = comments.find((c) => typeof c?.body === 'string' && c.body.includes(marker));
|
|
389
|
+
if (match) return match;
|
|
390
|
+
if (comments.length < COMMENTS_PER_PAGE) return null;
|
|
391
|
+
}
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Create, update, or leave alone the single sticky Flecto comment on a PR.
|
|
397
|
+
* Throws on API failure; callers decide how to degrade.
|
|
398
|
+
* @param {string} body Markdown containing the sticky marker
|
|
399
|
+
* @param {PrCommentContext} context
|
|
400
|
+
* @param {{ fetchImpl?: typeof fetch, marker?: string, timeoutMs?: number }} [options]
|
|
401
|
+
* @returns {Promise<{ action: 'created' | 'updated' | 'unchanged', url?: string }>}
|
|
402
|
+
*/
|
|
403
|
+
export async function upsertPrComment(body, context, options = {}) {
|
|
404
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
405
|
+
const marker = options.marker ?? PR_COMMENT_MARKER;
|
|
406
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
407
|
+
if (typeof fetchImpl !== 'function') {
|
|
408
|
+
throw new Error('Global fetch unavailable. Use Node.js >= 20.19.0.');
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const existing = await findStickyComment(context, { fetchImpl, marker, timeoutMs });
|
|
412
|
+
|
|
413
|
+
if (!existing) {
|
|
414
|
+
const response = await githubRequest({
|
|
415
|
+
fetchImpl,
|
|
416
|
+
url: `${context.apiUrl}/repos/${context.repo}/issues/${context.prNumber}/comments`,
|
|
417
|
+
method: 'POST',
|
|
418
|
+
token: context.token,
|
|
419
|
+
body: { body },
|
|
420
|
+
timeoutMs,
|
|
421
|
+
});
|
|
422
|
+
const created = await response.json().catch(() => ({}));
|
|
423
|
+
return { action: 'created', url: created?.html_url };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Rendering is deterministic, so an identical body means nothing moved since
|
|
427
|
+
// the last run — skip the write and the "edited" noise it creates.
|
|
428
|
+
if (existing.body === body) {
|
|
429
|
+
return { action: 'unchanged', url: existing.html_url };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const response = await githubRequest({
|
|
433
|
+
fetchImpl,
|
|
434
|
+
url: `${context.apiUrl}/repos/${context.repo}/issues/comments/${existing.id}`,
|
|
435
|
+
method: 'PATCH',
|
|
436
|
+
token: context.token,
|
|
437
|
+
body: { body },
|
|
438
|
+
timeoutMs,
|
|
439
|
+
});
|
|
440
|
+
const updated = await response.json().catch(() => ({}));
|
|
441
|
+
return { action: 'updated', url: updated?.html_url ?? existing.html_url };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Post the sticky comment when — and only when — posting was explicitly enabled
|
|
446
|
+
* and a complete GitHub pull request context is present.
|
|
447
|
+
*
|
|
448
|
+
* Never throws and never reports the token: a delivery problem must not change
|
|
449
|
+
* the CI exit code, which belongs to the diff and policy result alone.
|
|
450
|
+
* @param {string} body
|
|
451
|
+
* @param {{
|
|
452
|
+
* enabled?: boolean,
|
|
453
|
+
* env?: Record<string, string | undefined>,
|
|
454
|
+
* fetchImpl?: typeof fetch,
|
|
455
|
+
* marker?: string,
|
|
456
|
+
* timeoutMs?: number,
|
|
457
|
+
* readEventFile?: (path: string) => string
|
|
458
|
+
* }} [options]
|
|
459
|
+
* @returns {Promise<{ posted: boolean, action?: 'created' | 'updated' | 'unchanged', url?: string, reason?: string }>}
|
|
460
|
+
*/
|
|
461
|
+
export async function deliverPrComment(body, options = {}) {
|
|
462
|
+
if (!options.enabled) {
|
|
463
|
+
return { posted: false, reason: 'posting is disabled (pass --pr-comment-post to enable)' };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const resolved = resolvePrCommentContext(options.env ?? process.env, options);
|
|
467
|
+
if (!resolved.ok) {
|
|
468
|
+
return { posted: false, reason: resolved.reason };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
try {
|
|
472
|
+
const result = await upsertPrComment(body, resolved.context, options);
|
|
473
|
+
return { posted: true, ...result };
|
|
474
|
+
} catch (err) {
|
|
475
|
+
return {
|
|
476
|
+
posted: false,
|
|
477
|
+
reason: redact(err?.message ?? String(err), resolved.context.token),
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
}
|
package/src/renderer.js
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
import { redactSecretString } from './secrets.js';
|
|
3
|
+
import { ENCRYPTED_DISPLAY, displayEncrypted, isEncryptedSentinel } from './encrypted.js';
|
|
4
|
+
import { secretMatchPath } from './differ.js';
|
|
2
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Key names that mean "this value is a credential".
|
|
8
|
+
*
|
|
9
|
+
* Matched against the *configuration* path only. A multi-document file prefixes
|
|
10
|
+
* every path with the document's identity — `Deployment/prod/token-service.…` —
|
|
11
|
+
* and that identity is a resource name the user chose, not a key name. Letting
|
|
12
|
+
* it match here masked every value in the document, numbers and booleans
|
|
13
|
+
* included, so the path reaching this regex is always the one
|
|
14
|
+
* {@link secretMatchPath} produced.
|
|
15
|
+
*/
|
|
3
16
|
const SECRET_PATH_RE = /(secret|token|password|api[_-]?key|private[_-]?key|credential)/i;
|
|
4
17
|
|
|
5
18
|
/**
|
|
@@ -10,12 +23,22 @@ const SECRET_PATH_RE = /(secret|token|password|api[_-]?key|private[_-]?key|crede
|
|
|
10
23
|
*/
|
|
11
24
|
function fmt(v, opts = {}) {
|
|
12
25
|
if (v === undefined) return '';
|
|
13
|
-
|
|
14
|
-
|
|
26
|
+
// An encrypted value reads the same masked or not: there is nothing to mask,
|
|
27
|
+
// because the parser never carried the ciphertext this far.
|
|
28
|
+
if (isEncryptedSentinel(v)) return chalk.dim(ENCRYPTED_DISPLAY);
|
|
29
|
+
let value = displayEncrypted(v);
|
|
30
|
+
if (opts.maskSecrets) {
|
|
31
|
+
if (opts.path && SECRET_PATH_RE.test(opts.path)) {
|
|
32
|
+
return chalk.dim('"***"');
|
|
33
|
+
}
|
|
34
|
+
// The changed path itself can look benign while the value carries secrets,
|
|
35
|
+
// e.g. "database" holding { password }. Redact those the same way the
|
|
36
|
+
// webhook/CI payloads do.
|
|
37
|
+
value = maskSensitiveValue(value, opts.path ?? '');
|
|
15
38
|
}
|
|
16
|
-
if (typeof
|
|
17
|
-
if (typeof
|
|
18
|
-
return String(
|
|
39
|
+
if (typeof value === 'string') return JSON.stringify(value);
|
|
40
|
+
if (typeof value === 'object' && value !== null) return JSON.stringify(value);
|
|
41
|
+
return String(value);
|
|
19
42
|
}
|
|
20
43
|
|
|
21
44
|
/**
|
|
@@ -35,23 +58,38 @@ function timestamp() {
|
|
|
35
58
|
*/
|
|
36
59
|
function renderEvent(event, mode, opts = {}) {
|
|
37
60
|
const { type, path, before, after, note } = event;
|
|
38
|
-
|
|
61
|
+
// The full path is what gets printed; the stripped one is what secret-name
|
|
62
|
+
// matching is allowed to see.
|
|
63
|
+
const maskOpts = { maskSecrets: Boolean(opts.maskSecrets), path: secretMatchPath(event) };
|
|
64
|
+
// Notes ride on every change type — a Terraform plan explains a create or a
|
|
65
|
+
// destroy there. The tree differ only ever notes changed events, so existing
|
|
66
|
+
// added/removed output is unaffected.
|
|
67
|
+
const noteStr = note ? chalk.dim(` [${note}]`) : '';
|
|
39
68
|
|
|
40
69
|
if (type === 'added') {
|
|
41
|
-
const line = ` ${chalk.green('+')} ${chalk.green(path)}: ${chalk.green(fmt(after, maskOpts))}`;
|
|
70
|
+
const line = ` ${chalk.green('+')} ${chalk.green(path)}: ${chalk.green(fmt(after, maskOpts))}${noteStr}`;
|
|
42
71
|
return mode === 'verbose'
|
|
43
72
|
? `${line}\n ${chalk.dim('(key added)')}`
|
|
44
73
|
: line;
|
|
45
74
|
}
|
|
46
75
|
|
|
47
76
|
if (type === 'removed') {
|
|
48
|
-
const line = ` ${chalk.red('-')} ${chalk.red(path)}: ${chalk.red(fmt(before, maskOpts))}`;
|
|
77
|
+
const line = ` ${chalk.red('-')} ${chalk.red(path)}: ${chalk.red(fmt(before, maskOpts))}${noteStr}`;
|
|
49
78
|
return mode === 'verbose'
|
|
50
79
|
? `${line}\n ${chalk.dim('(key removed)')}`
|
|
51
80
|
: line;
|
|
52
81
|
}
|
|
53
82
|
|
|
54
|
-
|
|
83
|
+
// Both sides ciphertext: the only honest thing to say is that it moved.
|
|
84
|
+
// Printing the two sentinels would be noise, and printing what they stand for
|
|
85
|
+
// is not something Flecto can do or wants to be able to do.
|
|
86
|
+
if (isEncryptedSentinel(before) && isEncryptedSentinel(after)) {
|
|
87
|
+
const line = ` ${chalk.yellow('~')} ${chalk.yellow(path)}: ${chalk.dim('<encrypted value changed>')}`;
|
|
88
|
+
return mode === 'verbose'
|
|
89
|
+
? `${line}\n ${chalk.dim('(Flecto never decrypts — only that the ciphertext differs is known)')}`
|
|
90
|
+
: line;
|
|
91
|
+
}
|
|
92
|
+
|
|
55
93
|
if (mode === 'verbose') {
|
|
56
94
|
return [
|
|
57
95
|
` ${chalk.yellow('~')} ${chalk.yellow(path)}${noteStr}`,
|
|
@@ -85,18 +123,22 @@ export function renderChanges(filepath, events, mode = 'compact', opts = {}) {
|
|
|
85
123
|
}
|
|
86
124
|
|
|
87
125
|
/**
|
|
88
|
-
* Print a diff result (for --diff
|
|
126
|
+
* Print a diff result (for `watch --diff` and `compare`) to stdout.
|
|
127
|
+
* `baseline` names the side the events are measured from, so a cross-file
|
|
128
|
+
* comparison reads unambiguously; it defaults to the saved snapshot.
|
|
89
129
|
* @param {string} filepath
|
|
90
130
|
* @param {import('./differ.js').ChangeEvent[]} events
|
|
91
|
-
* @param {{ maskSecrets?: boolean }} [opts]
|
|
131
|
+
* @param {{ maskSecrets?: boolean, baseline?: string }} [opts]
|
|
92
132
|
*/
|
|
93
133
|
export function renderDiff(filepath, events, opts = {}) {
|
|
134
|
+
const baseline = opts.baseline ?? 'snapshot';
|
|
135
|
+
|
|
94
136
|
if (events.length === 0) {
|
|
95
|
-
console.log(chalk.green(`✓ ${filepath} matches
|
|
137
|
+
console.log(chalk.green(`✓ ${filepath} matches ${baseline} — no changes`));
|
|
96
138
|
return;
|
|
97
139
|
}
|
|
98
140
|
|
|
99
|
-
console.log(chalk.cyan(`${filepath}`) + ` — ${events.length} change${events.length !== 1 ? 's' : ''} from
|
|
141
|
+
console.log(chalk.cyan(`${filepath}`) + ` — ${events.length} change${events.length !== 1 ? 's' : ''} from ${baseline}:`);
|
|
100
142
|
for (const event of events) {
|
|
101
143
|
console.log(renderEvent(event, 'compact', opts));
|
|
102
144
|
}
|
|
@@ -126,6 +168,14 @@ export function renderInfo(msg) {
|
|
|
126
168
|
console.log(chalk.dim(msg));
|
|
127
169
|
}
|
|
128
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Print a dim status note on stderr, so it stays out of machine-readable stdout.
|
|
173
|
+
* @param {string} msg
|
|
174
|
+
*/
|
|
175
|
+
export function renderNote(msg) {
|
|
176
|
+
console.error(chalk.dim(msg));
|
|
177
|
+
}
|
|
178
|
+
|
|
129
179
|
/**
|
|
130
180
|
* Print policy findings.
|
|
131
181
|
* @param {import('./policy.js').PolicyFinding[]} findings
|
|
@@ -144,7 +194,9 @@ export function renderPolicyFindings(findings) {
|
|
|
144
194
|
}
|
|
145
195
|
|
|
146
196
|
/**
|
|
147
|
-
* Mask secret-like values in a plain object tree for CI output.
|
|
197
|
+
* Mask secret-like values in a plain object tree for CI output. A value is
|
|
198
|
+
* masked when its path looks secret, or — see secrets.js — when the value
|
|
199
|
+
* itself does, which covers credentials stored under innocuous key names.
|
|
148
200
|
* @param {unknown} value
|
|
149
201
|
* @param {string} [path]
|
|
150
202
|
* @returns {unknown}
|
|
@@ -167,6 +219,7 @@ export function maskSensitiveValue(value, path = '') {
|
|
|
167
219
|
}
|
|
168
220
|
return out;
|
|
169
221
|
}
|
|
222
|
+
if (typeof value === 'string') return redactSecretString(value);
|
|
170
223
|
return value;
|
|
171
224
|
}
|
|
172
225
|
|
|
@@ -175,9 +228,10 @@ export function maskSensitiveValue(value, path = '') {
|
|
|
175
228
|
* @returns {import('./differ.js').ChangeEvent}
|
|
176
229
|
*/
|
|
177
230
|
export function maskChangeEvent(event) {
|
|
231
|
+
const path = secretMatchPath(event);
|
|
178
232
|
return {
|
|
179
233
|
...event,
|
|
180
|
-
before: event.before === undefined ? undefined : maskSensitiveValue(event.before,
|
|
181
|
-
after: event.after === undefined ? undefined : maskSensitiveValue(event.after,
|
|
234
|
+
before: event.before === undefined ? undefined : maskSensitiveValue(event.before, path),
|
|
235
|
+
after: event.after === undefined ? undefined : maskSensitiveValue(event.after, path),
|
|
182
236
|
};
|
|
183
237
|
}
|