audit-labs/gh-attest

GitHub Audit Evidence Extractor

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

main: scripts/check-mappings.mjs · raw

 1// Sync check: assert that the control-framework mappings in migrations/ exactly
 2// match the reference table in docs/framework-mapping.md. The migrations are the
 3// source of truth the engine reads; the doc is the human-readable copy an auditor
 4// relies on. If they drift, the doc is lying — so this fails CI.
 5//
 6// It works by actually applying every migration to an in-memory SQLite database
 7// (so a migration's delete-and-reinsert is handled exactly as production D1
 8// would), reading back control_mappings, and diffing against the rows parsed out
 9// of the doc's "Complete mapping reference" table — including the rationale
10// text, which is what an auditor reads in every export.
11//
12// Run: npm run test:mappings   (no dependencies — uses Node's built-in sqlite)
13
14import { DatabaseSync } from "node:sqlite";
15import { readFileSync, readdirSync } from "node:fs";
16import { fileURLToPath } from "node:url";
17import { dirname, join } from "node:path";
18
19const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
20const migrationsDir = join(repoRoot, "migrations");
21const docPath = join(repoRoot, "docs", "framework-mapping.md");
22
23// A "·" in the doc's Status column means the mapping's status is NULL (matches
24// any status); normalize both sides to this sentinel so they compare equal.
25const NULL_STATUS = "·";
26// The rationale is part of the key: it is the auditor-facing string in every
27// export, so the doc's copy drifting from the SQL is as much a lie as a
28// wrong posture.
29const key = (resource, status, framework, control, posture, rationale) =>
30  `${resource}|${status ?? NULL_STATUS}|${framework}|${control}|${posture}|${rationale}`;
31
32// --- 1. Source of truth: apply migrations, read control_mappings. ---
33function rowsFromMigrations() {
34  const db = new DatabaseSync(":memory:");
35  const files = readdirSync(migrationsDir)
36    .filter((f) => f.endsWith(".sql"))
37    .sort();
38  for (const file of files) {
39    db.exec(readFileSync(join(migrationsDir, file), "utf8"));
40  }
41  const rows = db
42    .prepare("SELECT resource, status, framework, control_id, posture, rationale FROM control_mappings")
43    .all();
44  db.close();
45  return new Set(rows.map((r) => key(r.resource, r.status, r.framework, r.control_id, r.posture, r.rationale)));
46}
47
48// --- 2. Human-readable copy: parse the doc's reference table. ---
49// Rows are parsed by splitting on "|" rather than a single big regex — that is
50// unambiguous and linear (no backtracking). A row counts only if it has the
51// reference table's shape: a backtick-wrapped resource, a known framework
52// label, and a bare posture word. That shape excludes the header/separator
53// rows, the per-section tables (framework-first, no backticked resource), and
54// the glossary (fewer columns) — so only reference-table data rows match.
55const FRAMEWORK_LABELS = { "SOC 2": "soc2", "ISO 27001": "iso27001" };
56const POSTURES = new Set(["positive", "negative", "informational"]);
57const BACKTICKED = /^`.+`$/;
58
59function rowsFromDoc() {
60  const set = new Set();
61  for (const line of readFileSync(docPath, "utf8").split("\n")) {
62    if (!line.startsWith("|")) continue;
63    // Leading "|" yields an empty cells[0]; data lives in cells[1..6].
64    const cells = line.split("|").map((c) => c.trim());
65    const [, resource, statusCell, frameworkLabel, control, posture, rationale] = cells;
66    const framework = FRAMEWORK_LABELS[frameworkLabel];
67    if (!framework || !POSTURES.has(posture) || !BACKTICKED.test(resource ?? "")) continue;
68    const status = statusCell.replaceAll("`", ""); // "·" for NULL
69    set.add(key(resource.replaceAll("`", ""), status, framework, control, posture, rationale ?? ""));
70  }
71  return set;
72}
73
74// --- 3. Diff. ---
75const db = rowsFromMigrations();
76const doc = rowsFromDoc();
77
78const onlyInDb = [...db].filter((k) => !doc.has(k)).sort((a, b) => a.localeCompare(b));
79const onlyInDoc = [...doc].filter((k) => !db.has(k)).sort((a, b) => a.localeCompare(b));
80
81if (onlyInDb.length === 0 && onlyInDoc.length === 0) {
82  console.log(`✓ mappings in sync: ${db.size} rows match between migrations/ and docs/framework-mapping.md`);
83  process.exit(0);
84}
85
86console.error("✗ control-mapping drift between migrations/ and docs/framework-mapping.md\n");
87console.error("  columns: resource | status | framework | control | posture | rationale\n");
88if (onlyInDb.length) {
89  console.error(`  In migrations but MISSING from the doc (${onlyInDb.length}):`);
90  for (const k of onlyInDb) console.error(`    + ${k}`);
91}
92if (onlyInDoc.length) {
93  console.error(`  In the doc but MISSING from migrations (${onlyInDoc.length}):`);
94  for (const k of onlyInDoc) console.error(`    - ${k}`);
95}
96console.error("\n  Fix: update whichever is wrong so migrations/ and the doc's reference table agree.");
97process.exit(1);