fix: harden V1 deployment manifests
This commit is contained in:
+65
-34
@@ -1,15 +1,26 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
export const IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc";
|
||||
export const EDUCATIONAL_WARNING = "Educational demo — mock token — never use real funds.";
|
||||
|
||||
const NETWORKS = {
|
||||
anvil: { name: "anvil", chainId: 31337, canonical: "anvil.json", recovery: "make reset-local" },
|
||||
"base-sepolia": { name: "base-sepolia", chainId: 84532, canonical: "base-sepolia.json", recovery: "make archive-base-manifest" },
|
||||
anvil: {
|
||||
name: "anvil", chainId: 31337, canonical: "anvil.json", recovery: "make reset-local", rpcUrl: "http://127.0.0.1:8545", explorerUrl: "",
|
||||
},
|
||||
"base-sepolia": {
|
||||
name: "base-sepolia", chainId: 84532, canonical: "base-sepolia.json", recovery: "make archive-base-manifest", rpcUrl: "https://sepolia.base.org", explorerUrl: "https://sepolia.basescan.org",
|
||||
},
|
||||
};
|
||||
|
||||
const MANIFEST_FIELDS = [
|
||||
"schemaVersion", "network", "chainId", "deploymentBlock", "rpcUrl", "explorerUrl", "token", "proxy", "implementation", "owner", "actorLabels", "actors",
|
||||
];
|
||||
const ANVIL_TEST_PHRASE = "test test test test test test test test test test test junk";
|
||||
const PROHIBITED_STRING_VALUE = /(?:private[_ -]?key|mnemonic|secret|password|credential|api[_ -]?key)|0x[a-fA-F0-9]{64}/i;
|
||||
|
||||
export function networkSpec(network) {
|
||||
const normalized = network === "baseSepolia" ? "base-sepolia" : network;
|
||||
const spec = NETWORKS[normalized];
|
||||
@@ -72,7 +83,7 @@ export async function finalizeDeployment({ root = process.cwd(), rpc }) {
|
||||
throw new Error("proxy implementation slot does not match pending manifest implementation");
|
||||
}
|
||||
|
||||
const confirmed = { ...pending, deploymentBlock };
|
||||
const confirmed = publicManifest(pending, deploymentBlock);
|
||||
validateManifest(confirmed);
|
||||
const path = join(root, "deployments", spec.canonical);
|
||||
await atomicWriteJson(path, confirmed);
|
||||
@@ -87,15 +98,17 @@ export async function readManifest(path, { pending = false } = {}) {
|
||||
|
||||
export function validateManifest(manifest, { pending = false } = {}) {
|
||||
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) throw new Error("manifest must be a JSON object");
|
||||
rejectSecretBearingContent(manifest);
|
||||
assertExactSchema(manifest);
|
||||
rejectProhibitedStringValues(manifest);
|
||||
if (manifest.schemaVersion !== 1) throw new Error("manifest schemaVersion must be 1");
|
||||
const spec = networkSpec(manifest.network);
|
||||
if (manifest.chainId !== spec.chainId) throw new Error(`manifest chain ID does not match ${manifest.network}`);
|
||||
if (!Number.isSafeInteger(manifest.deploymentBlock) || manifest.deploymentBlock < (pending ? 0 : 1)) {
|
||||
throw new Error(`manifest deploymentBlock must be ${pending ? "a nonnegative integer" : "at least 1"}`);
|
||||
}
|
||||
assertPublicUrl(manifest.rpcUrl, "rpcUrl", false);
|
||||
assertPublicUrl(manifest.explorerUrl, "explorerUrl", true);
|
||||
if (manifest.rpcUrl !== spec.rpcUrl || manifest.explorerUrl !== spec.explorerUrl) {
|
||||
throw new Error(`manifest display URLs must use the public ${manifest.network} endpoints`);
|
||||
}
|
||||
for (const field of ["token", "proxy", "implementation", "owner"]) assertAddress(manifest[field], field);
|
||||
if (!Array.isArray(manifest.actorLabels) || !Array.isArray(manifest.actors) || manifest.actorLabels.length !== manifest.actors.length || manifest.actors.length === 0) {
|
||||
throw new Error("manifest actors and actorLabels must be nonempty parallel arrays");
|
||||
@@ -118,9 +131,15 @@ export async function atomicWriteJson(path, value) {
|
||||
}
|
||||
|
||||
export async function atomicWrite(path, contents, io = { writeFile, rename }) {
|
||||
const temporary = join(dirname(path), `.${randomUUID()}.tmp`);
|
||||
await io.writeFile(temporary, contents, { mode: 0o600 });
|
||||
await io.rename(temporary, path);
|
||||
const temporary = join(dirname(path), `.${randomUUID()}.json`);
|
||||
const operations = { writeFile, rename, rm, ...io };
|
||||
try {
|
||||
await operations.writeFile(temporary, contents, { mode: 0o600 });
|
||||
await operations.rename(temporary, path);
|
||||
} catch (error) {
|
||||
await operations.rm(temporary, { force: true }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function assertAddress(value, label) {
|
||||
@@ -129,39 +148,50 @@ function assertAddress(value, label) {
|
||||
}
|
||||
}
|
||||
|
||||
function assertPublicUrl(value, label, allowEmpty) {
|
||||
if (allowEmpty && value === "") return;
|
||||
if (typeof value !== "string") throw new Error(`manifest ${label} must be a URL`);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new Error(`manifest ${label} must be a URL`);
|
||||
function assertExactSchema(manifest) {
|
||||
for (const field of MANIFEST_FIELDS) {
|
||||
if (!Object.hasOwn(manifest, field)) throw new Error(`manifest is missing required field ${field}`);
|
||||
}
|
||||
if (!/^https?:$/.test(parsed.protocol)) throw new Error(`manifest ${label} must use http or https`);
|
||||
if (parsed.username || parsed.password) throw new Error(`manifest ${label} contains credentials`);
|
||||
for (const key of parsed.searchParams.keys()) {
|
||||
if (/(?:key|token|secret|password|credential|private)/i.test(key)) {
|
||||
throw new Error(`manifest ${label} contains a secret-bearing query parameter`);
|
||||
}
|
||||
for (const field of Object.keys(manifest)) {
|
||||
if (!MANIFEST_FIELDS.includes(field)) throw new Error(`manifest contains unknown field ${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
function rejectSecretBearingContent(value, path = "") {
|
||||
function rejectProhibitedStringValues(value, path = "") {
|
||||
if (typeof value === "string") {
|
||||
if (value.includes(ANVIL_TEST_PHRASE) || PROHIBITED_STRING_VALUE.test(value)) {
|
||||
throw new Error(`manifest contains prohibited secret material at ${path}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => rejectSecretBearingContent(item, `${path}[${index}]`));
|
||||
value.forEach((item, index) => rejectProhibitedStringValues(item, `${path}[${index}]`));
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== "object") return;
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
const nestedPath = path ? `${path}.${key}` : key;
|
||||
if (/(?:private[_ -]?key|mnemonic|secret|password|credential|api[_ -]?key)/i.test(key)) {
|
||||
throw new Error(`manifest contains secret-bearing field ${nestedPath}`);
|
||||
}
|
||||
rejectSecretBearingContent(nested, nestedPath);
|
||||
rejectProhibitedStringValues(nested, nestedPath);
|
||||
}
|
||||
}
|
||||
|
||||
function publicManifest(manifest, deploymentBlock) {
|
||||
return {
|
||||
schemaVersion: manifest.schemaVersion,
|
||||
network: manifest.network,
|
||||
chainId: manifest.chainId,
|
||||
deploymentBlock,
|
||||
rpcUrl: manifest.rpcUrl,
|
||||
explorerUrl: manifest.explorerUrl,
|
||||
token: manifest.token,
|
||||
proxy: manifest.proxy,
|
||||
implementation: manifest.implementation,
|
||||
owner: manifest.owner,
|
||||
actorLabels: [...manifest.actorLabels],
|
||||
actors: [...manifest.actors],
|
||||
};
|
||||
}
|
||||
|
||||
function isSuccessfulReceipt(status) {
|
||||
return status === "0x1" || status === 1 || status === "1";
|
||||
}
|
||||
@@ -202,18 +232,19 @@ function fetchRpc(rpcUrl) {
|
||||
};
|
||||
}
|
||||
|
||||
async function main(argv) {
|
||||
export async function runFinalizeCli(argv, { root = process.cwd(), log = console.log } = {}) {
|
||||
log(EDUCATIONAL_WARNING);
|
||||
const [command, network, ...rest] = argv;
|
||||
if (command === "preflight-deploy" && network && rest.length === 0) return preflightDeploy({ network });
|
||||
if (command === "preflight-deploy" && network && rest.length === 0) return preflightDeploy({ root, network });
|
||||
if (command === "deploy") {
|
||||
if (network !== "--rpc-url" || typeof rest[0] !== "string" || rest.length !== 1) throw new Error("usage: finalize-manifest.mjs deploy --rpc-url <url>");
|
||||
return finalizeDeployment({ rpc: fetchRpc(rest[0]) });
|
||||
return finalizeDeployment({ root, rpc: fetchRpc(rest[0]) });
|
||||
}
|
||||
throw new Error("usage: finalize-manifest.mjs preflight-deploy <anvil|base-sepolia> | deploy --rpc-url <url>");
|
||||
}
|
||||
|
||||
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
|
||||
main(process.argv.slice(2)).catch((error) => {
|
||||
runFinalizeCli(process.argv.slice(2)).catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user