#!/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 不匹配`); } if (manifest.artifacts.publicKey.keyId !== keyId) throw new Error("publicKey artifact keyId 不匹配"); 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("唯一通用安装入口") || !install.includes("postInstall") || !install.includes("onboarding.firstRun=true") || !install.includes("系统无需预装 Node.js") || !install.includes("至少 10 分钟超时") || !install.includes("Git Bash") || /--host|WorkBuddy|Codex|Claude/.test(install)) { throw new Error("install.md 版本、占位符或通用安装引导不正确"); } const windowsBootstrap = readFileSync(join(releaseDir, artifactName(manifest.artifacts.bootstrapWindows, "bootstrapWindows")), "utf8"); 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("Write-DingProgress") || !posixBootstrap.includes("ding_progress") || !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 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; } }