From aeb941b2c09d716c28d456ad6a8602b5ae320e21 Mon Sep 17 00:00:00 2001 From: le king fu Date: Tue, 29 Sep 2026 22:06:39 -0400 Subject: [PATCH] test: pin that the API writes only under HOSTS_DIR (#36) The two Defenseurs mounts are now read-only in the container, so a write added one day under REPORTS_DIR or /data/defenseurs would fail in production as a 500 (EROFS) while every other test stays green: their fixtures are writable temp dirs, and CI runs as root. writes.test.js spies every node:fs write API (callback, Sync and fs.promises forms) before a fresh require, after purging all runtime modules from the require cache, and records the calls whose stack goes through a runtime file. It covers boot (the temp-file sweep), the six GET routes (no write at all, one macrotask after the response included) and POST /hosts/ (writes only under HOSTS_DIR). Each case proves its own spies are live, and the require.main block is pinned since it never runs in-process. Mutation proofs: a write at module load, in the scan collector, through an fs function not imported today, in the ingest rename or in metrics.js, a dead spy, and a new call in the main block each turn the expected case red. With the cache purge narrowed to index.js, the metrics.js write stays green. Co-Authored-By: Claude Opus 5.5 --- __tests__/writes.test.js | 323 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 __tests__/writes.test.js diff --git a/__tests__/writes.test.js b/__tests__/writes.test.js new file mode 100644 index 0000000..cd9fba7 --- /dev/null +++ b/__tests__/writes.test.js @@ -0,0 +1,323 @@ +const http = require("node:http"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { fileURLToPath } = require("node:url"); + +// The API's only write surface is HOSTS_DIR (issue #36). +// +// Since 2026-09-30 the two Defenseurs mounts are read-only in the container +// (`:ro` on the Coolify destination). A write added one day under REPORTS_DIR +// or /data/defenseurs would not fail in the other test files: their fixtures +// live in a writable temp dir, and CI runs as root, so a chmod-555 fixture +// would prove nothing either. It would fail in production, as a 500 (EROFS) +// on the route that tried it. This file moves that failure to CI. +// +// Every node:fs function that can write is spied BEFORE a fresh require, +// because the runtime modules destructure their fs functions at load time +// (same technique as the timingSafeEqual spy in auth.test.js). A call is +// recorded when its stack goes through a runtime file of the repo, then passed +// through to the real function. +// +// Boot is exercised by calling the exported sweepStaleTempFiles(), which is +// what the `require.main === module` block runs before listen. That block +// never runs in-process, so its content is pinned at the end of this file. + +const REPO_ROOT = path.resolve(__dirname, ".."); +const TOKEN = "test-token"; +const INGEST_TOKEN = "test-ingest-token"; +const ORPHAN = ".thinkpad.4242.1700000000000.tmp"; + +// Runtime file = under the repo root, outside node_modules and __tests__. +// Used both to attribute a write to the API and to purge the module cache. +function isRuntimeFile(file) { + const rel = path.relative(REPO_ROOT, file); + if (rel === "" || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return false; + const top = rel.split(path.sep)[0]; + return top !== "node_modules" && top !== "__tests__"; +} + +function fromRuntimeFile(stack) { + return stack + .split("\n") + .slice(1) + .some((frame) => { + const match = /\(?(\/[^()]+?):\d+:\d+\)?$/.exec(frame.trim()); + return match !== null && isRuntimeFile(match[1]); + }); +} + +// Write APIs of node:fs, by the argument(s) naming what gets written. Each is +// spied in its callback, Sync and fs.promises forms, when that form exists. +const O_WRITE = + fs.constants.O_WRONLY | fs.constants.O_RDWR | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_APPEND; + +function opensForWriting(flags) { + if (flags === undefined || flags === null || typeof flags === "function") return false; // "r" + if (typeof flags === "number") return (flags & O_WRITE) !== 0; + return /[wa+]/.test(String(flags)); +} + +// mkdtemp's argument is a prefix: `/x` creates `/xXXXXXX` below +// , but `` alone creates the sibling `XXXXXX`. Checking the +// prefix plus one character tells the two apart. +function mkdtempTarget(prefix) { + if (prefix instanceof URL) return `${fileURLToPath(prefix)}X`; + if (typeof prefix === "string" || Buffer.isBuffer(prefix)) return `${prefix.toString()}X`; + return prefix; +} + +const at = (...indexes) => (args) => indexes.map((i) => args[i]); +const WRITE_APIS = [ + // The destination is the first argument. + ...[ + "writeFile", "appendFile", "unlink", "mkdir", "rm", "rmdir", "truncate", + "chmod", "lchmod", "chown", "lchown", "utimes", "lutimes", "createWriteStream", + ].map((name) => [name, at(0)]), + ["mkdtemp", (args) => [mkdtempTarget(args[0])]], + // Only the destination (second argument) is written. + ...["copyFile", "cp", "link", "symlink"].map((name) => [name, at(1)]), + // Both ends of a rename change. + ["rename", at(0, 1)], + // Metadata changes through a file descriptor: the target cannot be proven, + // so the fd itself is recorded and fails the path rule. + ...["fchmod", "fchown", "futimes"].map((name) => [name, at(0)]), + // An open is a write only when its flags say so. + ["open", (args) => (opensForWriting(args[1]) ? [args[0]] : null)], +]; + +let log = []; + +function drain() { + const out = log; + log = []; + return out; +} + +function installWriteSpies() { + for (const [name, targetsOf] of WRITE_APIS) { + for (const [owner, key] of [ + [fs, name], + [fs, `${name}Sync`], + [fs.promises, name], + ]) { + // lchmod is an own property holding undefined on Linux. + if (typeof owner[key] !== "function") continue; + const original = owner[key]; + vi.spyOn(owner, key).mockImplementation(function (...args) { + const limit = Error.stackTraceLimit; + Error.stackTraceLimit = 50; + const { stack } = new Error(); + Error.stackTraceLimit = limit; + if (fromRuntimeFile(stack)) { + const targets = targetsOf(args); + if (targets) log.push({ fn: key, targets }); + } + return original.apply(this, args); + }); + } + } +} + +// Every runtime module, not only index.js: a module left in the cache would +// keep the fs functions it destructured under a previous test's spies, which +// vi.restoreAllMocks() has turned back into silent pass-throughs. +function purgeRuntimeModules() { + for (const file of Object.keys(require.cache)) { + if (isRuntimeFile(file)) delete require.cache[file]; + } +} + +// Equal to HOSTS_DIR or below it. path.relative rather than startsWith, so a +// sibling such as `/hosts-evil` does not pass. A file descriptor, or any +// argument that is not a path, cannot be proven and fails. +function toPath(arg) { + if (typeof arg === "string") return path.resolve(arg); + if (Buffer.isBuffer(arg)) return path.resolve(arg.toString()); + if (arg instanceof URL) return path.resolve(fileURLToPath(arg)); + return null; +} + +function violations(writes) { + const hostsDir = path.resolve(process.env.HOSTS_DIR); + return writes.flatMap(({ fn, targets }) => + targets + .filter((arg) => { + const target = toPath(arg); + if (target === null) return true; + const rel = path.relative(hostsDir, target); + return rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel); + }) + .map((arg) => `${fn}(${typeof arg === "string" ? arg : typeof arg})`), + ); +} + +function validSnapshot() { + return { + hostname: "thinkpad-x1", + uptime: 4242, + cpu: { model: "Intel Core i7", cores: 8, loadAvg: [0.5, 0.4, 0.3], usagePercent: 12 }, + memory: { totalGB: 32, usedGB: 12.5, freeGB: 19.5, usagePercent: 39 }, + disk: { totalGB: 500, usedGB: 220, freeGB: 280, usagePercent: 44 }, + }; +} + +function writeJson(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(value)); +} + +// One macrotask more, so a write deferred past res.end() is caught too. +function settle() { + return new Promise((resolve) => setTimeout(resolve, 20)); +} + +let tmpDir; +let server; +let baseUrl; +let bootWrites; + +// Created before the spies: nothing here is attributed to the API. +function writeFixtures() { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-writes-test-")); + const defenseurs = path.join(tmpDir, "defenseurs"); + const reports = path.join(defenseurs, "reports"); + const hosts = path.join(tmpDir, "hosts"); + const report = (timestamp) => ({ + agent: "defenseur-proj", + timestamp, + findings: [{ severity: "HIGH", category: "deps", title: "vulnerable dependency" }], + }); + + writeJson(path.join(defenseurs, "status.json"), { + schemaVersion: 1, + timestamp: "2026-09-30T07:30:00.000Z", + agents: [{ name: "defenseur-proj", status: "ok" }], + }); + writeJson(path.join(defenseurs, "findings-state.json"), { + schemaVersion: 1, + updatedAt: "2026-09-30T07:30:00.000Z", + findings: { "proj:deps:1": { firstSeen: "2026-09-29T07:30:00.000Z" } }, + }); + writeJson(path.join(defenseurs, "agents-map.json"), { proj: "defenseur-proj" }); + writeJson(path.join(reports, "defenseur-proj_2026-09-30T07-30-00-000Z.json"), report("2026-09-30T07:30:00.000Z")); + writeJson(path.join(reports, "archive", "defenseur-proj_2026-09-29T07-30-00-000Z.json"), report("2026-09-29T07:30:00.000Z")); + // A stored snapshot, so GET /hosts runs its real read path, not neverSeen. + writeJson(path.join(hosts, "thinkpad.json"), { ...validSnapshot(), receivedAt: new Date().toISOString() }); + // An orphan temp file older than an hour: the boot sweep must remove it, + // which gives every test a write it can see (proof its spies are live). + const orphan = path.join(hosts, ORPHAN); + fs.writeFileSync(orphan, "{}"); + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); + fs.utimesSync(orphan, twoHoursAgo, twoHoursAgo); + + process.env.HEALTH_TOKEN = TOKEN; + process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN; + process.env.REPORTS_DIR = reports; + process.env.DEFENSEURS_STATUS_PATH = path.join(defenseurs, "status.json"); + process.env.DEFENSEURS_FINDINGS_STATE_PATH = path.join(defenseurs, "findings-state.json"); + process.env.DEFENSEURS_AGENTS_MAP_PATH = path.join(defenseurs, "agents-map.json"); + process.env.HOSTS_DIR = hosts; + process.env.HOSTS_ALLOWED_IDS = "thinkpad"; + // Closed port: /health must never reach the real IdP. + process.env.LOGTO_HEALTH_URL = "http://127.0.0.1:1/oidc/.well-known/openid-configuration"; +} + +beforeEach(async () => { + writeFixtures(); + log = []; + installWriteSpies(); + purgeRuntimeModules(); + const { handler, sweepStaleTempFiles } = require("../index.js"); + sweepStaleTempFiles(); + server = http.createServer(handler); + await new Promise((resolve) => server.listen(0, resolve)); + baseUrl = `http://127.0.0.1:${server.address().port}`; + bootWrites = drain(); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("fs writes — the API writes only under HOSTS_DIR (#36)", () => { + test("boot: module load and the temp-file sweep write only under HOSTS_DIR", () => { + expect(bootWrites.map(({ fn }) => fn)).toContain("unlinkSync"); + expect(fs.existsSync(path.join(process.env.HOSTS_DIR, ORPHAN))).toBe(false); + expect(violations(bootWrites)).toEqual([]); + }); + + // Each route with the content that proves its fixture was really read: a + // missing mount answers 200 no_data, which must not pass for a clean read. + const READ_ROUTES = [ + ["/health", (body) => expect(body.cpu).toBeDefined()], + ["/defenseurs", (body) => expect(body.agents).toHaveLength(1)], + ["/defenseurs/state", (body) => expect(Object.keys(body.findings)).toHaveLength(1)], + [ + "/defenseurs/findings?project=proj", + (body) => { + expect(body.agent).toBe("defenseur-proj"); + expect(body.findings).toHaveLength(1); + }, + ], + ["/reports/scans?date=2026-09-30", (body) => expect(body.count).toBe(1)], + [ + "/hosts", + (body) => { + const host = body.hosts.find(({ id }) => id === "thinkpad"); + expect(host.neverSeen).toBeUndefined(); + expect(host.receivedAt).toEqual(expect.any(String)); + }, + ], + ]; + + test.each(READ_ROUTES)("GET %s writes nothing, not even after the response", async (route, expectContent) => { + // This test's own spies are live: they saw the boot sweep. + expect(bootWrites).not.toHaveLength(0); + + const res = await fetch(`${baseUrl}${route}`, { headers: { Authorization: `Bearer ${TOKEN}` } }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).not.toBe("no_data"); + expectContent(body); + + await settle(); + expect(drain()).toEqual([]); + }); + + test("POST /hosts/ writes, and only under HOSTS_DIR", async () => { + const res = await fetch(`${baseUrl}/hosts/thinkpad`, { + method: "POST", + headers: { Authorization: `Bearer ${INGEST_TOKEN}`, "Content-Type": "application/json" }, + body: JSON.stringify(validSnapshot()), + }); + expect(res.status).toBe(204); + + await settle(); + const writes = drain(); + expect(writes.map(({ fn }) => fn)).toEqual(expect.arrayContaining(["writeFileSync", "renameSync"])); + expect(violations(writes)).toEqual([]); + }); +}); + +describe("fs writes — the entrypoint adds no write of its own", () => { + // The block below never runs in-process: the tests above stand in for it by + // calling the exported sweep. This pin keeps that stand-in honest. A new call + // in the block (a PID file, a marker) must be looked at, then allowed here. + // The comparison is an exact set, so a block that cannot be found or comes + // back empty fails instead of passing vacuously. + test("the main block only sweeps, creates the server and listens", () => { + const source = fs.readFileSync(path.join(REPO_ROOT, "index.js"), "utf-8"); + const start = source.indexOf("if (require.main === module) {"); + expect(start).toBeGreaterThan(-1); + const block = source.slice(start, source.indexOf("\n}\n", start) + 2); + const calls = [...block.matchAll(/([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\(/g)] + .map((match) => match[1]) + .filter((name) => name !== "if"); + expect([...new Set(calls)].sort()).toEqual( + ["console.log", "http.createServer", "server.listen", "sweepStaleTempFiles"].sort(), + ); + }); +});