Public: anyone can read this; the crew can change it.

forge.db and ssh/host_ed25519 leave the tree, and the tree refuses runtime state #671

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/622-forge-db-and-ssh-host-ed25519-leave-the → main 1 commit fork

  • Merged. torque/622-forge-db-and-ssh-host-ed25519-leave-the was merged into main as 596540a.

Review

Sign in to review this change.
10 files changed +356 −9
.gitignore +5
@@ -14,3 +14,8 @@ typesafe-api-key
1414 /*.db
1515 /*.db-wal
1616 /*.db-shm
17 # a forge whose data directory was the checkout leaves these; boot now
18 # refuses that, and the gate refuses a tracked key or database (#622)
19 /forge.db*
20 /ssh/
21 /*.sock
README.md +2 −1
@@ -81,7 +81,8 @@ convenience and exists only there: over SSH, the key decides who you are and
8181 nothing else can.
8282
8383 Configuration is environment variables, all with defaults: `FORGE_DATA`
84 (`data/`), `PORT` (`3000`), `FORGE_SSH_PORT` (`2222`; `0` turns SSH off), and
84 (`data/`; the forge refuses to boot when it is the checkout itself or a path
85 inside it that git does not ignore), `PORT` (`3000`), `FORGE_SSH_PORT` (`2222`; `0` turns SSH off), and
8586 `FORGE_HOST` (`localhost`, the hostname in clone URLs). Connecting origins
8687 adds `FORGE_URL` (the canonical public base URL, needed for OAuth callbacks),
8788 `FORGE_GITHUB_CLIENT_ID` / `FORGE_GITHUB_CLIENT_SECRET`, and
scripts/gate.ts +2
@@ -83,6 +83,8 @@ export const GATE: readonly string[] = [
8383 "test/highlight.test.ts",
8484 "test/check-list.test.ts",
8585 "test/oauth-discovery.test.ts",
86 "test/data-home.test.ts",
87 "test/tree.test.ts",
8688 ];
8789
8890 export const EXCLUDED: readonly Exclusion[] = [
scripts/tree.ts added +92
@@ -0,0 +1,92 @@
1 /**
2 * Runtime state the tree must not carry (#622): a private key or a SQLite
3 * database committed beside the source rides into every clone and preview.
4 *
5 * `auditTree` names each such file, one line per file, outside `FIXTURES`
6 * (the explicit allow-list for test fixtures). `test/tree.test.ts` runs it
7 * over the tracked files, so the gate goes red on one. In a clone the files
8 * are `git ls-files`; in the forge's check directory, written by
9 * `checkout-index` with no `.git`, every file there is a tracked one, so the
10 * directory is walked instead (`node_modules` aside, which the install made).
11 */
12
13 import { readdirSync, readFileSync } from "node:fs";
14 import { join } from "node:path";
15
16 /** Tracked files allowed to look like a key or a database, each with its reason. */
17 export const FIXTURES: readonly { file: string; reason: string }[] = [];
18
19 /** The 16-byte header every SQLite 3 database file starts with. */
20 const SQLITE = new TextEncoder().encode("SQLite format 3\0");
21 // Spelled so this source does not match itself.
22 const PRIVATE_KEY = new RegExp(`-{5}BEGIN [A-Z0-9 ]*PRIVATE KEY-{5}`);
23
24 /** What `bytes` look like, if it is something the tree refuses. */
25 export function stateKind(bytes: Uint8Array): string | null {
26 if (
27 bytes.length >= SQLITE.length &&
28 SQLITE.every((byte, i) => bytes[i] === byte)
29 )
30 return "a SQLite database";
31 // latin1 keeps every byte one character, so a key inside a binary still shows.
32 if (PRIVATE_KEY.test(Buffer.from(bytes).toString("latin1")))
33 return "a private key";
34 return null;
35 }
36
37 /** One line per tracked file that is a key or a database and not a listed fixture. */
38 export function auditTree(
39 files: readonly string[],
40 read: (file: string) => Uint8Array,
41 fixtures: readonly { file: string }[] = FIXTURES,
42 ): string[] {
43 const allowed = new Set(fixtures.map((f) => f.file));
44 const problems: string[] = [];
45 for (const file of files) {
46 if (allowed.has(file)) continue;
47 const kind = stateKind(read(file));
48 if (kind !== null)
49 problems.push(
50 `${file} is ${kind}; runtime state belongs under FORGE_DATA, a fixture belongs in FIXTURES (scripts/tree.ts)`,
51 );
52 }
53 return problems;
54 }
55
56 function walk(root: string, dir = ""): string[] {
57 const out: string[] = [];
58 for (const entry of readdirSync(join(root, dir), { withFileTypes: true })) {
59 const path = dir === "" ? entry.name : `${dir}/${entry.name}`;
60 if (entry.isDirectory()) {
61 if (entry.name === "node_modules" || entry.name === ".git") continue;
62 out.push(...walk(root, path));
63 } else if (entry.isFile()) out.push(path);
64 }
65 return out;
66 }
67
68 /** The repository's tracked files, relative to `root`. */
69 export function trackedFiles(root: string): string[] {
70 const listed = Bun.spawnSync(["git", "ls-files", "-z"], {
71 cwd: root,
72 stdout: "pipe",
73 stderr: "ignore",
74 });
75 if (listed.exitCode === 0)
76 return listed.stdout
77 .toString()
78 .split("\0")
79 .filter((file) => file !== "");
80 return walk(root);
81 }
82
83 /** The tree's problems, read from disk; a tracked file deleted in the work tree is skipped. */
84 export function auditRepository(root: string): string[] {
85 return auditTree(trackedFiles(root), (file) => {
86 try {
87 return readFileSync(join(root, file));
88 } catch {
89 return new Uint8Array();
90 }
91 });
92 }
src/kernel/data_home.ts added +65
@@ -0,0 +1,65 @@
1 /**
2 * Where a forge may keep its state: anywhere but the tree it runs from.
3 *
4 * A forge started with its data directory set to its own checkout leaves
5 * `forge.db`, `ssh/host_ed25519` and a socket beside the source, and the
6 * next commit carries a private key into every clone (#622). So boot asks
7 * git one question: is the data directory the checkout itself, or a path
8 * inside it that `.gitignore` does not cover? Either is refused, with the
9 * reason in one line. A forge running from no checkout (an image, a
10 * `checkout-index` deploy) has nothing to protect and is let through.
11 */
12
13 import { existsSync, realpathSync } from "node:fs";
14 import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
15
16 /** The real path of `path`, or of its nearest existing ancestor with the rest appended. */
17 function real(path: string): string {
18 const missing: string[] = [];
19 let at = resolve(path);
20 while (!existsSync(at)) {
21 const up = dirname(at);
22 if (up === at) break;
23 missing.unshift(at.slice(up.length).replace(/^[/\\]+/, ""));
24 at = up;
25 }
26 return resolve(realpathSync(at), ...missing);
27 }
28
29 function git(args: string[], cwd: string): { code: number; out: string } {
30 const run = Bun.spawnSync(["git", ...args], {
31 cwd,
32 stdout: "pipe",
33 stderr: "ignore",
34 env: { PATH: process.env.PATH ?? "/usr/bin:/bin", LC_ALL: "C" },
35 });
36 return { code: run.exitCode ?? 1, out: run.stdout.toString().trim() };
37 }
38
39 /**
40 * Why `dataDir` may not hold this forge's state, in one line; `null` when
41 * it may. `checkout` is any directory inside the forge's own source tree.
42 */
43 export function dataHomeRefusal(
44 dataDir: string,
45 checkout: string,
46 ): string | null {
47 if (!existsSync(checkout)) return null;
48 const top = git(["rev-parse", "--show-toplevel"], checkout);
49 if (top.code !== 0 || top.out === "") return null;
50 const root = real(top.out);
51 const data = real(dataDir);
52 const rel = relative(root, data);
53 if (rel === "")
54 return `forge: refusing to boot: the data directory ${data} is the repository checkout; set FORGE_DATA to a path outside it (#622)`;
55 if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
56 return null;
57 // The trailing slash lets a directory pattern (`data/`) match a
58 // directory that does not exist yet.
59 const ignored = git(
60 ["check-ignore", "-q", "--", `${rel.split(sep).join("/")}/`],
61 root,
62 );
63 if (ignored.code === 0) return null;
64 return `forge: refusing to boot: the data directory ${data} is inside the checkout ${root} and git does not ignore it; set FORGE_DATA outside it or list it in .gitignore (#622)`;
65 }
src/main.ts +14 −4
@@ -30,6 +30,7 @@ import { graphInstagram } from "./crew/instagram.ts";
3030 import { startNotifier } from "./crew/notify.ts";
3131 import { startPromptRoutine } from "./crew/prompts.ts";
3232 import { pruneTokens } from "./core/tokens.ts";
33 import { dataHomeRefusal } from "./kernel/data_home.ts";
3334
3435 // `forge hook <name>` re-enters here from a git hook stub. It relays to the
3536 // running forge over its socket and exits — it must never boot a server.
@@ -63,11 +64,20 @@ const canonicalUrl = hosted ? hostedAt : (process.env.FORGE_URL ?? "").trim();
6364 // identity headers sign people in; without it they are ignored.
6465 const edgeToken = (process.env.FORGE_EDGE_TOKEN ?? "").trim();
6566
67 const dataDir =
68 process.env.FORGE_DATA ??
69 (hosted ? process.env.FORGE_APP_DATA : undefined) ??
70 "data";
71 // State never lands in the tree the forge runs from (#622): a database or a
72 // host key there is one `git add -A` from every clone.
73 const refusal = dataHomeRefusal(dataDir, import.meta.dir);
74 if (refusal !== null) {
75 console.error(refusal);
76 process.exit(1);
77 }
78
6679 const app = createApp({
67 dataDir:
68 process.env.FORGE_DATA ??
69 (hosted ? process.env.FORGE_APP_DATA : undefined) ??
70 "data",
80 dataDir,
7181 port: Number(process.env.PORT ?? 3000),
7282 ...(process.env.FORGE_HOST === undefined
7383 ? {}
ssh/host_ed25519 deleted −3
@@ -1,3 +0,0 @@
1 -----BEGIN PRIVATE KEY-----
2 MC4CAQAwBQYDK2VwBCIEIJcIFQoeJ/tnUP4YRLfkgVdlhqtyfoINOdy0bmS3qYOf
3 -----END PRIVATE KEY-----
ssh/host_ed25519.pub deleted −1
@@ -1,1 +0,0 @@
1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEHkZRmKOl+ECcGUt2okfxOAeGbtAPbabnnZe4K63uzd forge
test/data-home.test.ts added +102
@@ -0,0 +1,102 @@
1 /**
2 * A forge keeps its state outside the tree it runs from (#622): boot refuses
3 * a data directory that is the checkout, or inside it and not ignored, with
4 * the reason in one line. The fixture is a throwaway git repository with
5 * `data/` in its `.gitignore`, as this one has.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { dataHomeRefusal } from "../src/kernel/data_home.ts";
13
14 let base: string;
15 let repo: string;
16
17 beforeAll(() => {
18 base = mkdtempSync(join(tmpdir(), "forge-data-home-"));
19 repo = join(base, "checkout");
20 mkdirSync(join(repo, "src"), { recursive: true });
21 Bun.spawnSync(["git", "init", "-q", repo]);
22 writeFileSync(join(repo, ".gitignore"), "data/\n*.local\n");
23 });
24
25 afterAll(() => rmSync(base, { recursive: true, force: true }));
26
27 describe("dataHomeRefusal", () => {
28 test("the checkout itself is refused, in one line", () => {
29 const why = dataHomeRefusal(repo, join(repo, "src"));
30 expect(why).toContain("is the repository checkout");
31 expect(why).not.toContain("\n");
32 });
33
34 test("a relative path that resolves to the checkout is refused", () => {
35 const why = dataHomeRefusal(join(repo, "src", ".."), join(repo, "src"));
36 expect(why).toContain("is the repository checkout");
37 });
38
39 test("a directory inside the checkout that git does not ignore is refused", () => {
40 const why = dataHomeRefusal(join(repo, "state"), join(repo, "src"));
41 expect(why).toContain("git does not ignore it");
42 expect(why).not.toContain("\n");
43 expect(dataHomeRefusal(join(repo, "src"), repo)).toContain(
44 "git does not ignore it",
45 );
46 });
47
48 test("an ignored directory inside the checkout is allowed, existing or not", () => {
49 expect(dataHomeRefusal(join(repo, "data"), repo)).toBeNull();
50 expect(dataHomeRefusal(join(repo, "x.local"), repo)).toBeNull();
51 mkdirSync(join(repo, "data"));
52 expect(dataHomeRefusal(join(repo, "data"), repo)).toBeNull();
53 expect(dataHomeRefusal(join(repo, "data", "deeper"), repo)).toBeNull();
54 });
55
56 test("a directory outside the checkout is allowed", () => {
57 expect(dataHomeRefusal(join(base, "elsewhere"), repo)).toBeNull();
58 expect(dataHomeRefusal(base, repo)).toBeNull();
59 });
60
61 test("a forge running from no checkout has nothing to refuse", () => {
62 const loose = join(base, "loose");
63 mkdirSync(loose);
64 expect(dataHomeRefusal(loose, loose)).toBeNull();
65 });
66 });
67
68 const root = new URL("..", import.meta.url).pathname;
69 const inCheckout =
70 Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], { cwd: root })
71 .exitCode === 0;
72
73 describe("boot", () => {
74 // The forge's check runs from a `checkout-index` directory with no `.git`,
75 // where there is no checkout to protect and main.ts would boot for real.
76 test.skipIf(!inCheckout)(
77 "`FORGE_DATA` set to this checkout stops main.ts before anything starts",
78 async () => {
79 const proc = Bun.spawn(["bun", "run", "src/main.ts"], {
80 cwd: root,
81 env: {
82 PATH: process.env.PATH ?? "",
83 HOME: process.env.HOME ?? "",
84 FORGE_DATA: root,
85 PORT: "0",
86 FORGE_SSH_PORT: "0",
87 FORGE_APPS: "0",
88 },
89 stdout: "pipe",
90 stderr: "pipe",
91 });
92 const timer = setTimeout(() => proc.kill(), 20_000);
93 const code = await proc.exited;
94 clearTimeout(timer);
95 const err = (await new Response(proc.stderr).text()).trim();
96 expect(code).toBe(1);
97 expect(err.split("\n")).toHaveLength(1);
98 expect(err).toContain("refusing to boot");
99 },
100 30_000,
101 );
102 });
test/tree.test.ts added +74
@@ -0,0 +1,74 @@
1 /**
2 * The tree carries no runtime state (#622): no tracked file is a private
3 * key or a SQLite database unless `scripts/tree.ts` lists it as a fixture.
4 */
5
6 import { describe, expect, test } from "bun:test";
7 import { auditRepository, auditTree, stateKind } from "../scripts/tree.ts";
8
9 const bytes = (text: string) => new TextEncoder().encode(text);
10 // Spelled in pieces so this file does not trip the rule it tests.
11 const dashes = "-".repeat(5);
12 const KEY = `${dashes}BEGIN OPENSSH PRIVATE KEY${dashes}\nb3BlbnNzaC1rZXktdjEA\n${dashes}END OPENSSH PRIVATE KEY${dashes}\n`;
13 const DB = new Uint8Array([...bytes("SQLite format 3\0"), 16, 0, 1, 1]);
14
15 describe("the repository", () => {
16 test("no tracked file is a private key or a database", () => {
17 expect(auditRepository(new URL("..", import.meta.url).pathname)).toEqual(
18 [],
19 );
20 });
21 });
22
23 describe("stateKind", () => {
24 test("a PEM or OpenSSH private key is named, wherever it sits in the file", () => {
25 expect(stateKind(bytes(KEY))).toBe("a private key");
26 expect(stateKind(bytes(`const k = "${KEY.replace(/\n/g, "\\n")}";`))).toBe(
27 "a private key",
28 );
29 expect(
30 stateKind(bytes(`${dashes}BEGIN EC PRIVATE KEY${dashes}\nMHcC\n`)),
31 ).toBe("a private key");
32 });
33
34 test("a SQLite database is named by its header", () => {
35 expect(stateKind(DB)).toBe("a SQLite database");
36 });
37
38 test("a public key, source that mentions the header, and short files pass", () => {
39 expect(stateKind(bytes("ssh-ed25519 AAAAC3Nza forge\n"))).toBeNull();
40 expect(stateKind(bytes(`${dashes}BEGIN PUBLIC KEY${dashes}\n`))).toBeNull();
41 expect(stateKind(bytes('if (head === "SQLite format 3") ok();'))).toBeNull();
42 expect(stateKind(bytes("SQLite"))).toBeNull();
43 expect(stateKind(new Uint8Array())).toBeNull();
44 });
45 });
46
47 describe("auditTree", () => {
48 const files: Record<string, Uint8Array> = {
49 "ssh/host_ed25519": bytes(KEY),
50 "ssh/host_ed25519.pub": bytes("ssh-ed25519 AAAA forge\n"),
51 "forge.db": DB,
52 "src/a.ts": bytes("export const a = 1;\n"),
53 "test/fixtures/key.pem": bytes(KEY),
54 };
55 const read = (file: string) => files[file] ?? new Uint8Array();
56
57 test("each key and database is named in one line, with where it belongs", () => {
58 const problems = auditTree(Object.keys(files), read, [
59 { file: "test/fixtures/key.pem" },
60 ]);
61 expect(problems).toHaveLength(2);
62 expect(problems[0]).toStartWith("ssh/host_ed25519 is a private key;");
63 expect(problems[1]).toStartWith("forge.db is a SQLite database;");
64 });
65
66 test("a fixture is let through only when listed", () => {
67 expect(auditTree(["test/fixtures/key.pem"], read, [])).toHaveLength(1);
68 expect(
69 auditTree(["test/fixtures/key.pem"], read, [
70 { file: "test/fixtures/key.pem" },
71 ]),
72 ).toEqual([]);
73 });
74 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs