audit-labs/gh-attest
GitHub Audit Evidence Extractor
clone: git clone https://gitbay.org/audit-labs/gh-attest.git
1import type { EvidenceRow, Framework } from "./exporter";
2
3export interface ExportListRow {
4 id: string;
5 framework: string;
6 format: string;
7 status: string;
8 created_at: string;
9}
10
11export interface InstallationOption {
12 installation_id: number;
13 org_login: string;
14}
15
16// Shown only when the user can see more than one installation; a single-org
17// user gets the plain org name instead of a pointless dropdown.
18function installationSwitcher(
19 installations: InstallationOption[],
20 current: number,
21 returnTo: "dashboard" | "access-review",
22): string {
23 if (installations.length < 2) return "";
24 const options = installations
25 .map(
26 (i) =>
27 `<option value="${esc(i.installation_id)}"${i.installation_id === current ? " selected" : ""}>${esc(
28 i.org_login,
29 )}</option>`,
30 )
31 .join("");
32 return `<form method="post" action="/switch" class="switcher">
33 <input type="hidden" name="return" value="${esc(returnTo)}">
34 <select name="installationId" onchange="this.form.submit()">${options}</select>
35 <noscript><button type="submit">Switch</button></noscript>
36 </form>`;
37}
38
39export const POSTURES = ["negative", "positive", "informational"] as const;
40export type Posture = (typeof POSTURES)[number];
41
42export function normalizePosture(value: string | null): Posture | null {
43 return POSTURES.includes(value as Posture) ? (value as Posture) : null;
44}
45
46export interface DashboardData {
47 login: string;
48 installationId: number;
49 orgLogin: string;
50 installations: InstallationOption[];
51 framework: Framework;
52 // Null = show every posture; otherwise the table is narrowed to the one
53 // whose card the user clicked.
54 posture: Posture | null;
55 rows: EvidenceRow[];
56 exports: ExportListRow[];
57 lastPolledAt: string | null;
58 excludedRepos: string[];
59 // Repos seen in this installation's snapshots that aren't excluded yet —
60 // the options the exclusion form offers.
61 excludableRepos: string[];
62}
63
64// Deliberately narrower than `unknown`: an object reaching here would render
65// as "[object Object]" in an evidence table, which is worse than failing.
66// Keeping the parameter to primitives makes that a compile error instead.
67function esc(value: string | number | null | undefined): string {
68 return String(value ?? "").replace(/[&<>"']/g, (c) => {
69 switch (c) {
70 case "&": return "&";
71 case "<": return "<";
72 case ">": return ">";
73 case '"': return """;
74 default: return "'";
75 }
76 });
77}
78
79const STYLE = `
80 :root { color-scheme: light; }
81 * { box-sizing: border-box; }
82 body { margin: 0; font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
83 color: #1a1a1a; background: #f6f7f9; }
84 header { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem;
85 padding: 1rem 1.5rem; background: #fff; border-bottom: 1px solid #e2e5e9; flex-wrap: wrap; }
86 header h1 { font-size: 1.05rem; margin: 0; }
87 header .who { color: #666; font-size: 0.85rem; }
88 header .who a { color: #0055dc; margin-left: 0.75rem; }
89 main { max-width: 1100px; margin: 0 auto; padding: 1.5rem; }
90 .cards { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
91 .card { flex: 1 1 120px; background: #fff; border: 1px solid #e2e5e9; border-radius: 8px; padding: 0.9rem 1rem; }
92 a.card { display: block; color: inherit; text-decoration: none; }
93 a.card:hover { border-color: #0055dc; }
94 a.card.active { border-color: #0055dc; box-shadow: inset 0 0 0 1px #0055dc; }
95 a.card.active .l::after { content: " ✕"; }
96 .card .n { font-size: 1.6rem; font-weight: 600; }
97 .card .l { color: #666; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; }
98 .n.positive { color: #1a8039; } .n.negative { color: #b32626; } .n.informational { color: #666; }
99 .bar { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; margin-bottom: 1rem; }
100 .bar .filters a { margin-right: 0.5rem; text-decoration: none; color: #0055dc; padding: 0.2rem 0.5rem; border-radius: 5px; }
101 .bar .filters a.active { background: #0055dc; color: #fff; }
102 form { display: inline-flex; gap: 0.4rem; align-items: center; margin: 0; }
103 input, select, button { font: inherit; padding: 0.35rem 0.6rem; border: 1px solid #c9ced6; border-radius: 6px; background: #fff; }
104 button { cursor: pointer; background: #0055dc; color: #fff; border-color: #0055dc; }
105 button.secondary { background: #fff; color: #1a1a1a; }
106 table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #e2e5e9; border-radius: 8px; overflow: hidden; }
107 th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #eef0f3; font-size: 0.85rem; }
108 th { background: #fafbfc; font-weight: 600; color: #444; }
109 tr:last-child td { border-bottom: none; }
110 .posture { font-weight: 600; }
111 .posture.positive { color: #1a8039; } .posture.negative { color: #b32626; } .posture.informational { color: #888; }
112 .section-title { font-size: 1rem; margin: 2rem 0 0.75rem; }
113 .muted { color: #888; }
114 code { background: #eef0f3; padding: 0.1rem 0.3rem; border-radius: 4px; font-size: 0.85em; }
115`;
116
117export function renderDashboard(data: DashboardData): string {
118 const counts = { positive: 0, negative: 0, informational: 0 };
119 const repos = new Set<string>();
120 for (const r of data.rows) {
121 counts[r.posture as keyof typeof counts] = (counts[r.posture as keyof typeof counts] ?? 0) + 1;
122 if (r.repo) repos.add(r.repo);
123 }
124
125 // Cards count the whole framework view, so the numbers stay stable while a
126 // posture filter is applied — otherwise clicking "Gaps" would zero the other
127 // two cards and there would be nothing left to click.
128 const href = (framework: Framework, posture: Posture | null) =>
129 `/?framework=${framework}${posture ? `&posture=${posture}` : ""}`;
130
131 const frameworkTab = (value: Framework, label: string) =>
132 `<a href="${href(value, data.posture)}" class="${data.framework === value ? "active" : ""}">${label}</a>`;
133
134 // Clicking the active card clears the filter rather than reapplying it.
135 const postureCard = (posture: Posture, label: string) => {
136 const active = data.posture === posture;
137 return `<a class="card${active ? " active" : ""}" href="${href(data.framework, active ? null : posture)}"
138 aria-pressed="${active}"><div class="n ${posture}">${counts[posture]}</div><div class="l">${label}</div></a>`;
139 };
140
141 const visibleRows = data.posture ? data.rows.filter((r) => r.posture === data.posture) : data.rows;
142
143 const evidenceRows = visibleRows
144 .map(
145 (r) => `<tr>
146 <td>${esc(r.framework)}</td>
147 <td>${esc(r.control_id)}</td>
148 <td class="posture ${esc(r.posture)}">${esc(r.posture)}</td>
149 <td>${esc(r.repo ?? r.subject ?? "—")}</td>
150 <td>${esc(r.resource)}</td>
151 <td>${esc(r.status)}</td>
152 </tr>`,
153 )
154 .join("");
155
156 const exportRows = data.exports
157 .map((e) => {
158 const done = e.status === "done";
159 const cell = done
160 ? `<a href="/exports/${esc(e.id)}/download">Download ${esc(e.format.toUpperCase())}</a>`
161 : `<span class="muted" data-export-id="${esc(e.id)}">${esc(e.status)}…</span>`;
162 return `<tr>
163 <td>${esc(e.created_at)}</td>
164 <td>${esc(e.framework)}</td>
165 <td>${esc(e.format.toUpperCase())}</td>
166 <td class="export-status">${cell}</td>
167 </tr>`;
168 })
169 .join("");
170
171 const excludeForm = data.excludableRepos.length
172 ? `<div class="bar"><form method="post" action="/exclusions">
173 <select name="repo">${data.excludableRepos.map((r) => `<option value="${esc(r)}">${esc(r)}</option>`).join("")}</select>
174 <button type="submit">Exclude</button>
175 </form></div>`
176 : `<p class="muted">No repositories left to exclude.</p>`;
177
178 const exclusionRows = data.excludedRepos
179 .map(
180 (repo) => `<tr>
181 <td>${esc(repo)}</td>
182 <td><form method="post" action="/exclusions">
183 <input type="hidden" name="repo" value="${esc(repo)}">
184 <input type="hidden" name="action" value="remove">
185 <button class="secondary" type="submit">Include again</button>
186 </form></td>
187 </tr>`,
188 )
189 .join("");
190
191 return `<!doctype html>
192<html lang="en">
193<head>
194 <meta charset="utf-8">
195 <meta name="viewport" content="width=device-width, initial-scale=1">
196 <title>gh-attest — Compliance Evidence</title>
197 <style>${STYLE}</style>
198</head>
199<body>
200 <header>
201 <h1>gh-attest — Compliance Evidence</h1>
202 <div class="who">${esc(data.login)} ·
203 ${installationSwitcher(data.installations, data.installationId, "dashboard") || esc(data.orgLogin)}
204 <a href="/access-review">Access review</a><a href="/logout">Log out</a></div>
205 </header>
206 <main>
207 <div class="cards">
208 ${postureCard("negative", "Gaps")}
209 ${postureCard("positive", "Satisfied")}
210 ${postureCard("informational", "Informational")}
211 <div class="card"><div class="n">${repos.size}</div><div class="l">Repositories</div></div>
212 </div>
213
214 <div class="bar">
215 <div class="filters">
216 ${frameworkTab("all", "All")}
217 ${frameworkTab("soc2", "SOC 2")}
218 ${frameworkTab("iso27001", "ISO 27001")}
219 </div>
220 <form method="post" action="/resync">
221 <button class="secondary" type="submit">Re-sync now</button>
222 </form>
223 <form method="post" action="/exports">
224 <input type="hidden" name="framework" value="${esc(data.framework)}">
225 <select name="format">
226 <option value="csv">CSV</option>
227 <option value="pdf">PDF</option>
228 </select>
229 <button type="submit">Generate export</button>
230 </form>
231 </div>
232
233 <p class="muted">${
234 data.lastPolledAt ? `Last synced ${esc(data.lastPolledAt)}` : "Not yet synced — click Re-sync now."
235 }</p>
236
237 <table>
238 <thead><tr><th>Framework</th><th>Control</th><th>Posture</th><th>Repo / Subject</th><th>Resource</th><th>Status</th></tr></thead>
239 <tbody>${
240 evidenceRows ||
241 `<tr><td colspan="6" class="muted">${
242 data.posture ? `No ${esc(data.posture)} evidence in this view.` : "No evidence yet."
243 }</td></tr>`
244 }</tbody>
245 </table>
246
247 <h2 class="section-title">Excluded repositories</h2>
248 <p class="muted">Excluded repositories are skipped by the sync and contribute no evidence.
249 Their existing history is kept, so including one again restores it.</p>
250 ${excludeForm}
251 <table>
252 <thead><tr><th>Repository</th><th></th></tr></thead>
253 <tbody>${exclusionRows || `<tr><td colspan="2" class="muted">No repositories excluded.</td></tr>`}</tbody>
254 </table>
255
256 <h2 class="section-title">Recent exports</h2>
257 <table>
258 <thead><tr><th>Created</th><th>Framework</th><th>Format</th><th>File</th></tr></thead>
259 <tbody>${exportRows || `<tr><td colspan="4" class="muted">No exports yet.</td></tr>`}</tbody>
260 </table>
261 </main>
262
263 <script>
264 // Poll any pending exports and swap in the download link when ready.
265 for (const el of document.querySelectorAll("[data-export-id]")) {
266 const id = el.getAttribute("data-export-id");
267 const tick = async () => {
268 const r = await fetch("/exports/" + id, { headers: { accept: "application/json" } });
269 if (!r.ok) return;
270 const job = await r.json();
271 if (job.status === "done") {
272 el.closest(".export-status").innerHTML =
273 '<a href="/exports/' + id + '/download">Download ' + String(job.format).toUpperCase() + "</a>";
274 } else if (job.status === "error") {
275 el.textContent = "error";
276 } else {
277 setTimeout(tick, 3000);
278 }
279 };
280 setTimeout(tick, 3000);
281 }
282 </script>
283</body>
284</html>`;
285}
286
287export interface AccessReviewData {
288 login: string;
289 installationId: number;
290 orgLogin: string;
291 installations: InstallationOption[];
292 since: string;
293 diff: import("./access-review").AccessDiff;
294}
295
296const CHANGE_CLASS: Record<string, string> = {
297 added: "negative", // new access is what an access review scrutinises
298 removed: "positive",
299 changed: "informational",
300};
301
302export function renderAccessReview(data: AccessReviewData): string {
303 const { diff } = data;
304
305 const rows = diff.entries
306 .map(
307 (e) => `<tr>
308 <td class="posture ${esc(CHANGE_CLASS[e.change] ?? "informational")}">${esc(e.change)}</td>
309 <td>${esc(e.resource === "org_member" ? "org member" : "team member")}</td>
310 <td>${esc(e.subject)}</td>
311 <td>${esc(e.from ?? "—")}</td>
312 <td>${esc(e.to ?? "—")}</td>
313 </tr>`,
314 )
315 .join("");
316
317 let banner: string;
318 if (!diff.currentAt) {
319 banner = `<p class="muted">No access data collected yet. Access review requires the App to be
320 installed on an <strong>organization</strong> (personal accounts have no membership to review),
321 and at least one sync to have run.</p>`;
322 } else if (!diff.priorAt) {
323 banner = `<p class="muted">Baseline captured ${esc(diff.currentAt)} (${diff.currentCount} access
324 entries). No earlier snapshot before ${esc(data.since)} to compare against yet — the next sync
325 after that date will produce a diff.</p>`;
326 } else {
327 banner = `<p class="muted">Comparing ${esc(diff.priorAt)} → ${esc(diff.currentAt)} ·
328 ${diff.currentCount} current access entries · ${diff.entries.length} change(s).</p>`;
329 }
330
331 return `<!doctype html>
332<html lang="en">
333<head>
334 <meta charset="utf-8">
335 <meta name="viewport" content="width=device-width, initial-scale=1">
336 <title>gh-attest — Access Review</title>
337 <style>${STYLE}</style>
338</head>
339<body>
340 <header>
341 <h1>gh-attest — Access Review</h1>
342 <div class="who">${esc(data.login)} ·
343 ${installationSwitcher(data.installations, data.installationId, "access-review") || esc(data.orgLogin)}
344 <a href="/">Dashboard</a><a href="/logout">Log out</a></div>
345 </header>
346 <main>
347 <div class="bar">
348 <form method="get" action="/access-review">
349 <label for="since">Compare against</label>
350 <input id="since" type="date" name="since" value="${esc(data.since.slice(0, 10))}">
351 <button type="submit">Update</button>
352 </form>
353 </div>
354
355 ${banner}
356
357 <table>
358 <thead><tr><th>Change</th><th>Type</th><th>Subject</th><th>Was</th><th>Now</th></tr></thead>
359 <tbody>${rows || `<tr><td colspan="5" class="muted">No membership changes in this window.</td></tr>`}</tbody>
360 </table>
361 </main>
362</body>
363</html>`;
364}