fix: certify generated symlink state
This commit is contained in:
+62
-12
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user