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/report.js
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static HTML drift report.
|
|
3
|
+
*
|
|
4
|
+
* `renderReportHtml` is pure: it reads nothing but its argument, so the bytes
|
|
5
|
+
* written to disk are exactly the bytes a test can assert on. The page is
|
|
6
|
+
* deliberately self-contained — inline CSS, one small inline script, no fonts,
|
|
7
|
+
* no images, no network access at view time. A report is a file you attach to
|
|
8
|
+
* an incident thread, so it has to render identically on a machine that is
|
|
9
|
+
* offline, and it must never phone home.
|
|
10
|
+
*
|
|
11
|
+
* Every value that reaches the page goes through `escapeHtml` first. Config
|
|
12
|
+
* values are attacker-influenced in the sense that matters here: a value
|
|
13
|
+
* containing `</div>` or `<script>` must render as text, never as markup.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { isAbsolute, relative } from 'path';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {{
|
|
20
|
+
* file: string,
|
|
21
|
+
* createdAt: string,
|
|
22
|
+
* changeCount: number,
|
|
23
|
+
* previousCreatedAt?: string | null,
|
|
24
|
+
* changes?: import('./differ.js').ChangeEvent[],
|
|
25
|
+
* policies?: import('./policy.js').PolicyFinding[]
|
|
26
|
+
* }} ReportSnapshot
|
|
27
|
+
*
|
|
28
|
+
* @typedef {{
|
|
29
|
+
* snapshots?: ReportSnapshot[],
|
|
30
|
+
* generatedAt?: string,
|
|
31
|
+
* cwd?: string,
|
|
32
|
+
* version?: string,
|
|
33
|
+
* limit?: number,
|
|
34
|
+
* maskSecrets?: boolean
|
|
35
|
+
* }} ReportData
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const SEVERITY_ORDER = ['error', 'warn', 'info'];
|
|
39
|
+
const SEVERITY_HEADINGS = { error: 'Errors', warn: 'Warnings', info: 'Notices' };
|
|
40
|
+
const SEVERITY_NOUNS = { error: 'error', warn: 'warning', info: 'notice' };
|
|
41
|
+
const CHANGE_TYPES = ['changed', 'added', 'removed'];
|
|
42
|
+
const CHANGE_SYMBOLS = { added: '+', removed: '-', changed: '~' };
|
|
43
|
+
/** Long values (PEM blocks, embedded JSON) would otherwise dominate the page. */
|
|
44
|
+
const MAX_VALUE_CHARS = 400;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Characters that can break out of an HTML text node or an attribute value.
|
|
48
|
+
* `=` and the backtick are included because they end an unquoted attribute in
|
|
49
|
+
* some legacy parsers, which is the cheapest way to be wrong about escaping.
|
|
50
|
+
*/
|
|
51
|
+
const HTML_ESCAPES = {
|
|
52
|
+
'&': '&',
|
|
53
|
+
'<': '<',
|
|
54
|
+
'>': '>',
|
|
55
|
+
'"': '"',
|
|
56
|
+
"'": ''',
|
|
57
|
+
'`': '`',
|
|
58
|
+
'=': '=',
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Escape a value for interpolation into HTML text or a quoted attribute.
|
|
63
|
+
*
|
|
64
|
+
* One regex pass replaces each source character exactly once, so an entity this
|
|
65
|
+
* function emits is never re-escaped and `&` in a config value survives as
|
|
66
|
+
* the literal text it was. Nullish input renders as the empty string rather
|
|
67
|
+
* than the literal "null" — an absent value should read as absent.
|
|
68
|
+
* @param {unknown} value
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
export function escapeHtml(value) {
|
|
72
|
+
if (value === null || value === undefined) return '';
|
|
73
|
+
return String(value).replace(/[&<>"'`=]/g, (char) => HTML_ESCAPES[char]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {number} count
|
|
78
|
+
* @param {string} noun
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
function plural(count, noun) {
|
|
82
|
+
return `${count} ${noun}${count === 1 ? '' : 's'}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Normalize a snapshot timestamp into an unambiguous UTC label plus the raw
|
|
87
|
+
* ISO string for the `datetime` attribute. Unparseable input is shown verbatim
|
|
88
|
+
* rather than silently dropped.
|
|
89
|
+
* @param {unknown} value
|
|
90
|
+
* @returns {{ iso: string, label: string }}
|
|
91
|
+
*/
|
|
92
|
+
function formatTimestamp(value) {
|
|
93
|
+
const raw = String(value ?? '');
|
|
94
|
+
const date = new Date(raw);
|
|
95
|
+
if (!raw || Number.isNaN(date.getTime())) {
|
|
96
|
+
return { iso: raw, label: raw || 'unknown time' };
|
|
97
|
+
}
|
|
98
|
+
const iso = date.toISOString();
|
|
99
|
+
return { iso, label: `${iso.slice(0, 10)} ${iso.slice(11, 19)} UTC` };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Format one side of a change, or return null when that side is absent.
|
|
104
|
+
* @param {unknown} value
|
|
105
|
+
* @returns {string | null}
|
|
106
|
+
*/
|
|
107
|
+
function formatValue(value) {
|
|
108
|
+
if (value === undefined) return null;
|
|
109
|
+
const json = JSON.stringify(value);
|
|
110
|
+
const text = json === undefined ? String(value) : json;
|
|
111
|
+
return text.length > MAX_VALUE_CHARS ? `${text.slice(0, MAX_VALUE_CHARS)}…` : text;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param {ReportSnapshot} snapshot
|
|
116
|
+
* @returns {import('./differ.js').ChangeEvent[]}
|
|
117
|
+
*/
|
|
118
|
+
function changesOf(snapshot) {
|
|
119
|
+
return Array.isArray(snapshot.changes) ? snapshot.changes : [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {ReportSnapshot} snapshot
|
|
124
|
+
* @returns {import('./policy.js').PolicyFinding[]}
|
|
125
|
+
*/
|
|
126
|
+
function findingsOf(snapshot) {
|
|
127
|
+
return Array.isArray(snapshot.policies) ? snapshot.policies : [];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Prefer a path relative to where the report was generated; an absolute runner
|
|
132
|
+
* path is noise. Anything outside `cwd` keeps its absolute form.
|
|
133
|
+
* @param {string} file
|
|
134
|
+
* @param {string} [cwd]
|
|
135
|
+
* @returns {string}
|
|
136
|
+
*/
|
|
137
|
+
function displayPath(file, cwd) {
|
|
138
|
+
const value = String(file ?? '');
|
|
139
|
+
if (!cwd || !isAbsolute(value)) return value;
|
|
140
|
+
const rel = relative(cwd, value);
|
|
141
|
+
if (!rel || rel.startsWith('..') || isAbsolute(rel)) return value;
|
|
142
|
+
return rel.replaceAll('\\', '/');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @param {unknown} severity
|
|
147
|
+
* @returns {'error' | 'warn' | 'info'}
|
|
148
|
+
*/
|
|
149
|
+
function normalizeSeverity(severity) {
|
|
150
|
+
return SEVERITY_ORDER.includes(severity) ? severity : 'info';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Group snapshots by file, keeping the order they arrive in (newest first), so
|
|
155
|
+
* the file whose config moved most recently leads the report.
|
|
156
|
+
* @param {ReportSnapshot[]} snapshots
|
|
157
|
+
* @returns {Array<{ file: string, entries: ReportSnapshot[] }>}
|
|
158
|
+
*/
|
|
159
|
+
function groupByFile(snapshots) {
|
|
160
|
+
/** @type {Map<string, ReportSnapshot[]>} */
|
|
161
|
+
const byFile = new Map();
|
|
162
|
+
for (const snapshot of snapshots) {
|
|
163
|
+
const file = String(snapshot.file ?? '');
|
|
164
|
+
const entries = byFile.get(file) ?? [];
|
|
165
|
+
entries.push(snapshot);
|
|
166
|
+
byFile.set(file, entries);
|
|
167
|
+
}
|
|
168
|
+
return [...byFile.entries()].map(([file, entries]) => ({ file, entries }));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @param {string} label
|
|
173
|
+
* @param {string} value
|
|
174
|
+
* @param {string} [tone]
|
|
175
|
+
* @returns {string}
|
|
176
|
+
*/
|
|
177
|
+
function statTile(label, value, tone = '') {
|
|
178
|
+
return `<div class="stat${tone ? ` stat-${tone}` : ''}">`
|
|
179
|
+
+ `<div class="stat-value">${escapeHtml(value)}</div>`
|
|
180
|
+
+ `<div class="stat-label">${escapeHtml(label)}</div>`
|
|
181
|
+
+ '</div>';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @param {import('./differ.js').ChangeEvent} change
|
|
186
|
+
* @returns {string}
|
|
187
|
+
*/
|
|
188
|
+
function changeRow(change) {
|
|
189
|
+
const type = CHANGE_TYPES.includes(change.type) ? change.type : 'changed';
|
|
190
|
+
const before = formatValue(change.before);
|
|
191
|
+
const after = formatValue(change.after);
|
|
192
|
+
const note = change.note ? `<div class="note">${escapeHtml(change.note)}</div>` : '';
|
|
193
|
+
return [
|
|
194
|
+
`<tr class="change change-${escapeHtml(type)}">`,
|
|
195
|
+
`<td class="cell-type"><span class="badge badge-${escapeHtml(type)}">`
|
|
196
|
+
+ `<span class="sym">${escapeHtml(CHANGE_SYMBOLS[type])}</span> ${escapeHtml(type)}</span></td>`,
|
|
197
|
+
`<td class="cell-path"><code>${escapeHtml(change.path ?? '')}</code>${note}</td>`,
|
|
198
|
+
`<td class="cell-value">${before === null ? '<span class="absent">—</span>' : `<code>${escapeHtml(before)}</code>`}</td>`,
|
|
199
|
+
`<td class="cell-value">${after === null ? '<span class="absent">—</span>' : `<code>${escapeHtml(after)}</code>`}</td>`,
|
|
200
|
+
'</tr>',
|
|
201
|
+
].join('');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* @param {import('./policy.js').PolicyFinding} finding
|
|
206
|
+
* @returns {string}
|
|
207
|
+
*/
|
|
208
|
+
function findingItem(finding) {
|
|
209
|
+
const severity = normalizeSeverity(finding.severity);
|
|
210
|
+
const pack = finding.pack ? ` <span class="pack">[${escapeHtml(finding.pack)}]</span>` : '';
|
|
211
|
+
return `<li class="finding finding-${escapeHtml(severity)}">`
|
|
212
|
+
+ `<span class="sev sev-${escapeHtml(severity)}">${escapeHtml(severity)}</span>`
|
|
213
|
+
+ `<span class="rule"><code>${escapeHtml(finding.id ?? '')}</code>${pack}</span>`
|
|
214
|
+
+ `<span class="finding-body"><code>${escapeHtml(finding.path ?? '')}</code> — ${escapeHtml(finding.message ?? '')}</span>`
|
|
215
|
+
+ '</li>';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* One snapshot in a file's timeline: when it was taken, how far it moved from
|
|
220
|
+
* the snapshot before it, and everything that moved.
|
|
221
|
+
* @param {ReportSnapshot} snapshot
|
|
222
|
+
* @param {number} index
|
|
223
|
+
* @returns {string}
|
|
224
|
+
*/
|
|
225
|
+
function snapshotCard(snapshot, index) {
|
|
226
|
+
const time = formatTimestamp(snapshot.createdAt);
|
|
227
|
+
const changes = changesOf(snapshot);
|
|
228
|
+
const findings = findingsOf(snapshot);
|
|
229
|
+
const count = Number.isInteger(snapshot.changeCount) ? snapshot.changeCount : changes.length;
|
|
230
|
+
const previous = snapshot.previousCreatedAt ? formatTimestamp(snapshot.previousCreatedAt) : null;
|
|
231
|
+
|
|
232
|
+
const baselineNote = previous
|
|
233
|
+
? `<span class="baseline">since <time datetime="${escapeHtml(previous.iso)}">${escapeHtml(previous.label)}</time></span>`
|
|
234
|
+
: '<span class="baseline">first snapshot — nothing to compare against</span>';
|
|
235
|
+
|
|
236
|
+
const body = [];
|
|
237
|
+
if (changes.length > 0) {
|
|
238
|
+
body.push(
|
|
239
|
+
'<div class="scroll"><table class="changes">',
|
|
240
|
+
'<thead><tr><th>Change</th><th>Path</th><th>Before</th><th>After</th></tr></thead>',
|
|
241
|
+
'<tbody>',
|
|
242
|
+
...changes.map(changeRow),
|
|
243
|
+
'</tbody></table></div>',
|
|
244
|
+
);
|
|
245
|
+
} else if (count > 0) {
|
|
246
|
+
// changeCount without events: the caller summarized but did not diff.
|
|
247
|
+
body.push(`<p class="empty">${escapeHtml(plural(count, 'change'))} recorded.</p>`);
|
|
248
|
+
} else {
|
|
249
|
+
body.push('<p class="empty">No semantic changes from the previous snapshot.</p>');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (findings.length > 0) {
|
|
253
|
+
body.push(
|
|
254
|
+
`<h4 class="findings-title">Policy findings (${escapeHtml(String(findings.length))})</h4>`,
|
|
255
|
+
`<ul class="findings">${findings.map(findingItem).join('')}</ul>`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const countClass = count > 0 ? 'count count-active' : 'count';
|
|
260
|
+
return [
|
|
261
|
+
`<details class="card" open id="snapshot-${escapeHtml(String(index))}">`,
|
|
262
|
+
'<summary>',
|
|
263
|
+
`<time class="stamp" datetime="${escapeHtml(time.iso)}">${escapeHtml(time.label)}</time>`,
|
|
264
|
+
`<span class="${countClass}">${escapeHtml(plural(count, 'change'))}</span>`,
|
|
265
|
+
baselineNote,
|
|
266
|
+
'</summary>',
|
|
267
|
+
`<div class="card-body">${body.join('')}</div>`,
|
|
268
|
+
'</details>',
|
|
269
|
+
].join('');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* @param {Array<{ file: string, snapshot: ReportSnapshot, finding: import('./policy.js').PolicyFinding }>} rows
|
|
274
|
+
* @returns {string}
|
|
275
|
+
*/
|
|
276
|
+
function findingsTable(rows) {
|
|
277
|
+
const body = rows.map(({ file, snapshot, finding }) => {
|
|
278
|
+
const time = formatTimestamp(snapshot.createdAt);
|
|
279
|
+
return '<tr>'
|
|
280
|
+
+ `<td><code>${escapeHtml(finding.id ?? '')}</code>`
|
|
281
|
+
+ `${finding.pack ? ` <span class="pack">[${escapeHtml(finding.pack)}]</span>` : ''}</td>`
|
|
282
|
+
+ `<td><code>${escapeHtml(file)}</code></td>`
|
|
283
|
+
+ `<td><code>${escapeHtml(finding.path ?? '')}</code></td>`
|
|
284
|
+
+ `<td>${escapeHtml(finding.message ?? '')}</td>`
|
|
285
|
+
+ `<td><time datetime="${escapeHtml(time.iso)}">${escapeHtml(time.label)}</time></td>`
|
|
286
|
+
+ '</tr>';
|
|
287
|
+
}).join('');
|
|
288
|
+
return '<div class="scroll"><table class="findings-table">'
|
|
289
|
+
+ '<thead><tr><th>Rule</th><th>File</th><th>Path</th><th>Message</th><th>Snapshot</th></tr></thead>'
|
|
290
|
+
+ `<tbody>${body}</tbody></table></div>`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const STYLE = `
|
|
294
|
+
:root {
|
|
295
|
+
color-scheme: light dark;
|
|
296
|
+
--bg: #ffffff;
|
|
297
|
+
--panel: #f6f7f9;
|
|
298
|
+
--panel-2: #eef0f4;
|
|
299
|
+
--border: #dfe3ea;
|
|
300
|
+
--text: #11161d;
|
|
301
|
+
--muted: #5a6675;
|
|
302
|
+
--accent: #1d5fbf;
|
|
303
|
+
--added: #146c43;
|
|
304
|
+
--removed: #b3261e;
|
|
305
|
+
--changed: #8a5a00;
|
|
306
|
+
--error: #b3261e;
|
|
307
|
+
--warn: #8a5a00;
|
|
308
|
+
--info: #1d5fbf;
|
|
309
|
+
}
|
|
310
|
+
@media (prefers-color-scheme: dark) {
|
|
311
|
+
:root {
|
|
312
|
+
--bg: #0e1116;
|
|
313
|
+
--panel: #161b22;
|
|
314
|
+
--panel-2: #1c222b;
|
|
315
|
+
--border: #2b323c;
|
|
316
|
+
--text: #e6edf3;
|
|
317
|
+
--muted: #9aa7b4;
|
|
318
|
+
--accent: #6ea8fe;
|
|
319
|
+
--added: #4ec98a;
|
|
320
|
+
--removed: #ff8785;
|
|
321
|
+
--changed: #e3b341;
|
|
322
|
+
--error: #ff8785;
|
|
323
|
+
--warn: #e3b341;
|
|
324
|
+
--info: #6ea8fe;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
* { box-sizing: border-box; }
|
|
328
|
+
body {
|
|
329
|
+
margin: 0;
|
|
330
|
+
background: var(--bg);
|
|
331
|
+
color: var(--text);
|
|
332
|
+
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
333
|
+
font-size: 15px;
|
|
334
|
+
line-height: 1.5;
|
|
335
|
+
}
|
|
336
|
+
code, .stamp, .count, .stat-value, td, th {
|
|
337
|
+
font-variant-numeric: tabular-nums;
|
|
338
|
+
}
|
|
339
|
+
code {
|
|
340
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
|
341
|
+
font-size: 0.88em;
|
|
342
|
+
overflow-wrap: anywhere;
|
|
343
|
+
}
|
|
344
|
+
.wrap { max-width: 1100px; margin: 0 auto; padding: 32px 20px 64px; }
|
|
345
|
+
header h1 { font-size: 1.6rem; margin: 0 0 6px; letter-spacing: -0.01em; }
|
|
346
|
+
.subtitle { color: var(--muted); margin: 0 0 18px; }
|
|
347
|
+
.meta { color: var(--muted); font-size: 0.86rem; margin: 0 0 24px; }
|
|
348
|
+
.meta div { margin: 2px 0; }
|
|
349
|
+
.masked {
|
|
350
|
+
display: inline-block;
|
|
351
|
+
border: 1px solid var(--border);
|
|
352
|
+
background: var(--panel-2);
|
|
353
|
+
border-radius: 999px;
|
|
354
|
+
padding: 1px 10px;
|
|
355
|
+
color: var(--text);
|
|
356
|
+
}
|
|
357
|
+
.stats {
|
|
358
|
+
display: grid;
|
|
359
|
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
360
|
+
gap: 10px;
|
|
361
|
+
margin: 0 0 28px;
|
|
362
|
+
}
|
|
363
|
+
.stat { border: 1px solid var(--border); background: var(--panel); border-radius: 8px; padding: 12px 14px; }
|
|
364
|
+
.stat-value { font-size: 1.5rem; font-weight: 600; line-height: 1.2; }
|
|
365
|
+
.stat-label { color: var(--muted); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
366
|
+
.stat-error .stat-value { color: var(--error); }
|
|
367
|
+
.stat-warn .stat-value { color: var(--warn); }
|
|
368
|
+
h2 { font-size: 1.05rem; margin: 32px 0 10px; padding-bottom: 6px; border-bottom: 1px solid var(--border); }
|
|
369
|
+
h2 code { font-size: 0.95em; }
|
|
370
|
+
h3 { font-size: 0.95rem; margin: 20px 0 8px; color: var(--muted); }
|
|
371
|
+
h4 { font-size: 0.85rem; margin: 14px 0 6px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
|
|
372
|
+
.controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0 0 8px; }
|
|
373
|
+
.controls input {
|
|
374
|
+
flex: 1 1 240px;
|
|
375
|
+
min-width: 0;
|
|
376
|
+
padding: 7px 10px;
|
|
377
|
+
border: 1px solid var(--border);
|
|
378
|
+
border-radius: 6px;
|
|
379
|
+
background: var(--bg);
|
|
380
|
+
color: var(--text);
|
|
381
|
+
font: inherit;
|
|
382
|
+
}
|
|
383
|
+
.controls button {
|
|
384
|
+
padding: 7px 12px;
|
|
385
|
+
border: 1px solid var(--border);
|
|
386
|
+
border-radius: 6px;
|
|
387
|
+
background: var(--panel);
|
|
388
|
+
color: var(--text);
|
|
389
|
+
font: inherit;
|
|
390
|
+
cursor: pointer;
|
|
391
|
+
}
|
|
392
|
+
.controls button:hover { background: var(--panel-2); }
|
|
393
|
+
.card { border: 1px solid var(--border); border-radius: 8px; background: var(--panel); margin: 0 0 10px; }
|
|
394
|
+
.card > summary {
|
|
395
|
+
cursor: pointer;
|
|
396
|
+
padding: 10px 14px;
|
|
397
|
+
display: flex;
|
|
398
|
+
flex-wrap: wrap;
|
|
399
|
+
gap: 6px 14px;
|
|
400
|
+
align-items: baseline;
|
|
401
|
+
}
|
|
402
|
+
/* display:flex drops the native disclosure marker, so draw our own. */
|
|
403
|
+
.card > summary { list-style: none; }
|
|
404
|
+
.card > summary::-webkit-details-marker { display: none; }
|
|
405
|
+
.card > summary::before { content: "\\25B8"; color: var(--muted); }
|
|
406
|
+
.card[open] > summary::before { content: "\\25BE"; }
|
|
407
|
+
.stamp { font-weight: 600; font-size: 0.95rem; }
|
|
408
|
+
.count { color: var(--muted); font-size: 0.86rem; }
|
|
409
|
+
.count-active { color: var(--text); }
|
|
410
|
+
.baseline { color: var(--muted); font-size: 0.82rem; margin-left: auto; }
|
|
411
|
+
.card-body { padding: 0 14px 14px; }
|
|
412
|
+
.scroll { overflow-x: auto; }
|
|
413
|
+
table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
|
|
414
|
+
th {
|
|
415
|
+
text-align: left;
|
|
416
|
+
color: var(--muted);
|
|
417
|
+
font-weight: 600;
|
|
418
|
+
font-size: 0.76rem;
|
|
419
|
+
text-transform: uppercase;
|
|
420
|
+
letter-spacing: 0.04em;
|
|
421
|
+
border-bottom: 1px solid var(--border);
|
|
422
|
+
padding: 6px 8px;
|
|
423
|
+
}
|
|
424
|
+
td { border-bottom: 1px solid var(--border); padding: 6px 8px; vertical-align: top; }
|
|
425
|
+
tr:last-child td { border-bottom: 0; }
|
|
426
|
+
.cell-type { white-space: nowrap; }
|
|
427
|
+
.cell-value code { overflow-wrap: anywhere; }
|
|
428
|
+
.badge { font-size: 0.78rem; font-weight: 600; }
|
|
429
|
+
.badge .sym { display: inline-block; width: 0.8em; font-family: ui-monospace, monospace; }
|
|
430
|
+
.badge-added { color: var(--added); }
|
|
431
|
+
.badge-removed { color: var(--removed); }
|
|
432
|
+
.badge-changed { color: var(--changed); }
|
|
433
|
+
.absent { color: var(--muted); }
|
|
434
|
+
.note { color: var(--muted); font-size: 0.8rem; }
|
|
435
|
+
.pack { color: var(--muted); }
|
|
436
|
+
.findings { list-style: none; margin: 0; padding: 0; }
|
|
437
|
+
.finding { display: flex; flex-wrap: wrap; gap: 4px 10px; padding: 5px 0; border-bottom: 1px solid var(--border); }
|
|
438
|
+
.finding:last-child { border-bottom: 0; }
|
|
439
|
+
.finding-body { flex: 1 1 320px; }
|
|
440
|
+
.sev { font-weight: 700; font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.04em; min-width: 44px; }
|
|
441
|
+
.sev-error { color: var(--error); }
|
|
442
|
+
.sev-warn { color: var(--warn); }
|
|
443
|
+
.sev-info { color: var(--info); }
|
|
444
|
+
.empty { color: var(--muted); margin: 8px 0; }
|
|
445
|
+
.no-matches { color: var(--muted); margin: 16px 0; }
|
|
446
|
+
footer { margin-top: 40px; padding-top: 14px; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.82rem; }
|
|
447
|
+
.hidden { display: none !important; }
|
|
448
|
+
@media (max-width: 620px) {
|
|
449
|
+
.wrap { padding: 20px 12px 40px; }
|
|
450
|
+
.baseline { margin-left: 0; flex-basis: 100%; }
|
|
451
|
+
}
|
|
452
|
+
@media print {
|
|
453
|
+
.controls { display: none; }
|
|
454
|
+
.wrap { max-width: none; padding: 0; }
|
|
455
|
+
.card { break-inside: avoid; border-color: #999; }
|
|
456
|
+
.card > summary::before { content: ""; }
|
|
457
|
+
}
|
|
458
|
+
`;
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Filtering and bulk expand/collapse. Vanilla, inline, and dependency-free:
|
|
462
|
+
* the page must work from a file:// URL with no network.
|
|
463
|
+
*/
|
|
464
|
+
const SCRIPT = `
|
|
465
|
+
(function () {
|
|
466
|
+
var input = document.getElementById('filter');
|
|
467
|
+
var cards = Array.prototype.slice.call(document.querySelectorAll('.card'));
|
|
468
|
+
var groups = Array.prototype.slice.call(document.querySelectorAll('.file-group'));
|
|
469
|
+
var empty = document.getElementById('no-matches');
|
|
470
|
+
if (!input) return;
|
|
471
|
+
|
|
472
|
+
function apply() {
|
|
473
|
+
var term = input.value.trim().toLowerCase();
|
|
474
|
+
var shown = 0;
|
|
475
|
+
cards.forEach(function (card) {
|
|
476
|
+
var hit = !term || (card.textContent || '').toLowerCase().indexOf(term) !== -1;
|
|
477
|
+
card.classList.toggle('hidden', !hit);
|
|
478
|
+
if (hit) shown++;
|
|
479
|
+
});
|
|
480
|
+
groups.forEach(function (group) {
|
|
481
|
+
var visible = group.querySelectorAll('.card:not(.hidden)').length;
|
|
482
|
+
group.classList.toggle('hidden', visible === 0);
|
|
483
|
+
});
|
|
484
|
+
if (empty) empty.classList.toggle('hidden', shown !== 0);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function setOpen(open) {
|
|
488
|
+
cards.forEach(function (card) { card.open = open; });
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
input.addEventListener('input', apply);
|
|
492
|
+
var expand = document.getElementById('expand-all');
|
|
493
|
+
var collapse = document.getElementById('collapse-all');
|
|
494
|
+
if (expand) expand.addEventListener('click', function () { setOpen(true); });
|
|
495
|
+
if (collapse) collapse.addEventListener('click', function () { setOpen(false); });
|
|
496
|
+
})();
|
|
497
|
+
`;
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Render the full drift report as a single self-contained HTML document.
|
|
501
|
+
*
|
|
502
|
+
* Pure — no filesystem, no clock, no network. Callers pass `generatedAt` so
|
|
503
|
+
* the same input always renders the same bytes.
|
|
504
|
+
* @param {ReportData} [data]
|
|
505
|
+
* @returns {string} A complete HTML document
|
|
506
|
+
*/
|
|
507
|
+
export function renderReportHtml(data = {}) {
|
|
508
|
+
const snapshots = Array.isArray(data.snapshots) ? data.snapshots : [];
|
|
509
|
+
const generated = formatTimestamp(data.generatedAt ?? new Date().toISOString());
|
|
510
|
+
const groups = groupByFile(snapshots);
|
|
511
|
+
|
|
512
|
+
const severityCounts = { error: 0, warn: 0, info: 0 };
|
|
513
|
+
/** @type {Array<{ file: string, snapshot: ReportSnapshot, finding: import('./policy.js').PolicyFinding }>} */
|
|
514
|
+
const allFindings = [];
|
|
515
|
+
let totalChanges = 0;
|
|
516
|
+
for (const snapshot of snapshots) {
|
|
517
|
+
const changes = changesOf(snapshot);
|
|
518
|
+
// Prefer the events actually carried; fall back to the count for callers
|
|
519
|
+
// that summarized without diffing.
|
|
520
|
+
totalChanges += Array.isArray(snapshot.changes)
|
|
521
|
+
? changes.length
|
|
522
|
+
: (Number.isInteger(snapshot.changeCount) ? snapshot.changeCount : 0);
|
|
523
|
+
for (const finding of findingsOf(snapshot)) {
|
|
524
|
+
const severity = normalizeSeverity(finding.severity);
|
|
525
|
+
severityCounts[severity] += 1;
|
|
526
|
+
allFindings.push({ file: displayPath(snapshot.file, data.cwd), snapshot, finding });
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const head = [
|
|
531
|
+
'<header>',
|
|
532
|
+
'<h1>Flecto drift report</h1>',
|
|
533
|
+
'<p class="subtitle">What your config changed, from local snapshot history.</p>',
|
|
534
|
+
'<div class="meta">',
|
|
535
|
+
`<div>Generated <time datetime="${escapeHtml(generated.iso)}">${escapeHtml(generated.label)}</time>`
|
|
536
|
+
+ ' · all timestamps are UTC</div>',
|
|
537
|
+
data.cwd ? `<div>Working directory <code>${escapeHtml(data.cwd)}</code></div>` : '',
|
|
538
|
+
data.version ? `<div>Flecto ${escapeHtml(data.version)}</div>` : '',
|
|
539
|
+
data.maskSecrets
|
|
540
|
+
? '<div><span class="masked">Secret masking on</span> — secret-like values are redacted in this report.</div>'
|
|
541
|
+
: '',
|
|
542
|
+
'</div>',
|
|
543
|
+
'</header>',
|
|
544
|
+
].filter(Boolean).join('');
|
|
545
|
+
|
|
546
|
+
if (snapshots.length === 0) {
|
|
547
|
+
return htmlDocument([
|
|
548
|
+
head,
|
|
549
|
+
'<p class="empty">No local snapshots found.'
|
|
550
|
+
+ ' Run <code>flecto watch <file> --snapshot</code> first.</p>',
|
|
551
|
+
footer(data),
|
|
552
|
+
].join(''));
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const stats = [
|
|
556
|
+
'<section class="stats">',
|
|
557
|
+
statTile('Snapshots', String(snapshots.length)),
|
|
558
|
+
statTile('Files', String(groups.length)),
|
|
559
|
+
statTile('Changes', String(totalChanges)),
|
|
560
|
+
statTile('Policy errors', String(severityCounts.error), 'error'),
|
|
561
|
+
statTile('Policy warnings', String(severityCounts.warn), 'warn'),
|
|
562
|
+
'</section>',
|
|
563
|
+
].join('');
|
|
564
|
+
|
|
565
|
+
const findingsSection = allFindings.length === 0
|
|
566
|
+
? '<h2>Policy findings</h2><p class="empty">No policy findings across these snapshots.</p>'
|
|
567
|
+
: [
|
|
568
|
+
`<h2>Policy findings (${escapeHtml(String(allFindings.length))})</h2>`,
|
|
569
|
+
...SEVERITY_ORDER.flatMap((severity) => {
|
|
570
|
+
const rows = allFindings.filter((row) => normalizeSeverity(row.finding.severity) === severity);
|
|
571
|
+
if (rows.length === 0) return [];
|
|
572
|
+
return [
|
|
573
|
+
`<h3>${escapeHtml(SEVERITY_HEADINGS[severity])} — `
|
|
574
|
+
+ `${escapeHtml(plural(rows.length, SEVERITY_NOUNS[severity]))}</h3>`,
|
|
575
|
+
findingsTable(rows),
|
|
576
|
+
];
|
|
577
|
+
}),
|
|
578
|
+
].join('');
|
|
579
|
+
|
|
580
|
+
const controls = [
|
|
581
|
+
'<div class="controls">',
|
|
582
|
+
'<input id="filter" type="search" placeholder="Filter by path, value, file, or rule"'
|
|
583
|
+
+ ' aria-label="Filter snapshots">',
|
|
584
|
+
'<button id="expand-all" type="button">Expand all</button>',
|
|
585
|
+
'<button id="collapse-all" type="button">Collapse all</button>',
|
|
586
|
+
'</div>',
|
|
587
|
+
'<p id="no-matches" class="no-matches hidden">Nothing matches that filter.</p>',
|
|
588
|
+
].join('');
|
|
589
|
+
|
|
590
|
+
let cardIndex = 0;
|
|
591
|
+
const timeline = groups.map(({ file, entries }) => {
|
|
592
|
+
const label = displayPath(file, data.cwd);
|
|
593
|
+
// The absolute path stays reachable on hover when the heading is shortened.
|
|
594
|
+
const title = label === file ? '' : ` title="${escapeHtml(file)}"`;
|
|
595
|
+
return [
|
|
596
|
+
'<section class="file-group">',
|
|
597
|
+
`<h2><code${title}>${escapeHtml(label)}</code></h2>`,
|
|
598
|
+
...entries.map((entry) => snapshotCard(entry, cardIndex++)),
|
|
599
|
+
'</section>',
|
|
600
|
+
].join('');
|
|
601
|
+
}).join('');
|
|
602
|
+
|
|
603
|
+
return htmlDocument([
|
|
604
|
+
head,
|
|
605
|
+
stats,
|
|
606
|
+
findingsSection,
|
|
607
|
+
`<h2>Snapshot timeline (${escapeHtml(plural(snapshots.length, 'snapshot'))})</h2>`,
|
|
608
|
+
controls,
|
|
609
|
+
timeline,
|
|
610
|
+
footer(data),
|
|
611
|
+
].join(''), true);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* @param {ReportData} data
|
|
616
|
+
* @returns {string}
|
|
617
|
+
*/
|
|
618
|
+
function footer(data) {
|
|
619
|
+
const limit = Number.isInteger(data.limit)
|
|
620
|
+
? ` Limited to the ${escapeHtml(plural(data.limit, 'most recent snapshot'))}.`
|
|
621
|
+
: '';
|
|
622
|
+
return '<footer>Generated by Flecto from <code>.flecto-snapshots/</code>.'
|
|
623
|
+
+ ' This file is self-contained: no external scripts, fonts, or images, and nothing'
|
|
624
|
+
+ ` is sent anywhere when you open it.${limit}</footer>`;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Wrap rendered body markup in the document shell. The shell is static text —
|
|
629
|
+
* no caller data reaches it — so the only inline `<style>` and `<script>`
|
|
630
|
+
* content on the page is Flecto's own.
|
|
631
|
+
* @param {string} body
|
|
632
|
+
* @param {boolean} [withScript]
|
|
633
|
+
* @returns {string}
|
|
634
|
+
*/
|
|
635
|
+
function htmlDocument(body, withScript = false) {
|
|
636
|
+
return [
|
|
637
|
+
'<!doctype html>',
|
|
638
|
+
'<html lang="en">',
|
|
639
|
+
'<head>',
|
|
640
|
+
'<meta charset="utf-8">',
|
|
641
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
642
|
+
'<meta name="robots" content="noindex">',
|
|
643
|
+
'<title>Flecto drift report</title>',
|
|
644
|
+
`<style>${STYLE}</style>`,
|
|
645
|
+
'</head>',
|
|
646
|
+
'<body>',
|
|
647
|
+
`<div class="wrap">${body}</div>`,
|
|
648
|
+
withScript ? `<script>${SCRIPT}</script>` : '',
|
|
649
|
+
'</body>',
|
|
650
|
+
'</html>',
|
|
651
|
+
'',
|
|
652
|
+
].filter(Boolean).join('\n');
|
|
653
|
+
}
|