audit-labs/gh-attest
GitHub Audit Evidence Extractor
clone: git clone https://gitbay.org/audit-labs/gh-attest.git
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}