diff --git a/README.md b/README.md index cefdc60d..a232db81 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,10 @@ pnpm install # Verify dependencies and generated state before running source-quality gates. # Missing dependencies exit 42; stale/foreign apps/web/.next state exits 43. +# The web build certifies its exact standalone symlink manifest; added, removed, +# retargeted, or manifest-only-tampered generated links also exit 43. This detects +# accidental/independent drift, not a same-UID actor that can rewrite both records +# consistently (CWE-345); an external trust anchor is required for that boundary. pnpm preflight # Optional local queue service only. This does not start PostgreSQL. diff --git a/docs/scratchpads/rm-01-reproducible-checkout.md b/docs/scratchpads/rm-01-reproducible-checkout.md index ead67b94..ef250828 100644 --- a/docs/scratchpads/rm-01-reproducible-checkout.md +++ b/docs/scratchpads/rm-01-reproducible-checkout.md @@ -40,6 +40,14 @@ - AC4 DONE at `0f706119`: in that same fresh clone and CI image, `pnpm -w build` completed 25/25 tasks and the immediately following `git status --porcelain` was empty; combined assertion exit 0. - Push BLOCKED after the required queue guard: `git push origin fix/rm-01-reproducible-checkout` was rejected by Gitea with `User permission denied for writing` / `pre-receive hook declined`, despite `MOSAIC_GIT_IDENTITY=f10-coder` resolving username `f10-coder` from the provisioned `gitea-mosaicstack-f10-coder.token`. +## Review remediation — restated AC2 + +- Independent review correctly found that an added symlink under a successfully built `.next` tree passed preflight. The exact reviewer control, `ln -s /etc/hosts apps/web/.next/reviewer-symlink && pnpm preflight`, was observed passing before remediation. +- The original blanket symlink wording conflicts with AC4 because canonical Next `output: 'standalone'` emits legitimate pnpm dependency symlinks. The coordinator independently verified 42 such links and approved the operative restatement: `.next` itself must not be a symlink; descendant symlinks must exactly match the successful build's certified manifest. +- RED-first controls were observed failing together against the prior implementation (exit 1): `.next` root, added, removed, retargeted, tampered-manifest, and canonical-style certified-link cases. The build now publishes the manifest atomically before the existing source certification commit marker; that marker binds the manifest SHA-256. Missing/partial/modified manifests remain untrusted. +- GREEN evidence: the six-case symlink control passes; the exact reviewer-added link exits 43; removing it restores preflight exit 0. The added RED-first build-publication control also proves a symlinked `.next` cannot redirect certification writes outside the checkout. `pnpm test:checkout` passes 23 top-level tests / 29 including subtests. Canonical `pnpm --filter @mosaicstack/web build` and the following `pnpm preflight` both exit 0. +- Threat-model ruling: the manifest detects accidental, independent, and stale mutation only. It does not defend against a same-UID actor able to rewrite both manifest and marker consistently (CWE-345); no local worktree construction can without an external trust anchor. The residual belongs to the planned choke-point executor / PostgreSQL spine where verification can occur outside worktree authority. + ## Handoff 1. Keep the newly committed RED tests red until implementing: (a) source-fingerprint marker support for valid incremental `.next` output, and (b) ownership-safe concurrent hook activation. diff --git a/scripts/build-web.mjs b/scripts/build-web.mjs index 0d9f51e3..dde9d60c 100644 --- a/scripts/build-web.mjs +++ b/scripts/build-web.mjs @@ -1,12 +1,12 @@ #!/usr/bin/env node import { spawn } from 'node:child_process'; -import { randomUUID } from 'node:crypto'; -import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { createHash, randomUUID } from 'node:crypto'; +import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; -import { sourceFingerprint } from './preflight.mjs'; +import { generatedSymlinkManifest, sourceFingerprint } from './preflight.mjs'; const scriptRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -26,6 +26,18 @@ function run(command, args, options) { const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); +async function requireRealDirectory(target, { allowMissing = false } = {}) { + try { + const stats = await lstat(target); + if (!stats.isDirectory() || stats.isSymbolicLink()) { + throw new Error(`${target} must be a real directory, not a symbolic link.`); + } + } catch (error) { + if (allowMissing && error.code === 'ENOENT') return; + throw error; + } +} + async function acquireBuildLock(root) { const workRoot = path.join(root, '.mosaic-test-work'); const lock = path.join(workRoot, 'web-build.lock'); @@ -89,12 +101,19 @@ export async function buildWeb({ try { const webDir = path.join(root, 'apps', 'web'); const nextDir = path.join(webDir, '.next'); - const marker = path.join(nextDir, '.mosaic-source-hash'); - const temporary = `${marker}.${randomUUID()}.tmp`; + const certificationMarker = path.join(nextDir, '.mosaic-source-hash'); + const symlinkManifest = path.join(nextDir, '.mosaic-symlink-manifest'); + const certificationTemporary = `${certificationMarker}.${randomUUID()}.tmp`; + const manifestTemporary = `${symlinkManifest}.${randomUUID()}.tmp`; const before = await fingerprint(root); - await rm(marker, { force: true }); + await requireRealDirectory(nextDir, { allowMissing: true }); + await Promise.all([ + rm(certificationMarker, { force: true }), + rm(symlinkManifest, { force: true }), + ]); await runBuild(webDir); + await requireRealDirectory(nextDir); const after = await fingerprint(root); if (after !== before) { @@ -103,9 +122,20 @@ export async function buildWeb({ ); } - await mkdir(nextDir, { recursive: true }); - await writeFile(temporary, `${before}\n`, { mode: 0o600 }); - await rename(temporary, marker); + const manifestContents = await generatedSymlinkManifest(nextDir); + const certificationContents = `${JSON.stringify({ + version: 1, + sourceFingerprint: before, + symlinkManifestHash: createHash('sha256').update(manifestContents).digest('hex'), + })}\n`; + await Promise.all([ + writeFile(certificationTemporary, certificationContents, { mode: 0o600 }), + writeFile(manifestTemporary, manifestContents, { mode: 0o600 }), + ]); + // The certification marker is the commit point. Publishing the manifest first + // leaves interrupted builds untrusted because the marker remains absent. + await rename(manifestTemporary, symlinkManifest); + await rename(certificationTemporary, certificationMarker); } finally { await releaseLock(); } diff --git a/scripts/build-web.test.mjs b/scripts/build-web.test.mjs index c897638d..2a53a107 100644 --- a/scripts/build-web.test.mjs +++ b/scripts/build-web.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { access, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import path from 'node:path'; import test from 'node:test'; @@ -26,19 +26,27 @@ test.after(async () => { await rm(fixtureRoot, { recursive: true, force: true }); }); -test('a successful web build atomically publishes its source fingerprint', async () => { +test('a successful web build atomically publishes its source and symlink certification', async () => { const root = await fixture('success'); const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'); + const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest'); await buildWeb({ root, fingerprint: async () => 'certified', runBuild: async () => {} }); - assert.equal(await readFile(marker, 'utf8'), 'certified\n'); + assert.deepEqual(JSON.parse(await readFile(marker, 'utf8')), { + version: 1, + sourceFingerprint: 'certified', + symlinkManifestHash: '8a5a375cea6a55d24bd5f875856da63feba33adbefb15a92a0007719b84bcf11', + }); + assert.equal(await readFile(manifest, 'utf8'), '{"version":1,"links":[]}\n'); }); test('a failed web build leaves no certification marker', async () => { const root = await fixture('failure'); const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'); + const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest'); await writeFile(marker, 'stale\n'); + await writeFile(manifest, 'stale\n'); await assert.rejects( buildWeb({ @@ -52,12 +60,15 @@ test('a failed web build leaves no certification marker', async () => { ); assert.equal(await exists(marker), false); + assert.equal(await exists(manifest), false); }); test('overlapping web builds are serialized while the marker remains absent', async () => { const root = await fixture('overlap'); const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'); + const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest'); await writeFile(marker, 'stale\n'); + await writeFile(manifest, 'stale\n'); let releaseFirst; let secondEntered = false; const firstEntered = new Promise((resolve) => { @@ -87,16 +98,41 @@ test('overlapping web builds are serialized while the marker remains absent', as await new Promise((resolve) => setTimeout(resolve, 75)); assert.equal(secondEntered, false); assert.equal(await exists(marker), false); + assert.equal(await exists(manifest), false); releaseFirst(); await Promise.all([first, second]); assert.equal(secondEntered, true); - assert.equal(await readFile(marker, 'utf8'), 'certified\n'); + assert.equal(JSON.parse(await readFile(marker, 'utf8')).sourceFingerprint, 'certified'); + assert.equal(await readFile(manifest, 'utf8'), '{"version":1,"links":[]}\n'); +}); + +test('a build that replaces .next with a symbolic link cannot publish outside the checkout', async () => { + const root = await fixture('symbolic-next'); + const nextDir = path.join(root, 'apps', 'web', '.next'); + const outside = path.join(root, 'outside-generated'); + await mkdir(outside); + + await assert.rejects( + buildWeb({ + root, + fingerprint: async () => 'certified', + runBuild: async () => { + await rm(nextDir, { recursive: true }); + await symlink(outside, nextDir); + }, + }), + /must be a real directory/, + ); + + assert.equal(await exists(path.join(outside, '.mosaic-source-hash')), false); + assert.equal(await exists(path.join(outside, '.mosaic-symlink-manifest')), false); }); test('inputs changed during a web build are not certified', async () => { const root = await fixture('changed-inputs'); const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'); + const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest'); const fingerprints = ['before', 'after']; await assert.rejects( @@ -109,4 +145,5 @@ test('inputs changed during a web build are not certified', async () => { ); assert.equal(await exists(marker), false); + assert.equal(await exists(manifest), false); }); diff --git a/scripts/preflight.mjs b/scripts/preflight.mjs index e5d12268..f3e21ae8 100644 --- a/scripts/preflight.mjs +++ b/scripts/preflight.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { constants } from 'node:fs'; -import { access, lstat, readFile, readdir } from 'node:fs/promises'; +import { access, lstat, readFile, readdir, readlink } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; @@ -32,6 +32,19 @@ async function entries(root) { return result; } +export async function generatedSymlinkManifest(nextDir) { + const links = []; + for (const target of (await entries(nextDir)).sort()) { + const stats = await lstat(target); + if (!stats.isSymbolicLink()) continue; + links.push({ + path: path.relative(nextDir, target).split(path.sep).join('/'), + target: await readlink(target), + }); + } + return `${JSON.stringify({ version: 1, links })}\n`; +} + const webSourceRoots = (root) => [ path.join(root, 'apps', 'web', 'src'), path.join(root, 'apps', 'web', 'public'), @@ -150,7 +163,14 @@ export async function runPreflight({ root = process.cwd(), uid = process.getuid? const nextDir = path.join(root, 'apps', 'web', '.next'); let generated = []; try { - await lstat(nextDir); + const nextStats = await lstat(nextDir); + if (nextStats.isSymbolicLink()) { + return { + code: GENERATED_STATE_EXIT, + message: + 'MOSAIC_PREFLIGHT_GENERATED_STATE: apps/web/.next contains a symbolic link and is not trustworthy; run pnpm clean:generated, then rerun the gate', + }; + } generated = [nextDir, ...(await entries(nextDir))]; } catch (error) { if (error.code !== 'ENOENT') throw error; @@ -158,25 +178,55 @@ export async function runPreflight({ root = process.cwd(), uid = process.getuid? if (generated.length > 0) { const foreign = []; - if (uid !== undefined) { - for (const target of generated) { - if ((await lstat(target)).uid !== uid) foreign.push(path.relative(root, target)); - } + for (const target of generated) { + const stats = await lstat(target); + if (uid !== undefined && stats.uid !== uid) foreign.push(path.relative(root, target)); } - let generatedFingerprint = null; + // Threat model: this detects accidental, independent, or stale generated-state + // mutation. It does NOT defend against an actor with same-UID write access to + // the generated tree: that actor can regenerate both the manifest and marker + // consistently (CWE-345). No local construction can without a trust anchor + // outside that actor's authority. + let certification = null; + let certifiedManifest = null; try { - generatedFingerprint = ( - await readFile(path.join(nextDir, '.mosaic-source-hash'), 'utf8') - ).trim(); + const [certificationContents, manifestContents] = await Promise.all([ + readFile(path.join(nextDir, '.mosaic-source-hash'), 'utf8'), + readFile(path.join(nextDir, '.mosaic-symlink-manifest'), 'utf8'), + ]); + try { + const parsed = JSON.parse(certificationContents); + if ( + parsed.version === 1 && + typeof parsed.sourceFingerprint === 'string' && + typeof parsed.symlinkManifestHash === 'string' + ) { + certification = parsed; + certifiedManifest = manifestContents; + } + } catch { + // Invalid certification is handled as untrusted generated state below. + } } catch (error) { if (error.code !== 'ENOENT') throw error; } - const stale = generatedFingerprint !== (await sourceFingerprint(root)); - if (foreign.length > 0 || stale) { + const stale = certification?.sourceFingerprint !== (await sourceFingerprint(root)); + const actualManifest = await generatedSymlinkManifest(nextDir); + const certifiedManifestHash = + certifiedManifest === null + ? null + : createHash('sha256').update(certifiedManifest).digest('hex'); + const changedSymlinks = + certification?.symlinkManifestHash !== certifiedManifestHash || + certifiedManifest !== actualManifest; + if (foreign.length > 0 || stale || changedSymlinks) { const reasons = [ foreign.length > 0 ? `foreign-owned paths: ${foreign.slice(0, 3).join(', ')}` : '', stale ? 'generated source fingerprint does not match web source/configuration' : '', + changedSymlinks + ? 'generated symbolic-link manifest does not match the certified build' + : '', ].filter(Boolean); return { code: GENERATED_STATE_EXIT, diff --git a/scripts/preflight.test.mjs b/scripts/preflight.test.mjs index f1018a3e..a035df4c 100644 --- a/scripts/preflight.test.mjs +++ b/scripts/preflight.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; import { chmod, mkdir, rm, symlink, utimes, writeFile } from 'node:fs/promises'; import path from 'node:path'; import test from 'node:test'; @@ -28,6 +29,22 @@ async function installRequiredBins(root) { ); } +async function certifyGeneratedState(root, links = []) { + const nextDir = path.join(root, 'apps', 'web', '.next'); + await mkdir(nextDir, { recursive: true }); + const manifest = `${JSON.stringify({ version: 1, links })}\n`; + const manifestHash = createHash('sha256').update(manifest).digest('hex'); + await writeFile(path.join(nextDir, '.mosaic-symlink-manifest'), manifest); + await writeFile( + path.join(nextDir, '.mosaic-source-hash'), + `${JSON.stringify({ + version: 1, + sourceFingerprint: await sourceFingerprint(root), + symlinkManifestHash: manifestHash, + })}\n`, + ); +} + test.after(async () => { await rm(fixtureRoot, { recursive: true, force: true }); }); @@ -98,6 +115,103 @@ test('a generated marker mismatch is identified separately from source errors', assert.match(result.message, /pnpm clean:generated/); }); +test('generated-state symbolic links are accepted only when exactly build-certified', async (t) => { + await t.test('apps/web/.next itself is rejected when it is a symbolic link', async () => { + const root = await fixture('symbolic-next-root'); + await installRequiredBins(root); + await writeFile(path.join(root, 'outside-generated'), 'not a Next build\n'); + await symlink(path.join(root, 'outside-generated'), path.join(root, 'apps', 'web', '.next')); + + const result = await runPreflight({ root }); + assert.equal(result.code, 43); + assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/); + assert.match(result.message, /symbolic link/); + }); + + await t.test('an added descendant symlink is rejected', async () => { + const root = await fixture('symbolic-next-added'); + await installRequiredBins(root); + await certifyGeneratedState(root); + await symlink('/etc/hosts', path.join(root, 'apps', 'web', '.next', 'reviewer-symlink')); + + const result = await runPreflight({ root }); + assert.equal(result.code, 43); + assert.match(result.message, /symbolic-link manifest/); + }); + + await t.test('a removed certified descendant symlink is rejected', async () => { + const root = await fixture('symbolic-next-removed'); + await installRequiredBins(root); + const link = path.join(root, 'apps', 'web', '.next', 'dependency-link'); + await mkdir(path.dirname(link), { recursive: true }); + await symlink('../dependency-one', link); + await certifyGeneratedState(root, [{ path: 'dependency-link', target: '../dependency-one' }]); + await rm(link); + + const result = await runPreflight({ root }); + assert.equal(result.code, 43); + assert.match(result.message, /symbolic-link manifest/); + }); + + await t.test('a retargeted certified descendant symlink is rejected', async () => { + const root = await fixture('symbolic-next-retargeted'); + await installRequiredBins(root); + const link = path.join(root, 'apps', 'web', '.next', 'dependency-link'); + await mkdir(path.dirname(link), { recursive: true }); + await symlink('../dependency-one', link); + await certifyGeneratedState(root, [{ path: 'dependency-link', target: '../dependency-one' }]); + await rm(link); + await symlink('../dependency-two', link); + + const result = await runPreflight({ root }); + assert.equal(result.code, 43); + assert.match(result.message, /symbolic-link manifest/); + }); + + await t.test('a manifest edited to whitelist a rogue symlink is rejected', async () => { + const root = await fixture('symbolic-next-tampered-manifest'); + await installRequiredBins(root); + await certifyGeneratedState(root); + const nextDir = path.join(root, 'apps', 'web', '.next'); + await symlink('/etc/hosts', path.join(nextDir, 'reviewer-symlink')); + await writeFile( + path.join(nextDir, '.mosaic-symlink-manifest'), + `${JSON.stringify({ + version: 1, + links: [{ path: 'reviewer-symlink', target: '/etc/hosts' }], + })}\n`, + ); + + const result = await runPreflight({ root }); + assert.equal(result.code, 43); + assert.match(result.message, /symbolic-link manifest/); + }); + + await t.test('unchanged canonical-style descendant symlinks are accepted', async () => { + const root = await fixture('symbolic-next-certified'); + await installRequiredBins(root); + const link = path.join( + root, + 'apps', + 'web', + '.next', + 'standalone', + 'node_modules', + 'dependency', + ); + await mkdir(path.dirname(link), { recursive: true }); + await symlink('../.pnpm/dependency', link); + await certifyGeneratedState(root, [ + { path: 'standalone/node_modules/dependency', target: '../.pnpm/dependency' }, + ]); + + assert.deepEqual(await runPreflight({ root }), { + code: 0, + message: 'checkout preflight passed', + }); + }); +}); + test('the source fingerprint includes inherited TypeScript configuration', async () => { const root = await fixture('inherited-typescript-config'); const config = path.join(root, 'tsconfig.base.json'); @@ -143,10 +257,7 @@ test('a matching generation marker accepts incremental output with mixed mtimes' await utimes(generated, new Date('2020-01-01T00:00:00Z'), new Date('2020-01-01T00:00:00Z')); const fresh = path.join(root, 'apps', 'web', '.next', 'types', 'routes.ts'); await writeFile(fresh, 'fresh generated output'); - await writeFile( - path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'), - await sourceFingerprint(root), - ); + await certifyGeneratedState(root); assert.deepEqual(await runPreflight({ root }), { code: 0, message: 'checkout preflight passed' }); });