audit-labs/gh-attest

GitHub Audit Evidence Extractor

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

main: src/exporter.ts · raw

  1import { PDFDocument, StandardFonts, rgb, type PDFFont, type PDFPage } from "pdf-lib";
  2
  3export type Framework = "soc2" | "iso27001" | "all";
  4export type ExportFormat = "csv" | "pdf";
  5
  6export interface EvidenceRow {
  7  repo: string | null;
  8  // Org-scoped facts (access review) identify a person or team here rather
  9  // than a repository.
 10  subject: string | null;
 11  resource: string;
 12  status: string;
 13  framework: string;
 14  control_id: string;
 15  posture: string;
 16  rationale: string;
 17  captured_at: string;
 18}
 19
 20// Current posture = the latest snapshot per (repo, resource) for this
 21// installation, joined to the control mapping table. snapshots is
 22// append-only, so "latest row wins" gives point-in-time current state.
 23// Rows whose resource/status pair has no mapping (e.g. "unavailable", or
 24// raw push/installation events) simply don't appear — no evidence either way.
 25export async function buildEvidenceRows(
 26  db: D1Database,
 27  installationId: number,
 28  framework: Framework,
 29): Promise<EvidenceRow[]> {
 30  const { results } = await db
 31    .prepare(
 32      `WITH access_latest AS (
 33         SELECT MAX(captured_at) AS t FROM snapshots
 34         WHERE installation_id = ?1 AND resource IN ('org_member', 'team_member')
 35       ),
 36       latest AS (
 37         SELECT repo, subject, resource, status, captured_at,
 38                ROW_NUMBER() OVER (
 39                  PARTITION BY repo, subject, resource
 40                  ORDER BY captured_at DESC, id DESC
 41                ) AS rn
 42         FROM snapshots
 43         WHERE installation_id = ?1
 44       )
 45       SELECT l.repo, l.subject, l.resource, l.status, cm.framework, cm.control_id,
 46              cm.posture, cm.rationale, l.captured_at
 47       FROM latest l
 48       JOIN control_mappings cm
 49         ON cm.resource = l.resource
 50        AND (cm.status IS NULL OR cm.status = l.status)
 51       WHERE l.rn = 1
 52         AND (?2 = 'all' OR cm.framework = ?2)
 53         -- Access facts are a full set per poll: a member who lost access has
 54         -- no newer row, so "latest row per subject" would keep attesting
 55         -- their access forever. Only the most recent poll batch counts.
 56         AND (
 57           l.resource NOT IN ('org_member', 'team_member')
 58           OR l.captured_at = (SELECT t FROM access_latest)
 59         )
 60         -- Excluded repos stay in snapshots (the exclusion is a reporting
 61         -- decision, reversible) but contribute no evidence.
 62         AND (
 63           l.repo IS NULL
 64           OR l.repo NOT IN (SELECT repo FROM repo_exclusions WHERE installation_id = ?1)
 65         )
 66       -- l.resource last so the change-control collapse below sees
 67       -- branch_protection before repository_ruleset deterministically.
 68       ORDER BY cm.framework, cm.control_id, l.repo, l.resource`,
 69    )
 70    .bind(installationId, framework)
 71    .all<EvidenceRow>();
 72
 73  return collapseChangeControl(results);
 74}
 75
 76// Classic branch protection and repository rulesets are two implementations of
 77// the same control (CC8.1 / A.8.32), and a repo can have either, both, or
 78// neither. Reported separately, a repo whose default branch is covered by an
 79// active ruleset still emitted a `branch_protection` / `disabled` row and
 80// counted as a change-control gap that isn't one. Collapse the pair to one row
 81// per (framework, control, repo), keeping the mechanism actually in force:
 82// enabled beats disabled, and classic protection wins an otherwise-equal tie
 83// because its rationale describes the repo's state without naming a mechanism
 84// the reader may not use.
 85const CHANGE_CONTROL_RESOURCES = new Set(["branch_protection", "repository_ruleset"]);
 86
 87function collapseChangeControl(rows: EvidenceRow[]): EvidenceRow[] {
 88  const groupKey = (row: EvidenceRow) => `${row.framework}|${row.control_id}|${row.repo}`;
 89  const winners = new Map<string, EvidenceRow>();
 90
 91  for (const row of rows) {
 92    if (!CHANGE_CONTROL_RESOURCES.has(row.resource)) continue;
 93    const incumbent = winners.get(groupKey(row));
 94    if (!incumbent || (row.posture === "positive" && incumbent.posture !== "positive")) {
 95      winners.set(groupKey(row), row);
 96    }
 97  }
 98
 99  return rows.filter((row) => !CHANGE_CONTROL_RESOURCES.has(row.resource) || winners.get(groupKey(row)) === row);
100}
101
102const CSV_COLUMNS: Array<keyof EvidenceRow> = [
103  "framework",
104  "control_id",
105  "posture",
106  "repo",
107  "subject",
108  "resource",
109  "status",
110  "rationale",
111  "captured_at",
112];
113
114export function renderCsv(rows: EvidenceRow[]): string {
115  const lines = [CSV_COLUMNS.map(csvEscape).join(",")];
116  for (const row of rows) {
117    lines.push(CSV_COLUMNS.map((col) => csvEscape(row[col] ?? "")).join(","));
118  }
119  // CRLF line endings — RFC 4180, and what spreadsheet apps expect.
120  return lines.join("\r\n") + "\r\n";
121}
122
123function csvEscape(value: string | number): string {
124  const str = String(value);
125  if (/[",\r\n]/.test(str)) {
126    return `"${str.replaceAll('"', '""')}"`;
127  }
128  return str;
129}
130
131export interface ExportMeta {
132  framework: Framework;
133  installationId: number;
134  generatedAt: string;
135}
136
137interface Column {
138  header: string;
139  key: keyof EvidenceRow;
140  width: number;
141}
142
143const PDF_COLUMNS: Column[] = [
144  { header: "Control", key: "control_id", width: 60 },
145  { header: "Posture", key: "posture", width: 75 },
146  { header: "Repo / Subject", key: "repo", width: 175 },
147  { header: "Resource", key: "resource", width: 110 },
148  { header: "Status", key: "status", width: 82 },
149];
150
151const PAGE = { width: 612, height: 792, margin: 50 };
152const ROW_HEIGHT = 15;
153const FONT_SIZE = 8;
154
155const POSTURE_COLOR: Record<string, ReturnType<typeof rgb>> = {
156  positive: rgb(0.1, 0.5, 0.2),
157  negative: rgb(0.7, 0.15, 0.15),
158  informational: rgb(0.4, 0.4, 0.4),
159};
160
161export async function renderPdf(rows: EvidenceRow[], meta: ExportMeta): Promise<Uint8Array> {
162  const doc = await PDFDocument.create();
163  const font = await doc.embedFont(StandardFonts.Helvetica);
164  const bold = await doc.embedFont(StandardFonts.HelveticaBold);
165
166  const frameworkLabel = meta.framework === "all" ? "SOC 2 + ISO 27001" : meta.framework.toUpperCase();
167  let page = doc.addPage([PAGE.width, PAGE.height]);
168  let y = PAGE.height - PAGE.margin;
169
170  // Title block.
171  page.drawText("Compliance Evidence", { x: PAGE.margin, y, size: 18, font: bold });
172  y -= 22;
173  page.drawText(
174    `${frameworkLabel}  ·  Installation ${meta.installationId}  ·  Generated ${meta.generatedAt}  ·  ${rows.length} findings`,
175    { x: PAGE.margin, y, size: 9, font, color: rgb(0.35, 0.35, 0.35) },
176  );
177  y -= 24;
178
179  y = drawHeader(page, bold, y);
180
181  for (const row of rows) {
182    if (y < PAGE.margin + ROW_HEIGHT) {
183      page = doc.addPage([PAGE.width, PAGE.height]);
184      y = PAGE.height - PAGE.margin;
185      y = drawHeader(page, bold, y);
186    }
187    let x = PAGE.margin;
188    for (const col of PDF_COLUMNS) {
189      // The repo column doubles as the scope column: org-level access facts
190      // carry a subject (member/team) instead of a repository.
191      const raw = col.key === "repo" ? String(row.repo ?? row.subject ?? "") : String(row[col.key] ?? "");
192      const text = truncate(raw, font, FONT_SIZE, col.width - 4);
193      const color = col.key === "posture" ? POSTURE_COLOR[row.posture] ?? rgb(0, 0, 0) : rgb(0.1, 0.1, 0.1);
194      page.drawText(text, { x, y, size: FONT_SIZE, font, color });
195      x += col.width;
196    }
197    y -= ROW_HEIGHT;
198  }
199
200  return doc.save();
201}
202
203function drawHeader(page: PDFPage, bold: PDFFont, y: number): number {
204  let x = PAGE.margin;
205  for (const col of PDF_COLUMNS) {
206    page.drawText(col.header, { x, y, size: FONT_SIZE, font: bold });
207    x += col.width;
208  }
209  const lineY = y - 4;
210  page.drawLine({
211    start: { x: PAGE.margin, y: lineY },
212    end: { x: PAGE.width - PAGE.margin, y: lineY },
213    thickness: 0.5,
214    color: rgb(0.6, 0.6, 0.6),
215  });
216  return y - ROW_HEIGHT;
217}
218
219function truncate(text: string, font: PDFFont, size: number, maxWidth: number): string {
220  if (font.widthOfTextAtSize(text, size) <= maxWidth) return text;
221  let truncated = text;
222  while (truncated.length > 1 && font.widthOfTextAtSize(truncated + "…", size) > maxWidth) {
223    truncated = truncated.slice(0, -1);
224  }
225  return truncated + "…";
226}