audit-labs/gh-attest
GitHub Audit Evidence Extractor
clone: git clone https://gitbay.org/audit-labs/gh-attest.git
v1.0.0: src/dashboard.ts · raw
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 interface DashboardData {
40 login: string;
41 installationId: number;
42 orgLogin: string;
43 installations: InstallationOption[];
44 framework: Framework;
45 rows: EvidenceRow[];
46 exports: ExportListRow[];
47 lastPolledAt: string | null;
48}
49
50function esc(value: unknown): string {
51 return String(value ?? "").replace(/[&<>"']/g, (c) => {
52 switch (c) {
53 case "&": return "&";
54 case "<": return "<";
55 case ">": return ">";
56 case '"': return """;
57 default: return "'";
58 }
59 });
60}
61
62const STYLE = `
63 :root { color-scheme: light; }
64 * { box-sizing: border-box; }
65 body { margin: 0; font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
66 color: #1a1a1a; background: #f6f7f9; }
67 header { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem;
68 padding: 1rem 1.5rem; background: #fff; border-bottom: 1px solid #e2e5e9; flex-wrap: wrap; }
69 header h1 { font-size: 1.05rem; margin: 0; }
70 header .who { color: #666; font-size: 0.85rem; }
71 header .who a { color: #0055dc; margin-left: 0.75rem; }
72 main { max-width: 1100px; margin: 0 auto; padding: 1.5rem; }
73 .cards { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
74 .card { flex: 1 1 120px; background: #fff; border: 1px solid #e2e5e9; border-radius: 8px; padding: 0.9rem 1rem; }
75 .card .n { font-size: 1.6rem; font-weight: 600; }
76 .card .l { color: #666; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; }
77 .n.positive { color: #1a8039; } .n.negative { color: #b32626; } .n.informational { color: #666; }
78 .bar { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; margin-bottom: 1rem; }
79 .bar .filters a { margin-right: 0.5rem; text-decoration: none; color: #0055dc; padding: 0.2rem 0.5rem; border-radius: 5px; }
80 .bar .filters a.active { background: #0055dc; color: #fff; }
81 form { display: inline-flex; gap: 0.4rem; align-items: center; margin: 0; }
82 input, select, button { font: inherit; padding: 0.35rem 0.6rem; border: 1px solid #c9ced6; border-radius: 6px; background: #fff; }
83 button { cursor: pointer; background: #0055dc; color: #fff; border-color: #0055dc; }
84 button.secondary { background: #fff; color: #1a1a1a; }
85 table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #e2e5e9; border-radius: 8px; overflow: hidden; }
86 th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #eef0f3; font-size: 0.85rem; }
87 th { background: #fafbfc; font-weight: 600; color: #444; }
88 tr:last-child td { border-bottom: none; }
89 .posture { font-weight: 600; }
90 .posture.positive { color: #1a8039; } .posture.negative { color: #b32626; } .posture.informational { color: #888; }
91 .section-title { font-size: 1rem; margin: 2rem 0 0.75rem; }
92 .muted { color: #888; }
93 code { background: #eef0f3; padding: 0.1rem 0.3rem; border-radius: 4px; font-size: 0.85em; }
94`;
95
96export function renderDashboard(data: DashboardData): string {
97 const counts = { positive: 0, negative: 0, informational: 0 };
98 const repos = new Set<string>();
99 for (const r of data.rows) {
100 counts[r.posture as keyof typeof counts] = (counts[r.posture as keyof typeof counts] ?? 0) + 1;
101 if (r.repo) repos.add(r.repo);
102 }
103
104 const frameworkTab = (value: Framework, label: string) =>
105 `<a href="/?framework=${value}" class="${data.framework === value ? "active" : ""}">${label}</a>`;
106
107 const evidenceRows = data.rows
108 .map(
109 (r) => `<tr>
110 <td>${esc(r.framework)}</td>
111 <td>${esc(r.control_id)}</td>
112 <td class="posture ${esc(r.posture)}">${esc(r.posture)}</td>
113 <td>${esc(r.repo ?? r.subject ?? "—")}</td>
114 <td>${esc(r.resource)}</td>
115 <td>${esc(r.status)}</td>
116 </tr>`,
117 )
118 .join("");
119
120 const exportRows = data.exports
121 .map((e) => {
122 const done = e.status === "done";
123 const cell = done
124 ? `<a href="/exports/${esc(e.id)}/download">Download ${esc(e.format.toUpperCase())}</a>`
125 : `<span class="muted" data-export-id="${esc(e.id)}">${esc(e.status)}…</span>`;
126 return `<tr>
127 <td>${esc(e.created_at)}</td>
128 <td>${esc(e.framework)}</td>
129 <td>${esc(e.format.toUpperCase())}</td>
130 <td class="export-status">${cell}</td>
131 </tr>`;
132 })
133 .join("");
134
135 return `<!doctype html>
136<html lang="en">
137<head>
138 <meta charset="utf-8">
139 <meta name="viewport" content="width=device-width, initial-scale=1">
140 <title>gh-attest — Compliance Evidence</title>
141 <style>${STYLE}</style>
142</head>
143<body>
144 <header>
145 <h1>gh-attest — Compliance Evidence</h1>
146 <div class="who">${esc(data.login)} ·
147 ${installationSwitcher(data.installations, data.installationId, "dashboard") || esc(data.orgLogin)}
148 <a href="/access-review">Access review</a><a href="/logout">Log out</a></div>
149 </header>
150 <main>
151 <div class="cards">
152 <div class="card"><div class="n negative">${counts.negative}</div><div class="l">Gaps</div></div>
153 <div class="card"><div class="n positive">${counts.positive}</div><div class="l">Satisfied</div></div>
154 <div class="card"><div class="n informational">${counts.informational}</div><div class="l">Informational</div></div>
155 <div class="card"><div class="n">${repos.size}</div><div class="l">Repositories</div></div>
156 </div>
157
158 <div class="bar">
159 <div class="filters">
160 ${frameworkTab("all", "All")}
161 ${frameworkTab("soc2", "SOC 2")}
162 ${frameworkTab("iso27001", "ISO 27001")}
163 </div>
164 <form method="post" action="/resync">
165 <button class="secondary" type="submit">Re-sync now</button>
166 </form>
167 <form method="post" action="/exports">
168 <input type="hidden" name="framework" value="${esc(data.framework)}">
169 <select name="format">
170 <option value="csv">CSV</option>
171 <option value="pdf">PDF</option>
172 </select>
173 <button type="submit">Generate export</button>
174 </form>
175 </div>
176
177 <p class="muted">${
178 data.lastPolledAt ? `Last synced ${esc(data.lastPolledAt)}` : "Not yet synced — click Re-sync now."
179 }</p>
180
181 <table>
182 <thead><tr><th>Framework</th><th>Control</th><th>Posture</th><th>Repo / Subject</th><th>Resource</th><th>Status</th></tr></thead>
183 <tbody>${evidenceRows || `<tr><td colspan="6" class="muted">No evidence yet.</td></tr>`}</tbody>
184 </table>
185
186 <h2 class="section-title">Recent exports</h2>
187 <table>
188 <thead><tr><th>Created</th><th>Framework</th><th>Format</th><th>File</th></tr></thead>
189 <tbody>${exportRows || `<tr><td colspan="4" class="muted">No exports yet.</td></tr>`}</tbody>
190 </table>
191 </main>
192
193 <script>
194 // Poll any pending exports and swap in the download link when ready.
195 for (const el of document.querySelectorAll("[data-export-id]")) {
196 const id = el.getAttribute("data-export-id");
197 const tick = async () => {
198 const r = await fetch("/exports/" + id, { headers: { accept: "application/json" } });
199 if (!r.ok) return;
200 const job = await r.json();
201 if (job.status === "done") {
202 el.closest(".export-status").innerHTML =
203 '<a href="/exports/' + id + '/download">Download ' + String(job.format).toUpperCase() + "</a>";
204 } else if (job.status === "error") {
205 el.textContent = "error";
206 } else {
207 setTimeout(tick, 3000);
208 }
209 };
210 setTimeout(tick, 3000);
211 }
212 </script>
213</body>
214</html>`;
215}
216
217export interface AccessReviewData {
218 login: string;
219 installationId: number;
220 orgLogin: string;
221 installations: InstallationOption[];
222 since: string;
223 diff: import("./access-review").AccessDiff;
224}
225
226const CHANGE_CLASS: Record<string, string> = {
227 added: "negative", // new access is what an access review scrutinises
228 removed: "positive",
229 changed: "informational",
230};
231
232export function renderAccessReview(data: AccessReviewData): string {
233 const { diff } = data;
234
235 const rows = diff.entries
236 .map(
237 (e) => `<tr>
238 <td class="posture ${esc(CHANGE_CLASS[e.change] ?? "informational")}">${esc(e.change)}</td>
239 <td>${esc(e.resource === "org_member" ? "org member" : "team member")}</td>
240 <td>${esc(e.subject)}</td>
241 <td>${esc(e.from ?? "—")}</td>
242 <td>${esc(e.to ?? "—")}</td>
243 </tr>`,
244 )
245 .join("");
246
247 let banner: string;
248 if (!diff.currentAt) {
249 banner = `<p class="muted">No access data collected yet. Access review requires the App to be
250 installed on an <strong>organization</strong> (personal accounts have no membership to review),
251 and at least one sync to have run.</p>`;
252 } else if (!diff.priorAt) {
253 banner = `<p class="muted">Baseline captured ${esc(diff.currentAt)} (${diff.currentCount} access
254 entries). No earlier snapshot before ${esc(data.since)} to compare against yet — the next sync
255 after that date will produce a diff.</p>`;
256 } else {
257 banner = `<p class="muted">Comparing ${esc(diff.priorAt)} → ${esc(diff.currentAt)} ·
258 ${diff.currentCount} current access entries · ${diff.entries.length} change(s).</p>`;
259 }
260
261 return `<!doctype html>
262<html lang="en">
263<head>
264 <meta charset="utf-8">
265 <meta name="viewport" content="width=device-width, initial-scale=1">
266 <title>gh-attest — Access Review</title>
267 <style>${STYLE}</style>
268</head>
269<body>
270 <header>
271 <h1>gh-attest — Access Review</h1>
272 <div class="who">${esc(data.login)} ·
273 ${installationSwitcher(data.installations, data.installationId, "access-review") || esc(data.orgLogin)}
274 <a href="/">Dashboard</a><a href="/logout">Log out</a></div>
275 </header>
276 <main>
277 <div class="bar">
278 <form method="get" action="/access-review">
279 <label for="since">Compare against</label>
280 <input id="since" type="date" name="since" value="${esc(data.since.slice(0, 10))}">
281 <button type="submit">Update</button>
282 </form>
283 </div>
284
285 ${banner}
286
287 <table>
288 <thead><tr><th>Change</th><th>Type</th><th>Subject</th><th>Was</th><th>Now</th></tr></thead>
289 <tbody>${rows || `<tr><td colspan="5" class="muted">No membership changes in this window.</td></tr>`}</tbody>
290 </table>
291 </main>
292</body>
293</html>`;
294}