audit-labs/gh-attest

GitHub Audit Evidence Extractor

clone: git clone https://gitbay.org/audit-labs/gh-attest.git

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