Files
dingMCP/scripts/verify-release.mjs
T

174 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { createHash, createPublicKey, verify as verifySignature } from "node:crypto";
import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, join, resolve, sep } from "node:path";
import { execFileSync } from "node:child_process";
import { parseArgs } from "node:util";
const { values } = parseArgs({ options: { "release-dir": { type: "string", default: "release" } } });
const releaseDir = resolve(String(values["release-dir"]));
const manifestBytes = readFileSync(join(releaseDir, "manifest.json"));
const manifest = JSON.parse(manifestBytes.toString("utf8"));
const signature = JSON.parse(readFileSync(join(releaseDir, "manifest.sig"), "utf8"));
const publicKey = readFileSync(resolve("distribution/release-signing-public.pem"));
const keyId = releaseKeyId(publicKey);
const version = String(manifest.version ?? "");
if (manifest.schemaVersion !== 3 || !/^\d+\.\d+\.\d+(?:-rc\.\d+)?$/.test(version)) throw new Error("manifest 缺少有效 schemaVersion 3 或版本号");
if (manifest.signing?.algorithm !== "Ed25519" || manifest.signing?.keyId !== keyId) throw new Error("manifest 发布公钥编号不匹配");
if (signature.schemaVersion !== 1 || signature.algorithm !== "Ed25519" || signature.keyId !== keyId) throw new Error("manifest.sig 元数据不匹配");
if (!verifySignature(null, manifestBytes, publicKey, Buffer.from(String(signature.signature), "base64"))) throw new Error("manifest Ed25519 签名无效");
for (const [kind, artifact] of Object.entries(manifest.artifacts ?? {})) {
const name = artifactName(artifact, kind);
const expected = String(artifact.sha256 ?? "");
const actual = sha256(join(releaseDir, name));
if (!/^[a-f0-9]{64}$/.test(expected) || actual !== expected) throw new Error(`${kind} SHA-256 不匹配`);
for (const mirror of artifact.mirrors ?? []) {
const url = verifiedHttpsUrl(mirror, `${kind} mirror`);
if (basename(url.pathname) !== name) throw new Error(`${kind} mirror 文件名不一致`);
}
}
if (manifest.artifacts.publicKey.keyId !== keyId) throw new Error("publicKey artifact keyId 不匹配");
for (const mirror of manifest.distribution?.manifestMirrors ?? []) {
const url = verifiedHttpsUrl(mirror, "manifest mirror");
if (!url.pathname.endsWith("/manifest.json")) throw new Error("manifest mirror 必须指向 manifest.json");
}
const root = mkdtempSync(join(tmpdir(), "dingmcp-release-verify-"));
try {
const clientName = artifactName(manifest.artifacts.client, "client");
const archivePath = join(releaseDir, clientName);
const members = execFileSync("tar", ["-tzf", archivePath], { encoding: "utf8" }).split(/\r?\n/).filter(Boolean);
const verboseMembers = execFileSync("tar", ["-tvzf", archivePath], { encoding: "utf8" }).split(/\r?\n/).filter(Boolean);
if (verboseMembers.some((line) => line.startsWith("l") || line.startsWith("h"))) {
throw new Error(`${clientName} 含符号链接或硬链接,拒绝解压`);
}
for (const member of members) {
if (member.startsWith("/") || member.split("/").includes("..")) throw new Error(`${clientName} 含不安全归档路径:${member}`);
if (/(?:^|\/)(?:\.secrets|\.runtime|auth\.sqlite|session\.json)(?:\/|$)/.test(member)) throw new Error(`${clientName} 含禁止发布文件:${member}`);
}
const clientRoot = join(root, "client");
execFileSync("mkdir", ["-p", clientRoot]);
execFileSync("tar", ["-xzf", archivePath, "-C", clientRoot]);
scanTree(clientRoot);
const packageMetadata = JSON.parse(readFileSync(join(root, "client", "package", "package.json"), "utf8"));
if (packageMetadata.name !== "dingtalk-approval-client" || packageMetadata.version !== version) throw new Error("客户端 package.json 名称或版本不一致");
if (JSON.stringify(Object.keys(packageMetadata.bin ?? {}).sort()) !== JSON.stringify(["dingtalk-approval", "dingtalk-approval-bridge"])) {
throw new Error("客户端只能发布安装 CLI 和 Bridge 两个 bin");
}
if (JSON.stringify([...(packageMetadata.bundledDependencies ?? packageMetadata.bundleDependencies ?? [])].sort()) !== JSON.stringify(["@modelcontextprotocol/sdk", "zod"])) {
throw new Error("客户端必须内置全部签名运行依赖");
}
for (const dependency of ["@modelcontextprotocol/sdk", "zod"]) {
if (!existsFile(join(root, "client", "package", "node_modules", ...dependency.split("/"), "package.json"))) {
throw new Error(`客户端缺少内置依赖:${dependency}`);
}
}
for (const target of Object.values(packageMetadata.bin ?? {})) {
const body = readFileSync(join(root, "client", "package", String(target)), "utf8");
if (!body.startsWith("#!/usr/bin/env node\n")) throw new Error(`客户端 CLI 缺少 Node shebang:${target}`);
}
if (!existsFile(join(root, "client", "package", "skill", "dingtalk-approval", "SKILL.md"))
|| !existsFile(join(root, "client", "package", "skill", "dingtalk-approval", "references", "create-approval.md"))) {
throw new Error("客户端包缺少完整 Skill 与创建审批 reference");
}
const offlineRoot = join(root, "offline-install");
execFileSync("npm", [
"install", "--prefix", offlineRoot, "--cache", join(root, "empty-cache"), "--offline",
"--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund", join(releaseDir, artifactName(manifest.artifacts.client, "client")),
], { stdio: "pipe", env: { ...process.env, npm_config_update_notifier: "false" } });
if (!existsFile(join(offlineRoot, "node_modules", "dingtalk-approval-client", "dist", "src", "bridge.js"))
|| !existsFile(join(offlineRoot, "node_modules", "dingtalk-approval-client", "dist", "src", "approval-contract.js"))
|| !existsFile(join(offlineRoot, "node_modules", "dingtalk-approval-client", "skill", "dingtalk-approval", "references", "create-approval.md"))) {
throw new Error("签名客户端无法在空缓存中离线安装");
}
const installer = readFileSync(join(releaseDir, artifactName(manifest.artifacts.installer, "installer")), "utf8");
if (!installer.startsWith("#!/usr/bin/env node\n") || /from ["']\.\//.test(installer)) throw new Error("首次安装器必须是无相对依赖的独立 Node 脚本");
const install = readFileSync(join(releaseDir, "install.md"), "utf8");
if (install.includes("{{") || !install.includes(`版本:\`${version}\``)
|| !install.includes(manifest.gatewayUrl) || !install.includes("Streamable HTTP") || !install.includes("OAuth")
|| !install.includes("compat-install.md") || !install.includes("不要安装 Node.js、npm、本地 Bridge")
|| /PowerShell|Git Bash|postInstall|loginCommand|npm install -g|--host|WorkBuddy|Codex|Claude/.test(install)) {
throw new Error("install.md 不是有效的远程 MCP 优先安装引导");
}
const compatibilityInstall = readFileSync(join(releaseDir, "compat-install.md"), "utf8");
if (compatibilityInstall.includes("{{") || !compatibilityInstall.includes(`版本:\`${version}\``)
|| !compatibilityInstall.includes("仅当当前 Agent 明确不支持远程 Streamable HTTP MCP 或标准 OAuth")
|| !compatibilityInstall.includes("postInstall") || !compatibilityInstall.includes("onboarding.firstRun=true")
|| !compatibilityInstall.includes("系统无需预装 Node.js") || !compatibilityInstall.includes("Git Bash")
|| /TimeoutSec 300|--max-time 300|--timeout=300/.test(compatibilityInstall)
|| /--host|WorkBuddy|Codex|Claude/.test(compatibilityInstall)) {
throw new Error("compat-install.md 版本、占位符或兼容安装引导不正确");
}
const windowsBootstrapBytes = readFileSync(join(releaseDir, artifactName(manifest.artifacts.bootstrapWindows, "bootstrapWindows")));
if (windowsBootstrapBytes[0] !== 0xef || windowsBootstrapBytes[1] !== 0xbb || windowsBootstrapBytes[2] !== 0xbf) {
throw new Error("Windows PowerShell 引导缺少 UTF-8 BOM");
}
const windowsBootstrap = windowsBootstrapBytes.toString("utf8").replace(/^\uFEFF/, "");
const posixBootstrapPath = join(releaseDir, artifactName(manifest.artifacts.bootstrapPosix, "bootstrapPosix"));
const posixBootstrap = readFileSync(posixBootstrapPath, "utf8");
if (windowsBootstrap.includes("{{") || posixBootstrap.includes("{{")
|| !windowsBootstrap.includes("Save-DingDownload") || !windowsBootstrap.includes("Expand-Archive")
|| !windowsBootstrap.includes("[Console]::OutputEncoding = $dingUtf8") || !windowsBootstrap.includes("$OutputEncoding = $dingUtf8")
|| !windowsBootstrap.includes("curl.exe") || !windowsBootstrap.includes("--max-time 90")
|| !windowsBootstrap.includes(`-e "const [a,b]=process.versions.node.split('.').map(Number)`)
|| !windowsBootstrap.includes("$dingRuntimeName = [IO.Path]::GetFileNameWithoutExtension($dingNodeFile)")
|| windowsBootstrap.includes("GetFileNameWithoutExtension([IO.Path]::GetFileNameWithoutExtension")
|| windowsBootstrap.includes("TimeoutSec 300")
|| !windowsBootstrap.includes("Write-DingProgress") || !posixBootstrap.includes("ding_progress")
|| !posixBootstrap.includes("--max-time 90") || /--max-time 300|--timeout=300/.test(posixBootstrap)
|| !posixBootstrap.startsWith("#!/bin/sh\nset -eu\n") || !posixBootstrap.includes("tar -xzf")
|| /latest-v|npm install -g/.test(`${windowsBootstrap}\n${posixBootstrap}`)) {
throw new Error("无 Node 安装引导无效或仍包含不安全的动态版本来源");
}
execFileSync("sh", ["-n", posixBootstrapPath]);
const managedPlatforms = [...(manifest.requirements?.managedNode?.platforms ?? [])].sort();
if (!/^\d+\.\d+\.\d+$/.test(String(manifest.requirements?.managedNode?.version ?? ""))
|| JSON.stringify(managedPlatforms) !== JSON.stringify([
"darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "win32-arm64", "win32-x64",
])) throw new Error("清单没有完整声明三平台私有 Node 运行时");
const skill = readFileSync(join(releaseDir, artifactName(manifest.artifacts.skill, "skill")), "utf8");
if (!skill.startsWith("---\nname: dingtalk-approval\n")) throw new Error("通用 Skill artifact 无效");
} finally {
rmSync(root, { recursive: true, force: true });
}
console.log(`发行包签名与客户端校验通过:${version}`);
function artifactName(value, kind) {
const url = value?.url;
if (typeof url !== "string") throw new Error(`manifest 缺少 ${kind} URL`);
return basename(new URL(url).pathname);
}
function verifiedHttpsUrl(value, label) {
const url = new URL(String(value));
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) throw new Error(`${label} 必须是 HTTPS URL`);
return url;
}
function sha256(path) { return createHash("sha256").update(readFileSync(path)).digest("hex"); }
function releaseKeyId(value) {
const der = createPublicKey(value).export({ type: "spki", format: "der" });
return createHash("sha256").update(der).digest("hex");
}
function existsFile(path) { try { return statSync(path).isFile(); } catch { return false; } }
function scanTree(rootPath) {
for (const path of walk(rootPath)) {
if (statSync(path).size > 2_000_000) continue;
const body = readFileSync(path, "utf8");
if (/(?:^|\r?\n)-----BEGIN (?:RSA |EC |ENCRYPTED )?PRIVATE KEY-----\r?\n(?:[A-Za-z0-9+/=]{20,}\r?\n)+/.test(body)) {
throw new Error(`发行物疑似包含私钥:${path}`);
}
if (!path.includes(`${sep}node_modules${sep}`)
&& (/\/(?:home|Users)\/[a-zA-Z0-9._-]+\//.test(body) || /[A-Za-z]:\\Users\\[a-zA-Z0-9._-]+\\/.test(body))) {
throw new Error(`发行物包含机器私有绝对路径:${path}`);
}
}
}
function* walk(rootPath) {
for (const entry of readdirSync(rootPath, { withFileTypes: true })) {
const path = join(rootPath, entry.name);
if (entry.isDirectory()) yield* walk(path);
else if (entry.isFile()) yield path;
}
}