174 lines
12 KiB
JavaScript
174 lines
12 KiB
JavaScript
#!/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;
|
||
}
|
||
}
|