diff --git a/CHANGELOG.md b/CHANGELOG.md index bfb8f55..2565cd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ project adheres to [Semantic Versioning](https://semver.org/). - Playwright setup note in the Quick start section of `README.md`. ### Fixed +- Comparison and case summaries now refuse a `runs` directory that is a + symlink, matching list, export, and prune. The comparison path used to + follow the link and read the target tree. The GUI compare route returns + HTTP 422 for that refusal. - Verify saved case now returns HTTP 422 when the saved case is structurally invalid, the same distinction inspect already makes with 404 and 422. The route used to report that failure as HTTP 500. diff --git a/bin/aas-gui.mjs b/bin/aas-gui.mjs index 1ceae2a..efd2b43 100644 --- a/bin/aas-gui.mjs +++ b/bin/aas-gui.mjs @@ -801,7 +801,16 @@ export function createGuiServer({ sendJson(response, 400, { error: "Compare requires two valid run ids." }); return; } - const comparison = compareRuns(left, right, { outputRoot }); + let comparison; + try { + comparison = compareRuns(left, right, { outputRoot }); + } catch (error) { + if (/regular directory/.test(error?.message ?? "")) { + sendJson(response, 422, { error: "Runs directory must be a regular directory." }); + return; + } + throw error; + } if (url.searchParams.get("format") === "markdown") { response.writeHead(200, { "content-type": "text/markdown; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff", "content-disposition": 'attachment; filename="case-comparison.md"' }); response.end(renderComparisonMarkdown(comparison)); return; diff --git a/bin/aas.mjs b/bin/aas.mjs index 38af7ff..f8052e8 100644 --- a/bin/aas.mjs +++ b/bin/aas.mjs @@ -1265,6 +1265,9 @@ function readStageArtifact(bundleDir, manifest, name) { * component revisions only. */ export function summarizeRun(runId, { outputRoot = DEFAULT_PATHS.outputRoot } = {}) { + // Same store rule as list, export, and prune. Otherwise a symlinked runs + // directory is followed and comparison reads the link target. + assertRunsDirectory(outputRoot); const { bundleDir, manifest } = readRunManifest(runsDirectory(outputRoot), runId); validateSavedManifest(manifest, bundleDir); const report = readRunReport(bundleDir, manifest); @@ -1344,6 +1347,9 @@ const COMPARED_FIELDS = [ * identical evidence. */ export function compareRuns(leftId, rightId, { outputRoot = DEFAULT_PATHS.outputRoot } = {}) { + // summarizeRun reports a bad id as not-comparable. A symlinked store is a + // precondition failure, same as list and export, and must not be swallowed. + assertRunsDirectory(outputRoot); let left = null; let right = null; const errors = []; diff --git a/test/gui.test.mjs b/test/gui.test.mjs index 177c6bc..e3585db 100644 --- a/test/gui.test.mjs +++ b/test/gui.test.mjs @@ -7,7 +7,7 @@ import { execFileSync } from "node:child_process"; import { setTimeout as delay } from "node:timers/promises"; import { createHash } from "node:crypto"; import { request } from "node:http"; -import { existsSync, readFileSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -639,6 +639,42 @@ test("GUI history and compare endpoints serve summaries and classifications", as } }); +test("GUI compare refuses a symlinked runs directory", (t) => { + const outputRoot = mkdtempSync(join(tmpdir(), "aas-gui-runs-link-")); + const outside = mkdtempSync(join(tmpdir(), "aas-gui-runs-outside-")); + const runId = "2026-09-06T050000000Z-outside"; + writeCase(outside, runId, { + report: { + run_id: runId, + flow: "decide -> act", + domain: "inventory", + policy_id: "outside-only-policy", + component_provenance: [], + stages: { decide: { status: "passed", policy_id: "outside-only-policy" }, act: { status: "skipped" }, prove: { status: "skipped" } }, + }, + }); + try { + symlinkSync(join(outside, "runs"), join(outputRoot, "runs")); + } catch (error) { + if (["EPERM", "EACCES", "ENOSYS"].includes(error.code)) { + t.skip("symlinks are unavailable on this platform"); + return; + } + throw error; + } + const server = createGuiServer({ outputRoot }); + return new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)).then(async () => { + try { + const compared = await requestServer(server, `/api/compare?a=${runId}&b=${runId}`); + assert.equal(compared.status, 422); + assert.match(compared.body, /regular directory/); + assert.doesNotMatch(compared.body, /outside-only-policy/); + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } + }); +}); + test("GUI exposes a domain selector defaulting to refund", () => { const page = renderPage(); assert.match(page, /