diff --git a/plugins/plugin-guard/.gitignore b/plugins/plugin-guard/.gitignore new file mode 100644 index 00000000..1eae0cf6 --- /dev/null +++ b/plugins/plugin-guard/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/plugins/plugin-guard/CHANGELOG.md b/plugins/plugin-guard/CHANGELOG.md new file mode 100644 index 00000000..8d2b69d4 --- /dev/null +++ b/plugins/plugin-guard/CHANGELOG.md @@ -0,0 +1,14 @@ +# 更新日志 + +## 0.1.0 + +- 提供只读的插件清单、能力与风险扫描器。 +- 为 ZTools 2.4+ 增加有界、需人工授权的原生 MCP 扫描工具,并在旧版宿主上安全降级。 +- 在保留完整发现项总数的同时限制 MCP 证据体积,并在不泄露路径的前提下统一处理已授权目录不可用错误。 +- 人工界面最多保留 5,000 条证据,保留完整汇总数,并在状态和复制报告中明确标记截断。 +- 将界面授权绑定到规范目录身份,在人工或 MCP 扫描前后复验,并拒绝同路径替换。 +- 对每个 MCP 返回字符串应用统一的最终脱敏器,覆盖 GitHub/OpenAI/AWS/Bearer/JWT 以及完整或截断的 PEM 凭据。 +- 通过已保留证据的 `hasMore`、`nextOffset` 和有效的 `offset=400` 空终止页,让全部 400 条 MCP 发现项可分页访问。 +- 根目录使用 ZTools 可直接加载的源码入口,CI 仍打包自包含的 `dist` 清单。 +- 在未压缩 `dist` 超过 14.5 MB 时拒绝发布,并打印精确的已验证字节数。 +- 将人类界面、状态提示、目录选择对话框、风险说明和导出报告完整本地化为简体中文。 diff --git a/plugins/plugin-guard/README.md b/plugins/plugin-guard/README.md new file mode 100644 index 00000000..2d864d85 --- /dev/null +++ b/plugins/plugin-guard/README.md @@ -0,0 +1,25 @@ +# 插件安全体检 + +界面、状态提示、文件对话框和导出的 Markdown 标题均使用简体中文;MCP 工具名、schema 字段和错误码保持稳定,便于 Agent 调用。 + +插件安全体检会读取用户选择的插件目录,报告清单错误、高风险能力模式、文件上限、路径风险与已脱敏的证据。它不会修改受扫描目录,也不会跟随符号链接。受审计的文本会在全局扫描字节上限内完整读取,不会静默截断。 + +扫描器限制遍历深度、文件数量和总字节数;未知桥接字段会按失败关闭处理。它会统计每一个发现项,但人工界面最多保留 5,000 条证据,显示“已展示数 / 总数”,并在证据被截断时标记 JSON 和 Markdown 导出。报告可导出为 JSON 或 Markdown。首个版本故意不开启 ZIP 检查,因为安全的归档解析器应在与专用归档工具相同的限制边界内运行。 + +扫描器结合使用 lstat、realpath 和经校验的打开句柄,以缩小文件系统竞态窗口。同一账户下的进程仍可在检查间隙替换目录树;纯跨平台 Node 无法在不使用平台特定文件系统能力的情况下彻底消除这类竞态,因此应在发布前立即重新执行扫描。 + +## 开发与打包 + +根目录 `plugin.json` 直接指向 `src/ui/index.html`、`preload/index.cjs` 和 `logo.svg`,因此 ZTools 开发项目无需解析 `development` 覆盖项即可加载源码包。仓库 CI 仍会执行包内的 `build` 脚本并打包 `dist`:源入口目录不包含嵌套清单,构建则会生成自包含的 `dist/plugin.json`,其入口为 `index.html`、`preload/index.cjs` 和 `logo.svg`。 + +`npm run verify-dist` 会递归统计未压缩的 `dist` 目录,打印精确字节数,并在超过 14.5 MB(14,500,000 字节)安全上限时失败。 + +## Agent / MCP 使用 + +在 ZTools 2.4+ 中,清单工具 `scan_approved` 会以 `plugin_guard_scan_approved` 的完整名称暴露给 Agent。Agent 无法提供或发现路径:必须先由用户在界面中选择插件目录,该只读授权会在五分钟后或插件退出时失效。没有有效界面授权时,工具以 `WORKSPACE_APPROVAL_REQUIRED` 失败。旧宿主没有 `registerTool` 时仍会保留原有人工界面,仅省略 Agent 工具。 + +工具接受 `report=json|markdown|both`、范围为 0–400 的 `offset` 和范围为 1–200 的 `limit`。Markdown 由与界面相同的已审计核心格式化器生成。MCP 扫描使用更紧的上限:在统计每一个发现项的同时,最多保留 400 条证据,因此分页可使用完整总数,而不会构建无上限的响应。`hasMore` 和 `nextOffset` 仅描述已保留证据;`offset=400` 是有效的空终止页,即使 `totals.findings` 仍可能大得多。发现项消息和文件证据分别按 512 个 UTF-8 字节裁剪;过大的页会继续缩减并设置 `responseTruncated=true`,直到序列化响应不超过 512 KiB。页面不会缩减到零进度,汇总总数也保持不变。Agent 输出会移除绝对根目录和原始入口清单;在序列化前,单一最终脱敏器会覆盖每个返回字符串,包括具有可靠前缀的 GitHub/OpenAI/AWS 凭据、带标签的 AWS 密钥、Bearer/JWT 值以及完整或截断的 PEM 私钥。 + +界面授权保存所选目录的规范真实路径和跨平台文件系统身份,而不只是路径字符串。人工扫描和 MCP 扫描都会在读取前后立即复验该身份;如果目录被删除、移动、在同一路径下替换或变得无法验证,授权会被撤销,MCP 工具返回 `APPROVED_DIRECTORY_UNAVAILABLE`,且不会暴露路径。在无法使用有效设备号/inode 组合的系统上,会通过出生/创建/变更/修改时间元数据按失败关闭处理。宿主将 MCP 请求体限制为 1 MiB;大型目录内容不会放入请求,必须通过人工文件对话框授权。 + +运行 `npm test`、`npm run build`,再运行 `npm run verify-dist`。在真实 Windows、macOS 和 Linux ZTools 宿主中的测试仍待完成。 diff --git a/plugins/plugin-guard/logo.svg b/plugins/plugin-guard/logo.svg new file mode 100644 index 00000000..121113f2 --- /dev/null +++ b/plugins/plugin-guard/logo.svg @@ -0,0 +1 @@ + diff --git a/plugins/plugin-guard/package-lock.json b/plugins/plugin-guard/package-lock.json new file mode 100644 index 00000000..912e4ecd --- /dev/null +++ b/plugins/plugin-guard/package-lock.json @@ -0,0 +1 @@ +{"name":"plugin-guard","version":"0.1.0","lockfileVersion":3,"requires":true,"packages":{"":{"name":"plugin-guard","version":"0.1.0","engines":{"node":">=16"}}}} diff --git a/plugins/plugin-guard/package.json b/plugins/plugin-guard/package.json new file mode 100644 index 00000000..ae4bec93 --- /dev/null +++ b/plugins/plugin-guard/package.json @@ -0,0 +1,14 @@ +{ + "name": "plugin-guard", + "version": "0.1.0", + "private": true, + "type": "commonjs", + "engines": { + "node": ">=16" + }, + "scripts": { + "test": "node --test", + "build": "node --test && node scripts/build.mjs && node scripts/verify-dist.mjs", + "verify-dist": "node scripts/verify-dist.mjs" + } +} diff --git a/plugins/plugin-guard/plugin.json b/plugins/plugin-guard/plugin.json new file mode 100644 index 00000000..96a2de1d --- /dev/null +++ b/plugins/plugin-guard/plugin.json @@ -0,0 +1,28 @@ +{ + "name": "plugin-guard", + "title": "插件安全体检", + "version": "0.1.0", + "description": "面向 ZTools 插件的只读能力与风险扫描器。", + "author": "harris", + "main": "src/ui/index.html", + "logo": "logo.svg", + "preload": "preload/index.cjs", + "platform": ["darwin", "win32", "linux"], + "categories": ["development", "system"], + "features": [{"code": "inspect-plugin", "explain": "只读检查所选插件目录,不修改其中内容。", "icon": "logo.svg", "cmds": ["插件体检", "插件安全扫描"]}], + "tools": { + "scan_approved": { + "title": "扫描已授权的 ZTools 插件目录", + "description": "只读扫描最近五分钟内由用户在插件界面选择的目录;不接受路径参数。", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "report": {"type": "string", "enum": ["json", "markdown", "both"]}, + "offset": {"type": "integer", "minimum": 0, "maximum": 400}, + "limit": {"type": "integer", "minimum": 1, "maximum": 200} + } + } + } + } +} diff --git a/plugins/plugin-guard/preload/index.cjs b/plugins/plugin-guard/preload/index.cjs new file mode 100644 index 00000000..c9260e1b --- /dev/null +++ b/plugins/plugin-guard/preload/index.cjs @@ -0,0 +1,291 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); + +const TOOL_NAMES = Object.freeze({ scan: 'scan_approved' }); +const GRANT_TTL_MS = 5 * 60 * 1000; +const MCP_FINDING_LIMIT = 400; +const MCP_FEATURE_LIMIT = 200; +const MCP_RESPONSE_BYTES = 512 * 1024; +const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']); +const registeredHosts = new WeakSet(); +let selectedRoot = null; +let guardPromise; +let sessionEpoch = 0; + +function invalid(message) { return Object.assign(new TypeError(message), { code: 'INVALID_TOOL_INPUT' }); } +function approvalRequired() { return Object.assign(new Error('请先在插件安全体检界面中选择插件目录;授权仅保留五分钟。'), { code: 'WORKSPACE_APPROVAL_REQUIRED' }); } +function approvedDirectoryUnavailable() { return Object.assign(new Error('已授权的插件目录不可用,请在界面中重新选择。'), { code: 'APPROVED_DIRECTORY_UNAVAILABLE' }); } +function sessionExpired() { return Object.assign(new Error('插件会话已结束,请重新打开后选择插件目录。'), { code: 'SESSION_EXPIRED' }); } +function expireSession() { sessionEpoch += 1; selectedRoot = null; } + +function selectedPath(result) { + if (typeof result === 'string') return result; + if (Array.isArray(result)) return result[0]; + return result && result.filePaths && result.filePaths[0]; +} + +function statValue(value) { + if (typeof value === 'bigint') return value.toString(); + return Number.isFinite(value) ? String(value) : null; +} + +function directoryIdentity(info) { + return Object.freeze({ + dev: statValue(info.dev), + ino: statValue(info.ino), + birthtimeMs: statValue(info.birthtimeMs), + ctimeMs: statValue(info.ctimeMs), + mtimeMs: statValue(info.mtimeMs), + mode: statValue(info.mode) + }); +} + +function hasStableFileId(identity) { + return identity.dev !== null && identity.ino !== null && identity.ino !== '0'; +} + +function sameDirectoryIdentity(left, right) { + const stableFileId = hasStableFileId(left) && hasStableFileId(right); + if (stableFileId && (left.dev !== right.dev || left.ino !== right.ino)) return false; + const fallbackFields = ['birthtimeMs', 'ctimeMs', 'mtimeMs', 'mode']; + if (!stableFileId && !fallbackFields.some((key) => left[key] !== null && left[key] !== '0')) return false; + return fallbackFields.every((key) => left[key] === right[key]); +} + +function realpathDirectory(directory) { + const requested = path.resolve(String(directory)); + const requestedLink = fs.lstatSync(requested); + if (requestedLink.isSymbolicLink() || !requestedLink.isDirectory()) throw new TypeError('请选择插件目录,而不是符号链接或文件。'); + const real = typeof fs.realpathSync.native === 'function' ? fs.realpathSync.native(requested) : fs.realpathSync(requested); + const realLink = fs.lstatSync(real); + const info = fs.statSync(real); + if (realLink.isSymbolicLink() || !realLink.isDirectory() || !info.isDirectory()) throw new TypeError('请选择插件目录,而不是符号链接或文件。'); + const requestedIdentity = directoryIdentity(requestedLink); + const realIdentity = directoryIdentity(realLink); + const identity = directoryIdentity(info); + if (!sameDirectoryIdentity(requestedIdentity, realIdentity) || !sameDirectoryIdentity(realIdentity, identity)) throw new Error('插件目录在验证授权时发生变化。'); + return { real, identity }; +} + +function createDirectoryGrant(directory, expires = Date.now() + GRANT_TTL_MS) { + const captured = realpathDirectory(directory); + return Object.freeze({ path: captured.real, real: captured.real, identity: captured.identity, expires, source: 'ui' }); +} + +function revokeGrant(grant) { + if (!grant || selectedRoot === grant) selectedRoot = null; +} + +function currentApprovedGrant() { + if (!selectedRoot || selectedRoot.source !== 'ui' || selectedRoot.expires < Date.now()) { + selectedRoot = null; + throw approvalRequired(); + } + return selectedRoot; +} + +function verifyApprovedGrant(grant) { + try { + if (!grant || selectedRoot !== grant) throw new Error('已授权目录的授权信息发生变化。'); + const current = realpathDirectory(grant.path); + if (current.real !== grant.real || !sameDirectoryIdentity(grant.identity, current.identity)) throw new Error('已授权目录的身份信息发生变化。'); + return current.real; + } catch { + revokeGrant(grant); + throw approvedDirectoryUnavailable(); + } +} + +function validateObject(value, allowed, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw invalid(`${label} 必须是对象。`); + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) throw invalid(`${label} 必须是普通对象。`); + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string' || FORBIDDEN_KEYS.has(key) || !allowed.has(key)) throw invalid(`${label} 包含不支持的字段。`); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !Object.hasOwn(descriptor, 'value')) throw invalid(`${label} 只能包含数据字段。`); + } +} + +function validateToolInput(input) { + validateObject(input, new Set(['report', 'offset', 'limit']), '工具输入'); + if (input.report !== undefined && !['json', 'markdown', 'both'].includes(input.report)) throw invalid('report 必须是 json、markdown 或 both。'); + if (input.offset !== undefined && (!Number.isInteger(input.offset) || input.offset < 0 || input.offset > MCP_FINDING_LIMIT)) throw invalid(`offset 必须是 0 到 ${MCP_FINDING_LIMIT} 之间的整数。`); + if (input.limit !== undefined && (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 200)) throw invalid('limit 必须是 1 到 200 之间的整数。'); + return { report: input.report || 'json', offset: input.offset || 0, limit: input.limit || 50 }; +} + +function loadGuard() { + if (!guardPromise) { + const packaged = path.join(__dirname, '..', 'core', 'guard.mjs'); + const corePath = fs.existsSync(packaged) ? packaged : path.join(__dirname, '..', 'src', 'core', 'guard.mjs'); + guardPromise = import(pathToFileURL(corePath).href); + } + return guardPromise; +} + +function clip(value, max = 2048) { + const text = String(value ?? ''); + if (Buffer.byteLength(text, 'utf8') <= max) return text; + const suffix = '…[truncated]'; + const budget = Math.max(0, max - Buffer.byteLength(suffix, 'utf8')); + const bytes = Buffer.from(text, 'utf8'); + let end = Math.min(budget, bytes.length); + while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1; + return `${bytes.subarray(0, end).toString('utf8')}${suffix}`; +} + +function sanitizedText(value, sanitize) { + return sanitize(String(value ?? '')); +} + +function safeFinding(value, sanitize) { + return { level: clip(sanitizedText(value?.level, sanitize), 32), code: clip(sanitizedText(value?.code, sanitize), 128), message: clip(sanitizedText(value?.message, sanitize), 512), ...(value?.file ? { file: clip(sanitizedText(value.file, sanitize), 512) } : {}) }; +} + +function sanitizeOutput(value, sanitize, ancestors = new WeakSet()) { + if (typeof value === 'string') return sanitize(value); + if (value === null || typeof value !== 'object') return value; + if (ancestors.has(value)) return '[redacted]'; + ancestors.add(value); + const output = Array.isArray(value) ? [] : Object.create(null); + if (Array.isArray(value)) { + for (const item of value) output.push(sanitizeOutput(item, sanitize, ancestors)); + } else { + for (const [key, item] of Object.entries(value)) output[key] = sanitizeOutput(item, sanitize, ancestors); + } + ancestors.delete(value); + return output; +} + +function paginateReport(report, { offset, limit }, sanitize = (value) => String(value ?? '')) { + const issues = Array.isArray(report.issues) ? report.issues : []; + const risks = Array.isArray(report.risks) ? report.risks : []; + const all = [...issues.map((value) => ({ kind: 'issue', value })), ...risks.map((value) => ({ kind: 'risk', value }))]; + const page = all.slice(offset, offset + limit); + const pageIssues = page.filter((item) => item.kind === 'issue').map((item) => safeFinding(item.value, sanitize)); + const pageRisks = page.filter((item) => item.kind === 'risk').map((item) => safeFinding(item.value, sanitize)); + const featureList = Array.isArray(report.manifest?.features) ? report.manifest.features : []; + const totalIssues = Number.isFinite(Number(report.findingTotals?.issues)) ? Number(report.findingTotals.issues) : issues.length; + const totalRisks = Number.isFinite(Number(report.findingTotals?.risks)) ? Number(report.findingTotals.risks) : risks.length; + const totalFindings = totalIssues + totalRisks; + const totalFeatures = Number.isFinite(Number(report.manifestFeatureTotal)) ? Number(report.manifestFeatureTotal) : featureList.length; + const json = { + manifest: report.manifest ? { name: clip(sanitizedText(report.manifest.name, sanitize), 256), version: clip(sanitizedText(report.manifest.version, sanitize), 128), features: featureList.slice(0, 200).map((feature) => ({ code: clip(sanitizedText(feature?.code, sanitize), 128) })) } : null, + files: Number(report.files) || 0, + bytes: Number(report.bytes) || 0, + scannedAt: clip(sanitizedText(report.scannedAt, sanitize), 64), + issues: pageIssues, + risks: pageRisks, + totals: { issues: totalIssues, risks: totalRisks, findings: totalFindings, retainedFindings: all.length, features: totalFeatures }, + pagination: { offset, limit, returned: page.length, nextOffset: offset + page.length, hasMore: offset + page.length < all.length } + }; + return json; +} + +function serializedBytes(value) { return Buffer.byteLength(JSON.stringify(value), 'utf8'); } + +function buildMcpResponse(report, options, core) { + if (typeof core?.maskSecrets !== 'function') throw Object.assign(new Error('MCP 输出脱敏器不可用。'), { code: 'MCP_SANITIZER_UNAVAILABLE' }); + const sanitize = core.maskSecrets; + const retainedFindings = (Array.isArray(report.issues) ? report.issues.length : 0) + (Array.isArray(report.risks) ? report.risks.length : 0); + const requestedPageCount = Math.min(options.limit, Math.max(0, retainedFindings - options.offset)); + let effectiveLimit = requestedPageCount; + while (true) { + const json = paginateReport(report, { offset: options.offset, limit: effectiveLimit }, sanitize); + const responseTruncated = json.pagination.returned < requestedPageCount; + json.pagination = { ...json.pagination, requestedLimit: options.limit, requestedPageCount, responseTruncated }; + const markdown = core.toMarkdown({ scannedAt: json.scannedAt, files: json.files, bytes: json.bytes, issues: json.issues, risks: json.risks, findingTotals: { issues: json.totals.issues, risks: json.totals.risks, findings: json.totals.findings }, retainedFindings: json.pagination.returned, findingsTruncated: json.pagination.offset > 0 || json.pagination.hasMore }); + const meta = { totals: json.totals, pagination: json.pagination, responseTruncated }; + const response = options.report === 'markdown' ? { ...meta, markdown } : options.report === 'both' ? { ...meta, json, markdown } : { ...meta, json }; + const safeResponse = sanitizeOutput(response, sanitize); + if (serializedBytes(safeResponse) <= MCP_RESPONSE_BYTES) return safeResponse; + if (effectiveLimit <= 1) throw Object.assign(new Error('MCP 响应无法满足安全预算。'), { code: 'MCP_RESPONSE_TOO_LARGE' }); + effectiveLimit = Math.max(1, Math.floor(effectiveLimit / 2)); + } +} + +async function scanGrantedDirectory(scanOptions) { + let core, report, grant; + try { + grant = currentApprovedGrant(); + const root = verifyApprovedGrant(grant); + core = await loadGuard(); + report = await core.scanPlugin(root, scanOptions); + verifyApprovedGrant(grant); + } catch (error) { + if (error?.code === 'WORKSPACE_APPROVAL_REQUIRED' || error?.code === 'APPROVED_DIRECTORY_UNAVAILABLE') throw error; + revokeGrant(grant); + throw approvedDirectoryUnavailable(); + } + return { core, report }; +} + +async function scanApprovedForMcp(input) { + const options = validateToolInput(input); + const { core, report } = await scanGrantedDirectory({ findingLimit: MCP_FINDING_LIMIT, manifestFeatureLimit: MCP_FEATURE_LIMIT }); + return buildMcpResponse(report, options, core); +} + +function createBridge(api) { + const bridge = { + choosePluginDirectory: async () => { + if (typeof api.showOpenDialog !== 'function') throw new Error('当前 ZTools 环境不提供目录选择对话框。'); + expireSession(); + const epoch = sessionEpoch; + const result = selectedPath(await api.showOpenDialog({ title: '选择插件目录', properties: ['openDirectory'] })); + if (epoch !== sessionEpoch) throw sessionExpired(); + if (!result) throw new Error('已取消选择。'); + const grant = createDirectoryGrant(result); + if (epoch !== sessionEpoch) throw sessionExpired(); + selectedRoot = grant; + return true; + }, + scan: async () => { + const { report } = await scanGrantedDirectory(); + const { root, ...safe } = report; + return safe; + }, + copyText: typeof api.copyText === 'function' ? (text) => api.copyText(String(text)) : undefined + }; + return Object.freeze(bridge); +} + +function registerTools(target) { + const api = target?.ztools; + if (!api || typeof api.registerTool !== 'function' || registeredHosts.has(api)) return false; + let registered = false; + try { api.registerTool.call(api, TOOL_NAMES.scan, scanApprovedForMcp); registered = true; } catch {} + registeredHosts.add(api); + return registered; +} + +function attachPluginGuard(target) { + if (!target || (typeof target !== 'object' && typeof target !== 'function')) throw new TypeError('需要一个类 window 目标。'); + const api = target.ztools || {}; + target.pluginGuard = createBridge(api); + if (typeof api.onPluginOut === 'function') api.onPluginOut(expireSession); + registerTools(target); + return target.pluginGuard; +} + +if (typeof globalThis !== 'undefined') attachPluginGuard(globalThis); + +module.exports = { + TOOL_NAMES, + GRANT_TTL_MS, + MCP_FINDING_LIMIT, + MCP_RESPONSE_BYTES, + validateToolInput, + paginateReport, + sanitizeOutput, + buildMcpResponse, + scanApprovedForMcp, + registerTools, + attachPluginGuard, + __testGrant: (root, expires = Date.now() + GRANT_TTL_MS) => { selectedRoot = createDirectoryGrant(root, expires); }, + __testClear: expireSession +}; diff --git a/plugins/plugin-guard/preload/package.json b/plugins/plugin-guard/preload/package.json new file mode 100644 index 00000000..5bbefffb --- /dev/null +++ b/plugins/plugin-guard/preload/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/plugins/plugin-guard/scripts/build.mjs b/plugins/plugin-guard/scripts/build.mjs new file mode 100644 index 00000000..9add627b --- /dev/null +++ b/plugins/plugin-guard/scripts/build.mjs @@ -0,0 +1 @@ +import { cp,mkdir,readFile,rm,writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'),dist=path.join(root,'dist'); await rm(dist,{recursive:true,force:true});await mkdir(dist,{recursive:true});await cp(path.join(root,'src','ui'),dist,{recursive:true});await cp(path.join(root,'src','core'),path.join(dist,'core'),{recursive:true});await cp(path.join(root,'preload'),path.join(dist,'preload'),{recursive:true});await cp(path.join(root,'logo.svg'),path.join(dist,'logo.svg'));const app=await readFile(path.join(dist,'app.mjs'),'utf8');await writeFile(path.join(dist,'app.mjs'),app.replace("'../core/guard.mjs'", "'./core/guard.mjs'"));const m=JSON.parse(await readFile(path.join(root,'plugin.json'),'utf8'));delete m.development;m.main='index.html';m.logo='logo.svg';m.preload='preload/index.cjs';await writeFile(path.join(dist,'plugin.json'),JSON.stringify(m,null,2)+'\n'); diff --git a/plugins/plugin-guard/scripts/dist-size.mjs b/plugins/plugin-guard/scripts/dist-size.mjs new file mode 100644 index 00000000..d249d628 --- /dev/null +++ b/plugins/plugin-guard/scripts/dist-size.mjs @@ -0,0 +1,42 @@ +import { lstat, readdir } from 'node:fs/promises'; +import path from 'node:path'; + +export const DIST_SIZE_LIMIT = 14_500_000; + +function relativeEntry(root, entryPath) { + return path.relative(root, entryPath) || '.'; +} + +function unsupportedEntry(root, entryPath) { + return new Error(`unsupported dist entry: ${relativeEntry(root, entryPath)}`); +} + +export async function directoryBytes( + directory, + { root = directory, readDirectory = readdir, inspectPath = lstat } = {} +) { + let bytes = 0; + + for (const entry of await readDirectory(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (!entry.isDirectory() && !entry.isFile()) throw unsupportedEntry(root, entryPath); + + const metadata = await inspectPath(entryPath); + if (metadata.isSymbolicLink()) throw unsupportedEntry(root, entryPath); + if (entry.isDirectory() && metadata.isDirectory()) { + bytes += await directoryBytes(entryPath, { root, readDirectory, inspectPath }); + } else if (entry.isFile() && metadata.isFile()) { + bytes += metadata.size; + } else { + throw unsupportedEntry(root, entryPath); + } + } + + return bytes; +} + +export function assertDistSize(bytes, limit = DIST_SIZE_LIMIT) { + if (!Number.isSafeInteger(bytes) || bytes < 0) throw new TypeError('dist size must be a non-negative safe integer'); + if (bytes > limit) throw new Error(`dist size ${bytes} exceeds the 14.5 MB safety limit (${limit} bytes)`); + return bytes; +} diff --git a/plugins/plugin-guard/scripts/verify-dist.mjs b/plugins/plugin-guard/scripts/verify-dist.mjs new file mode 100644 index 00000000..86f1b28c --- /dev/null +++ b/plugins/plugin-guard/scripts/verify-dist.mjs @@ -0,0 +1,19 @@ +import { access, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { assertDistSize, directoryBytes } from './dist-size.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const dist = path.join(root, 'dist'); + +const manifest = JSON.parse(await readFile(path.join(dist, 'plugin.json'), 'utf8')); +if (manifest.development) throw new Error('development leaked'); +for (const file of [manifest.main, manifest.logo, manifest.preload, 'core/guard.mjs']) await access(path.join(dist, file)); +if (!manifest.tools?.scan_approved) throw new Error('missing MCP tool scan_approved'); +if (/node:|\.\.\/core\//.test(await readFile(path.join(dist, 'app.mjs'), 'utf8'))) throw new Error('renderer imports Node code'); +if (await readFile(path.join(root, 'preload', 'index.cjs'), 'utf8') !== await readFile(path.join(dist, 'preload', 'index.cjs'), 'utf8')) throw new Error('dist preload is stale'); +if (await readFile(path.join(root, 'src', 'core', 'guard.mjs'), 'utf8') !== await readFile(path.join(dist, 'core', 'guard.mjs'), 'utf8')) throw new Error('dist guard core is stale'); + +const distBytes = await directoryBytes(dist); +assertDistSize(distBytes); +console.log(`plugin-guard dist verified (${distBytes} bytes)`); diff --git a/plugins/plugin-guard/src/core/guard.mjs b/plugins/plugin-guard/src/core/guard.mjs new file mode 100644 index 00000000..2dcabd2e --- /dev/null +++ b/plugins/plugin-guard/src/core/guard.mjs @@ -0,0 +1,358 @@ +import { lstat, open, readdir, realpath, stat } from 'node:fs/promises'; +import { constants as fsConstants } from 'node:fs'; +import path from 'node:path'; +const LIMITS = Object.freeze({ depth: 12, files: 1600, bytes: 24 * 1024 * 1024 }); +const DEFAULT_FINDING_LIMIT = 5000; +const DEFAULT_IO = Object.freeze({ lstat, open, readdir, realpath, stat }); +const KNOWN_BRIDGE = new Set(['choosePluginDirectory','scan','copyText']); +const FEATURE_CODES = /^[a-z0-9][a-z0-9-]{1,62}$/; +const PEM_PRIVATE_KEY = /-----BEGIN ((?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED) )?PRIVATE KEY)-----[\s\S]*?(?:-----END \1-----|$)/gi; +const SECRET_PATTERNS = Object.freeze([ + /\bgithub_pat_[A-Za-z0-9_]*\b/gi, + /\bgh[pousr]_[A-Za-z0-9_]*\b/gi, + /\bsk-(?:proj-)?[A-Za-z0-9_-]{12,}\b/gi, + /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, + /\bAWS[_\s-]*SECRET[_\s-]*ACCESS[_\s-]*KEY\s*[:=]\s*['\"]?[A-Za-z0-9/+=._-]{8,}/gi, + /\bBearer\s+[A-Za-z0-9._~+\/-]{8,}/gi, + /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, + /\b(?:api[_\s-]?key|access[_\s-]?token|secret|token|password|signature)\s*[:=]\s*['\"]?[^\s,'\";]{6,}/gi +]); +const RISKY = [ + ['dynamic-require', /require\s*\(\s*[^'"\s]/, 'high'], ['eval', /\beval\s*\(|\bFunction\s*\(/, 'high'], + ['child-process', /require\s*\(\s*['\"]child_process|\bexec(?:Sync|File)?\s*\(/, 'medium'], ['shell', /shell\s*:\s*true|exec\s*\(\s*[`'"]/, 'high'], + ['whole-module-bridge', /(?:globalThis|window)\.[\w$]+\s*=\s*(?:require\(|(?:fs|child_process)\b)/, 'high'], ['remote-url', /https?:\/\//i, 'low'] +]; +const BRIDGE_FIELD = /\bbridge\.([A-Za-z_$][\w$]*)\s*=/g; +function issue(level, code, message, file) { return { level, code, message, ...(file ? { file } : {}) }; } +function boundedOption(value, label) { if (value === undefined) return Infinity; if (!Number.isInteger(value) || value < 0 || value > 10000) throw new RangeError(`${label} 必须是 0 到 10,000 之间的整数。`); return value; } +function branchContains(directoryRel, file) { + if (directoryRel === '') return true; + return typeof file === 'string' && (file === directoryRel || file.startsWith(`${directoryRel}/`)); +} +function createFindingStore(limit) { + const store = { issues: [], risks: [], totals: { issues: 0, risks: 0 }, retained: 0 }; + const counts = { issues: new Map(), risks: new Map() }; + const sink = (kind) => Object.freeze({ push(value) { + const file = typeof value?.file === 'string' ? value.file : ''; + counts[kind].set(file, (counts[kind].get(file) || 0) + 1); + store.totals[kind] += 1; + if (store.retained < limit) { store[kind].push(value); store.retained += 1; } + return store.totals[kind]; + } }); + const dropBranch = (directoryRel) => { + for (const kind of ['issues', 'risks']) { + let removedTotal = 0; + for (const [file, count] of counts[kind]) { + if (!branchContains(directoryRel, file)) continue; + removedTotal += count; + counts[kind].delete(file); + } + store.totals[kind] -= removedTotal; + const retainedBefore = store[kind].length; + store[kind] = store[kind].filter((finding) => !branchContains(directoryRel, finding?.file)); + store.retained -= retainedBefore - store[kind].length; + } + }; + return { store, issues: sink('issues'), risks: sink('risks'), dropBranch }; +} +function within(root, candidate) { const rel = path.relative(root, candidate); return rel && !rel.startsWith('..'+path.sep) && rel !== '..' && !path.isAbsolute(rel); } +function withinOrEqual(root, candidate) { return path.resolve(root) === path.resolve(candidate) || within(root, candidate); } +function comparablePath(value) { const resolved=path.resolve(value); return process.platform==='win32'?resolved.toLocaleLowerCase('en-US'):resolved; } +function sameCanonicalPath(left, right) { return comparablePath(left)===comparablePath(right); } +function entrySnapshotMatches(left, right) { return Boolean(left&&right&&['dev','ino','mode','size','mtimeMs','ctimeMs'].every((key)=>Object.is(left[key],right[key]))); } +function hasSnapshotValue(value, rejectZero = false) { return value!==undefined&&value!==null&&!(typeof value==='number'&&Number.isNaN(value))&&(!rejectZero||String(value)!=='0'); } +function snapshotFieldsMatch(left, right, keys, requireOne = false) { + let compared=0; + for(const key of keys){ + const hasLeft=hasSnapshotValue(left[key]),hasRight=hasSnapshotValue(right[key]); + if(hasLeft!==hasRight)return false; + if(!hasLeft)continue; + compared+=1; + if(!Object.is(left[key],right[key]))return false; + } + return !requireOne||compared>0; +} +function directorySnapshotMatches(left, right) { + if(!left||!right||!left.isDirectory()||!right.isDirectory())return false; + const stableId=hasSnapshotValue(left.ino,true)&&hasSnapshotValue(right.ino,true); + if(stableId&&!snapshotFieldsMatch(left,right,['dev','ino']))return false; + if(!snapshotFieldsMatch(left,right,['mode','mtimeMs','ctimeMs'],true))return false; + return stableId||snapshotFieldsMatch(left,right,['dev','birthtimeMs'],true); +} +function resolveIo(overrides) { const io={...DEFAULT_IO,...(overrides||{})}; for(const key of Object.keys(DEFAULT_IO))if(typeof io[key]!=='function')throw new TypeError(`io.${key} 必须是函数。`); return io; } +export function manifestRelative(root, candidate, pathApi = path) { return pathApi.relative(root,candidate).split(pathApi.sep).join('/'); } +export function safeRelative(value) { return typeof value === 'string' && value.length > 0 && !value.includes('\0') && !path.isAbsolute(value) && !/^(?:[A-Za-z]:[\\/]|\\\\)/.test(value) && !value.split(/[\\/]+/).includes('..'); } +export function maskSecrets(text) { + let output = String(text).replace(PEM_PRIVATE_KEY, '[redacted]'); + for (const pattern of SECRET_PATTERNS) output = output.replace(pattern, '[redacted]'); + return output; +} +async function verifyDirectoryChain(root, directory, io) { + const relative=path.relative(root,directory); + if(relative==='..'||relative.startsWith(`..${path.sep}`)||path.isAbsolute(relative))throw new Error('目录超出授权根目录。'); + const segments=relative?relative.split(path.sep):[]; + let current=root,last; + for(const segment of [null,...segments]){ + if(segment!==null)current=path.join(current,segment); + const info=await io.lstat(current); + if(info.isSymbolicLink()||!info.isDirectory())throw new Error('目录链包含符号链接或非目录入口。'); + last=info; + } + const canonical=await io.realpath(directory); + if(!withinOrEqual(root,canonical)||!sameCanonicalPath(canonical,directory))throw new Error('目录规范路径超出授权根目录。'); + const after=await io.lstat(directory); + if(after.isSymbolicLink()||!directorySnapshotMatches(last,after))throw new Error('目录在验证过程中发生变化。'); + return after; +} +async function inspectEntry(root, directory, full, expectedDirectory, io) { + const parentBefore=await verifyDirectoryChain(root,directory,io); + if(!directorySnapshotMatches(expectedDirectory,parentBefore))throw new Error('父目录在扫描过程中发生变化。'); + const before=await io.lstat(full); + const parentAfter=await verifyDirectoryChain(root,directory,io); + const after=await io.lstat(full); + if(!directorySnapshotMatches(parentBefore,parentAfter)||!entrySnapshotMatches(before,after))throw new Error('目录入口在扫描过程中发生变化。'); + return after; +} +async function verifyFileCandidate(root, full, io) { + if(!within(root,full))throw new Error('文件超出授权根目录。'); + const directory=path.dirname(full),parentBefore=await verifyDirectoryChain(root,directory,io),before=await io.lstat(full); + if(before.isSymbolicLink()||!before.isFile())throw new Error('文件入口不是普通文件。'); + const canonical=await io.realpath(full); + if(!within(root,canonical)||!sameCanonicalPath(canonical,full))throw new Error('文件规范路径超出授权根目录。'); + const parentAfter=await verifyDirectoryChain(root,directory,io),after=await io.lstat(full); + if(!directorySnapshotMatches(parentBefore,parentAfter)||!fileSnapshotMatches(before,after))throw new Error('文件在规范路径验证过程中发生变化。'); + return after; +} +function captureDirectorySnapshot(info) { + const isDirectory = info.isDirectory(); + return Object.freeze({ dev:info.dev,ino:info.ino,birthtimeMs:info.birthtimeMs,ctimeMs:info.ctimeMs,mtimeMs:info.mtimeMs,mode:info.mode,isDirectory:()=>isDirectory }); +} +function emptyBranch(issues = []) { return { entries:[],directories:[],files:0,bytes:0,issues,caseKeys:[] }; } +function mergeBranch(target, branch, seenKeys) { + target.entries.push(...branch.entries); target.directories.push(...branch.directories); target.files+=branch.files; target.bytes+=branch.bytes; target.issues.push(...branch.issues); target.caseKeys.push(...branch.caseKeys); + for(const key of branch.caseKeys)seenKeys.add(key); +} +async function collectBranch(root, state, directory, depth, io, inheritedCaseKeys) { + const directoryRel=manifestRelative(root,directory); + if(depth>state.limits.depth)return emptyBranch([issue('high','depth-limit','目录深度超过上限。',directoryRel)]); + let directoryBefore; + try{directoryBefore=await verifyDirectoryChain(root,directory,io)}catch{return emptyBranch([issue('high','directory-race','目录在扫描过程中变得不安全。',directoryRel)])} + const branch=emptyBranch(),seenKeys=new Set(inheritedCaseKeys); + try{ + const entries=await io.readdir(directory,{withFileTypes:true}); + for(const entry of entries){ + const full=path.join(directory,entry.name),rel=manifestRelative(root,full),info=await inspectEntry(root,directory,full,directoryBefore,io); + if(info.isSymbolicLink()){branch.issues.push(issue('high','symlink','不会扫描符号链接。',rel));continue} + if(info.isDirectory()){ + const child=await collectBranch(root,state,full,depth+1,io,seenKeys); + mergeBranch(branch,child,seenKeys); + if(state.files+branch.files>state.limits.files||state.bytes+branch.bytes>state.limits.bytes)throw new RangeError('扫描超过安全上限。'); + }else if(!info.isFile()){ + branch.issues.push(issue('medium','special-file','已忽略特殊文件系统入口。',rel)); + }else{ + const safeInfo=await verifyFileCandidate(root,full,io); + if(!fileSnapshotMatches(info,safeInfo))throw new Error('候选文件在收集过程中发生变化。'); + const nextFiles=branch.files+1,nextBytes=branch.bytes+safeInfo.size; + if(state.files+nextFiles>state.limits.files||state.bytes+nextBytes>state.limits.bytes)throw new RangeError('扫描超过安全上限。'); + const key=rel.normalize('NFC').toLocaleLowerCase('en-US'); + seenKeys.add(key);branch.caseKeys.push(key);branch.files=nextFiles;branch.bytes=nextBytes;branch.entries.push({rel,size:safeInfo.size}); + } + } + const directoryAfter=await verifyDirectoryChain(root,directory,io); + if(!directorySnapshotMatches(directoryBefore,directoryAfter))throw new Error('目录在扫描过程中发生变化。'); + branch.directories.push({rel:directoryRel,snapshot:captureDirectorySnapshot(directoryAfter)}); + return branch; + }catch(error){ + if(error instanceof RangeError)throw error; + return emptyBranch([issue('high','directory-race','目录在扫描过程中发生变化,已丢弃该目录的全部结果。',directoryRel)]); + } +} +async function collect(root, state, io) { + const branch=await collectBranch(root,state,root,0,io,state.caseKeys); + state.entries.push(...branch.entries);state.directories.push(...branch.directories);state.files=branch.files;state.bytes=branch.bytes; + for(const key of branch.caseKeys)state.caseKeys.add(key); + for(const finding of branch.issues)state.issues.push(finding); +} +function branchDirectory(root, relative) { return relative?path.join(root,...relative.split('/')):root; } +function directoryIsAncestorOfFile(directoryRel, fileRel) { + if(directoryRel==='')return true; + const parent=path.posix.dirname(fileRel); + return parent===directoryRel||parent.startsWith(`${directoryRel}/`); +} +function invalidateCollectedBranch(state, findings, directoryRel) { + if(!state.directories.some((record)=>record.rel===directoryRel))return; + state.entries=state.entries.filter((entry)=>!branchContains(directoryRel,entry.rel)); + state.directories=state.directories.filter((record)=>!branchContains(directoryRel,record.rel)); + for(const rel of state.collectedSizes.keys())if(branchContains(directoryRel,rel))state.collectedSizes.delete(rel); + state.files=state.entries.length; + state.bytes=state.entries.reduce((total,entry)=>total+entry.size,0); + findings.dropBranch(directoryRel); + state.issues.push(issue('high','directory-race','目录在扫描后发生变化,已丢弃该目录的全部结果。',directoryRel)); +} +async function verifyCollectedDirectories(root, state, io, findings, fileRel) { + const records=state.directories + .filter((record)=>fileRel===undefined||directoryIsAncestorOfFile(record.rel,fileRel)) + .slice() + .sort((left,right)=>left.rel.split('/').filter(Boolean).length-right.rel.split('/').filter(Boolean).length); + for(const record of records){ + if(!state.directories.includes(record))continue; + try{ + const current=await verifyDirectoryChain(root,branchDirectory(root,record.rel),io); + if(!directorySnapshotMatches(record.snapshot,current))throw new Error('目录身份已变化。'); + }catch{invalidateCollectedBranch(state,findings,record.rel)} + } +} +export function fileSnapshotMatches(left, right) { return Boolean(left&&right&&left.isFile()&&right.isFile()&&['dev','ino','size','mtimeMs','ctimeMs'].every((key)=>Object.is(left[key],right[key]))); } +export async function readHandleBounded(handle, limit) { if(!Number.isSafeInteger(limit)||limit<0||limit>LIMITS.bytes)throw new RangeError('受审计文件超过扫描字节上限。');const buffer=Buffer.allocUnsafe(limit+1);let offset=0;while(offsetlimit)throw new RangeError('受审计文件增长后超过已授权大小。');return buffer.subarray(0,offset); } +async function readAuditedFileBounded(root, full, expectedSize, io, verifyReadBoundary = async()=>{}) { + const before=await verifyFileCandidate(root,full,io); + if(before.size!==expectedSize)throw new Error('文件大小与有界扫描结果不一致。'); + const flags=fsConstants.O_RDONLY|(process.platform==='win32'?0:(fsConstants.O_NOFOLLOW||0)),handle=await io.open(full,flags); + try{ + const held=await handle.stat(); + if(!fileSnapshotMatches(before,held))throw new Error('文件在打开过程中发生变化。'); + const openedPath=await verifyFileCandidate(root,full,io); + if(!fileSnapshotMatches(held,openedPath))throw new Error('已打开文件不再位于授权根目录。'); + await verifyReadBoundary(); + const bytes=await readHandleBounded(handle,expectedSize),heldAfter=await handle.stat(),after=await verifyFileCandidate(root,full,io); + if(!fileSnapshotMatches(held,heldAfter)||!fileSnapshotMatches(heldAfter,after))throw new Error('文件在读取过程中发生变化。'); + await verifyReadBoundary(); + return bytes.toString('utf8'); + }finally{await handle.close()} +} +function validateManifest(manifest, entries, issues) { + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return issues.push(issue('high','manifest','plugin.json 必须是对象。')); + for (const key of ['name','version','author','logo']) if (typeof manifest[key] !== 'string' || !manifest[key].trim()) issues.push(issue('high','manifest-field',`缺少 ${key} 或其值无效。`)); + if (!manifest.main && !manifest.preload) issues.push(issue('high','entrypoint','清单需要 main 或 preload 入口。')); + for (const key of ['main','logo','preload']) if (manifest[key] && (!safeRelative(manifest[key]) || !entries.has(manifest[key]))) issues.push(issue('high','entrypoint',`${key} 不安全或缺失:${String(manifest[key])}。`)); + const allowedCategories = new Set(['productivity','development','system','media','text','network','game','other']); if (!Array.isArray(manifest.categories) || manifest.categories.some((item)=>!allowedCategories.has(item))) issues.push(issue('medium','categories','categories 包含未知或无法跨平台使用的键。')); + if (!Array.isArray(manifest.platform) || !['darwin','win32','linux'].every((p)=>manifest.platform.includes(p))) issues.push(issue('medium','platform','清单应明确覆盖 darwin、win32 和 linux。')); + if (!Array.isArray(manifest.features)) { issues.push(issue('high','feature-list','features 必须是数组。')); return; } + const seen = new Set(); for (const feature of manifest.features) { if (!feature || !FEATURE_CODES.test(feature.code || '') || seen.has(feature.code)) issues.push(issue('high','feature-code','Feature code 必须唯一且范围明确。')); seen.add(feature?.code); if (!Array.isArray(feature?.cmds) || feature.cmds.some((x)=>{if(typeof x==='string')return !x.trim();if(!x||!['files','img','over'].includes(x.type)||!x.label)return true;if(x.type==='files')return !Number.isInteger(x.maxLength)||x.maxLength<1||(!Array.isArray(x.extensions)&&typeof x.match!=='string');if(x.type==='over')return !Number.isInteger(x.minLength)||!Number.isInteger(x.maxLength)||x.minLength<0||x.maxLength{ + const rel=manifestRelative(root,full); + await verifyCollectedDirectories(root,state,io,findings,rel); + if(!state.collectedSizes.has(rel))throw new Error('文件不在有界扫描范围内。'); + const verifyReadBoundary=async()=>{ + const scope=needsInitialReadBarrier?undefined:rel; + needsInitialReadBarrier=false; + await verifyCollectedDirectories(root,state,io,findings,scope); + if(!state.collectedSizes.has(rel))throw new Error('文件所属目录在读取前发生变化。'); + }; + try{return await readAuditedFileBounded(root,full,state.collectedSizes.get(rel),io,verifyReadBoundary)}catch(error){invalidEntries.add(rel);throw error} + }; + let index=new Set(state.entries.map((entry)=>entry.rel)),manifest; + if(!index.has('plugin.json')){ + state.issues.push(issue('high','manifest','缺少 plugin.json。')); + }else{ + try{ + const source=await readAuditedFile(path.join(root,'plugin.json')); + try{manifest=JSON.parse(source)}catch{state.issues.push(issue('high','manifest-json','plugin.json 不是有效的 JSON。'))} + }catch{state.issues.push(issue('high','manifest-read','无法从授权根目录安全读取 plugin.json。'))} + } + const risks=findings.risks; + for(const entry of state.entries.filter((value)=>/\.(?:cjs|mjs|js|json|html)$/i.test(value.rel))){ + if(!state.collectedSizes.has(entry.rel))continue; + const full=path.join(root,entry.rel); + if(!within(root,full)){state.issues.push(issue('high','containment','入口超出插件根目录。',entry.rel));invalidEntries.add(entry.rel);continue} + let source; + try{source=await readAuditedFile(full)}catch{if(state.collectedSizes.has(entry.rel))risks.push(issue('high','incomplete-file-read','无法安全、完整地读取文件。',entry.rel));continue} + for(const [code,re,level] of RISKY){re.lastIndex=0;if(re.test(source))risks.push(issue(level,code,`检测到静态模式:${code}。`,entry.rel))} + if(/preload/i.test(entry.rel)&&source.split(/\r?\n/).some((line)=>line.length>2000))risks.push(issue('medium','minified-preload','难以阅读或已打包的 preload 代码会扩大审计风险。',entry.rel)); + const req=/require\s*\(\s*['\"](\.[^'\"]+)['\"]\s*\)/g;let required; + while((required=req.exec(source))){ + const base=path.resolve(path.dirname(full),required[1]),candidates=[base,`${base}.js`,`${base}.cjs`,`${base}.json`,path.join(base,'index.js'),path.join(base,'index.cjs'),path.join(base,'index.json')]; + if(!candidates.some((target)=>within(root,target))){risks.push(issue('high','relative-require-escape','相对 require 超出插件根目录。',entry.rel));continue} + let found=false; + for(const target of candidates.filter((candidate)=>within(root,candidate))){try{const targetRel=manifestRelative(root,target);await verifyCollectedDirectories(root,state,io,findings,targetRel);if(!state.collectedSizes.has(targetRel))continue;await verifyFileCandidate(root,target,io);await verifyCollectedDirectories(root,state,io,findings,targetRel);if(!state.collectedSizes.has(targetRel))continue;found=true;break}catch{}} + if(!found)risks.push(issue('high','missing-relative-require',`无法读取相对 require:${required[1]}。`,entry.rel)); + } + BRIDGE_FIELD.lastIndex=0;let field; + while((field=BRIDGE_FIELD.exec(source)))if(!KNOWN_BRIDGE.has(field[1]))risks.push(issue('high','unknown-bridge',`未知桥接字段 ${field[1]} 已按失败关闭处理。`,entry.rel)); + if(maskSecrets(source)!==source)risks.push(issue('high','secret','发现疑似凭据,报告中的值已脱敏。',entry.rel)); + } + await verifyCollectedDirectories(root,state,io,findings); + let safeEntries=[]; + for(const entry of [...state.entries]){ + await verifyCollectedDirectories(root,state,io,findings,entry.rel); + if(!state.collectedSizes.has(entry.rel))continue; + const full=path.join(root,entry.rel); + try{const current=await verifyFileCandidate(root,full,io);if(current.size!==entry.size)throw new Error('文件大小发生变化。');await verifyCollectedDirectories(root,state,io,findings,entry.rel);if(state.collectedSizes.has(entry.rel))safeEntries.push(entry)} + catch{if(!invalidEntries.has(entry.rel))state.issues.push(issue('high','entry-invalidated','入口不再安全,已从扫描结果中移除。',entry.rel));invalidEntries.add(entry.rel)} + } + await verifyCollectedDirectories(root,state,io,findings); + safeEntries=safeEntries.filter((entry)=>state.collectedSizes.has(entry.rel)); + index=new Set(safeEntries.map((entry)=>entry.rel)); + if(manifest&&index.has('plugin.json'))validateManifest(manifest,index,state.issues); + else if(manifest&&!index.has('plugin.json'))manifest=undefined; + const collisionKeys=new Set(); + for(const entry of safeEntries){const key=entry.rel.normalize('NFC').toLocaleLowerCase('en-US');if(collisionKeys.has(key))state.issues.push(issue('high','case-collision','大小写或 Unicode 冲突路径无法安全跨平台使用。',entry.rel));collisionKeys.add(key)} + const featureValues=Array.isArray(manifest?.features)?manifest.features:[]; + const retainedFeatures=manifestFeatureLimit===Infinity?featureValues:featureValues.slice(0,manifestFeatureLimit); + const clean=(finding)=>({ ...finding, ...(finding.file?{file:maskSecrets(finding.file)}:{}), message:maskSecrets(finding.message) }); + return { + root, + manifest:manifest?{name:maskSecrets(manifest.name),version:maskSecrets(manifest.version),features:retainedFeatures.map((feature)=>({code:maskSecrets(feature?.code||'')}))}:null, + manifestFeatureTotal:featureValues.length, + files:safeEntries.length, + bytes:safeEntries.reduce((total,entry)=>total+entry.size,0), + issues:findings.store.issues.map(clean), + risks:findings.store.risks.map(clean), + findingTotals:{issues:findings.store.totals.issues,risks:findings.store.totals.risks,findings:findings.store.totals.issues+findings.store.totals.risks}, + retainedFindings:findings.store.retained, + findingsTruncated:findings.store.retained({ ...entry, rel:maskSecrets(entry.rel) })), + scannedAt:new Date().toISOString() + }; +} +function markdownText(value) { return maskSecrets(value).replace(/[\r\n\t]+/g,' ').replace(/\\/g,'\\\\').replace(/([`*_[\]<>#])/g,'\\$1'); } +function markdownCode(value) { return maskSecrets(value).replace(/[\r\n\t]+/g,' ').replace(/`/g,'ˋ'); } +function humanText(value) { return String(value??'').replace(/\bFeature code\b/gi,'功能代码').replace(/\[redacted\]/gi,'[已脱敏]'); } +const HUMAN_FINDING_CODES = Object.freeze({ + 'dynamic-require': '动态依赖加载', + eval: '动态代码执行', + 'child-process': '子进程调用', + shell: '命令解释器执行', + 'whole-module-bridge': '整模块桥接暴露', + 'remote-url': '远程网址', + 'depth-limit': '目录深度超限', + 'directory-race': '目录竞态风险', + symlink: '符号链接', + 'special-file': '特殊文件', + 'case-collision': '路径大小写或 Unicode 冲突', + manifest: '插件清单', + 'manifest-field': '清单字段', + entrypoint: '插件入口', + categories: '分类配置', + platform: '平台范围', + 'feature-list': '功能列表', + 'feature-code': '功能代码', + 'feature-trigger': '功能触发器', + 'manifest-json': '清单格式', + 'manifest-read': '清单读取', + containment: '路径越界', + 'incomplete-file-read': '文件未完整读取', + 'minified-preload': '预加载脚本可读性', + 'relative-require-escape': '相对依赖越界', + 'missing-relative-require': '相对依赖缺失', + 'unknown-bridge': '未知桥接字段', + secret: '疑似凭据', + 'entry-invalidated': '入口已失效' +}); +export function humanFindingCode(value) { return HUMAN_FINDING_CODES[String(value??'')] || '未分类检查项'; } +function humanFindingMessage(value, code) { const text=humanText(value),raw=String(code??''),label=HUMAN_FINDING_CODES[raw]; return label?text.split(raw).join(label):text; } +function humanLevel(value) { return ({high:'高',medium:'中',low:'低'})[value]||humanText(value); } +export function toMarkdown(report) { const issues=Array.isArray(report.issues)?report.issues:[],risks=Array.isArray(report.risks)?report.risks:[],shown=Number.isFinite(Number(report.retainedFindings))?Number(report.retainedFindings):issues.length+risks.length,total=Number.isFinite(Number(report.findingTotals?.findings))?Number(report.findingTotals.findings):shown,truncated=Boolean(report.findingsTruncated)||shown 证据已截断;汇总数量覆盖完整扫描结果。'); rows.push('','## 发现'); for(const x of [...issues,...risks]) rows.push('- `'+markdownCode(humanLevel(x.level))+'` `'+markdownCode(humanFindingCode(x.code))+'`'+(x.file?' — `'+markdownCode(humanText(x.file))+'`':'')+':'+markdownText(humanFindingMessage(x.message,x.code))); return rows.join('\n'); } +export { LIMITS, DEFAULT_FINDING_LIMIT, KNOWN_BRIDGE }; diff --git a/plugins/plugin-guard/src/ui/app.mjs b/plugins/plugin-guard/src/ui/app.mjs new file mode 100644 index 00000000..e14517d5 --- /dev/null +++ b/plugins/plugin-guard/src/ui/app.mjs @@ -0,0 +1,15 @@ +const $=(id)=>document.getElementById(id);let last;const bridge=globalThis.pluginGuard; +function markdownText(value){return String(value??'').replace(/[\r\n\t]+/g,' ').replace(/\\/g,'\\\\').replace(/([`*_[\]<>#])/g,'\\$1')} +function markdownCode(value){return '`'+String(value??'').replace(/[\r\n\t]+/g,' ').replace(/`/g,'ˋ')+'`'} +function humanText(value){return String(value??'').replace(/\bFeature code\b/gi,'功能代码').replace(/\[redacted\]/gi,'[已脱敏]')} +const HUMAN_FINDING_CODES=Object.freeze({'dynamic-require':'动态依赖加载',eval:'动态代码执行','child-process':'子进程调用',shell:'命令解释器执行','whole-module-bridge':'整模块桥接暴露','remote-url':'远程网址','depth-limit':'目录深度超限','directory-race':'目录竞态风险',symlink:'符号链接','special-file':'特殊文件','case-collision':'路径大小写或 Unicode 冲突',manifest:'插件清单','manifest-field':'清单字段',entrypoint:'插件入口',categories:'分类配置',platform:'平台范围','feature-list':'功能列表','feature-code':'功能代码','feature-trigger':'功能触发器','manifest-json':'清单格式','manifest-read':'清单读取',containment:'路径越界','incomplete-file-read':'文件未完整读取','minified-preload':'预加载脚本可读性','relative-require-escape':'相对依赖越界','missing-relative-require':'相对依赖缺失','unknown-bridge':'未知桥接字段',secret:'疑似凭据','entry-invalidated':'入口已失效'}); +function humanFindingCode(value){return HUMAN_FINDING_CODES[String(value??'')]||'未分类检查项'} +function humanFindingMessage(value,code){const text=humanText(value),raw=String(code??''),label=HUMAN_FINDING_CODES[raw];return label?text.split(raw).join(label):text} +function humanLevel(value){return({high:'高',medium:'中',low:'低'})[value]||humanText(value)} +function findingCounts(report){const shown=Number.isFinite(Number(report.retainedFindings))?Number(report.retainedFindings):(report.issues?.length||0)+(report.risks?.length||0),total=Number.isFinite(Number(report.findingTotals?.findings))?Number(report.findingTotals.findings):shown;return{shown,total,truncated:Boolean(report.findingsTruncated)||shown 证据已截断;汇总数量覆盖完整扫描结果。');rows.push('','## 发现');for(const x of [...report.issues,...report.risks])rows.push('- '+markdownCode(humanLevel(x.level))+' '+markdownCode(humanFindingCode(x.code))+(x.file?' — '+markdownCode(humanText(x.file)):'')+':'+markdownText(humanFindingMessage(x.message,x.code)));return rows.join('\n')} +async function copy(text,label){try{if(bridge?.copyText)await bridge.copyText(text);else await navigator.clipboard.writeText(text);$('status').textContent=label}catch{$('status').textContent='当前环境无法复制'}} +$('choose').addEventListener('click',async()=>{try{if(!bridge)throw new Error('插件安全体检桥接不可用。');await bridge.choosePluginDirectory();$('status').textContent='目录授权成功,尚未开始扫描。'}catch(e){$('status').textContent=e.message}}); +$('scan').addEventListener('click',async()=>{try{if(!bridge)throw new Error('插件安全体检桥接不可用。');$('status').textContent='正在进行只读扫描…';last=await bridge.scan();$('map').textContent=last.manifest?`${humanText(last.manifest.name)} · 功能代码:${last.manifest.features.map(f=>humanText(f.code)).join(', ')||'无'}`:'没有有效清单';$('inventory').textContent=`${last.files} 个文件 · ${last.bytes.toLocaleString()} 字节`; $('risks').replaceChildren();for(const finding of [...last.issues,...last.risks]){const li=document.createElement('li');li.className=finding.level;li.textContent=`${humanLevel(finding.level)} ${humanFindingCode(finding.code)}${finding.file?' · '+humanText(finding.file):''}:${humanFindingMessage(finding.message,finding.code)}`;$('risks').append(li)}const counts=findingCounts(last);$('status').textContent=`只读扫描完成 · 已展示 ${counts.shown.toLocaleString()} / ${counts.total.toLocaleString()} 项发现${counts.truncated?'(证据已截断)':''}`}catch(e){$('status').textContent=humanText(e.message)}}); +$('copyMarkdown').addEventListener('click',()=>last&©(markdown(last),'已复制脱敏后的 Markdown 报告')); +$('copyJson').addEventListener('click',()=>last&©(JSON.stringify(last,null,2),'已复制脱敏后的 JSON 报告')); diff --git a/plugins/plugin-guard/src/ui/index.html b/plugins/plugin-guard/src/ui/index.html new file mode 100644 index 00000000..507428d8 --- /dev/null +++ b/plugins/plugin-guard/src/ui/index.html @@ -0,0 +1 @@ +插件安全体检

只读能力审计

插件安全体检

等待选择目录

能力概览

尚未扫描清单。

文件统计

diff --git a/plugins/plugin-guard/src/ui/style.css b/plugins/plugin-guard/src/ui/style.css new file mode 100644 index 00000000..518463e5 --- /dev/null +++ b/plugins/plugin-guard/src/ui/style.css @@ -0,0 +1 @@ +:root{font-family:ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#eef3f3;background:#181c20}*{box-sizing:border-box}body{margin:0;background:linear-gradient(135deg,#181c20,#25343b)}main{max-width:1120px;margin:auto;padding:32px 20px}header,.picker{display:flex;justify-content:space-between;gap:16px;align-items:center;flex-wrap:wrap}header p{color:#28b8bd;letter-spacing:.16em;font:700 11px ui-monospace,monospace;margin:0}h1{font-size:clamp(30px,5vw,52px);margin:3px 0}.panel{background:#242a2f;border:1px solid #475057;border-radius:14px;padding:18px;margin-top:18px}.grid{display:grid;grid-template-columns:1.5fr 1fr;gap:18px}.grid .panel{margin-top:18px}button,input{font:inherit}button{padding:9px 12px;border-radius:8px;border:1px solid #28b8bd;background:#1c5d62;color:white;font-weight:700;cursor:pointer;white-space:normal;overflow-wrap:anywhere;text-align:center}button:focus-visible,input:focus-visible{outline:3px solid #f1ad4e;outline-offset:3px}input{margin:10px 0;display:block;max-width:100%}.rail{border-top:5px solid #f1ad4e}#status,#map,#inventory,li{overflow-wrap:anywhere}.panel h2{text-wrap:pretty}ol{padding-left:22px}.high{border-left-color:#ec6660}.medium{border-left-color:#f1ad4e}.low{border-left-color:#28b8bd}li{border-left:4px solid;padding:8px;margin:8px 0;background:#1b2024;font:13px ui-monospace,monospace}@media(max-width:720px){main{padding:20px 12px}.grid{grid-template-columns:1fr}}@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} diff --git a/plugins/plugin-guard/test/branch-lifecycle.test.mjs b/plugins/plugin-guard/test/branch-lifecycle.test.mjs new file mode 100644 index 00000000..fcaaff9d --- /dev/null +++ b/plugins/plugin-guard/test/branch-lifecycle.test.mjs @@ -0,0 +1,101 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, open, lstat, readdir, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { scanPlugin } from '../src/core/guard.mjs'; + +async function fixture(files) { + const root = await mkdtemp(path.join(tmpdir(), 'guard-branch-lifecycle-')); + for (const [relative, source] of Object.entries(files)) { + const target = path.join(root, relative); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, source); + } + return root; +} + +function validFiles(deepFiles) { + return { + 'plugin.json': JSON.stringify({ name: 'x', version: '1', author: 'a', main: 'index.html', logo: 'logo.svg', preload: 'preload.cjs', platform: ['darwin', 'win32', 'linux'], categories: ['other'], features: [] }), + 'index.html': 'ok', + 'logo.svg': 'x', + 'preload.cjs': 'bridge.scan = () => 1', + ...deepFiles + }; +} + +function changedDirectoryInfo(info) { + return new Proxy(info, { get(value, key) { + if (key === 'mtimeMs' || key === 'ctimeMs') return Number(value[key]) + 1000; + const item = Reflect.get(value, key, value); + return typeof item === 'function' ? item.bind(value) : item; + } }); +} + +test('drops a deep branch changed after collection before the first file read', async (t) => { + const input = await fixture(validFiles({ 'deep/inside.js': 'eval(1)' })); + t.after(() => rm(input, { recursive: true, force: true })); + const root = await realpath(input); + const deep = path.join(root, 'deep'); + const late = path.join(deep, 'late.js'); + const manifest = path.join(root, 'plugin.json'); + let mutated = false; + let openedDeep = false; + const report = await scanPlugin(root, { io: { + lstat: async (target) => { + const info = await lstat(target); + return mutated && path.resolve(target) === path.resolve(deep) ? changedDirectoryInfo(info) : info; + }, + open: async (target, flags) => { + if (!mutated && path.resolve(target) === path.resolve(manifest)) { + await writeFile(late, 'late'); + mutated = true; + } + if (path.resolve(target).startsWith(`${path.resolve(deep)}${path.sep}`)) openedDeep = true; + return open(target, flags); + } + } }); + assert.equal(mutated, true); + assert.equal(openedDeep, false); + assert.equal(report.issues.filter((finding) => finding.code === 'directory-race' && finding.file === 'deep').length, 1); + assert.equal(report.entries.some((entry) => entry.rel.startsWith('deep/')), false); + assert.equal(report.risks.some((finding) => finding.file?.startsWith('deep/')), false); + assert.equal(report.risks.some((finding) => finding.code === 'eval'), false); +}); + +test('invalidating a partially audited branch restores finding totals and does not read its open handle', async (t) => { + const input = await fixture(validFiles({ 'deep/a.js': 'eval(1); require(variable)', 'deep/b.js': 'safe' })); + t.after(() => rm(input, { recursive: true, force: true })); + const root = await realpath(input); + const deep = path.join(root, 'deep'); + const late = path.join(deep, 'late.js'); + const trigger = path.join(deep, 'b.js'); + let mutated = false; + let readInvalidatedHandle = false; + const report = await scanPlugin(root, { findingLimit: 1, io: { + readdir: async (directory, options) => (await readdir(directory, options)).sort((left, right) => left.name.localeCompare(right.name)), + lstat: async (target) => { + const info = await lstat(target); + return mutated && path.resolve(target) === path.resolve(deep) ? changedDirectoryInfo(info) : info; + }, + open: async (target, flags) => { + const handle = await open(target, flags); + if (path.resolve(target) !== path.resolve(trigger)) return handle; + await writeFile(late, 'late'); + mutated = true; + return { + stat: (...args) => handle.stat(...args), + read: (...args) => { readInvalidatedHandle = true; return handle.read(...args); }, + close: () => handle.close() + }; + } + } }); + assert.equal(mutated, true); + assert.equal(readInvalidatedHandle, false); + assert.deepEqual(report.findingTotals, { issues: 1, risks: 0, findings: 1 }); + assert.equal(report.retainedFindings, 1); + assert.equal(report.findingsTruncated, false); + assert.equal(report.entries.some((entry) => entry.rel.startsWith('deep/')), false); + assert.equal([...report.issues, ...report.risks].some((finding) => finding.file?.startsWith('deep/') && finding.code !== 'directory-race'), false); +}); diff --git a/plugins/plugin-guard/test/dist-size.test.mjs b/plugins/plugin-guard/test/dist-size.test.mjs new file mode 100644 index 00000000..06f331c3 --- /dev/null +++ b/plugins/plugin-guard/test/dist-size.test.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import path from 'node:path'; +import { assertDistSize, directoryBytes, DIST_SIZE_LIMIT } from '../scripts/dist-size.mjs'; + +function fakeEntry(name, type) { + return { + name, + isDirectory: () => type === 'directory', + isFile: () => type === 'file', + isSymbolicLink: () => type === 'symlink' + }; +} + +function fakeTree() { + const root = path.resolve('/virtual/dist'); + const nested = path.join(root, 'nested'); + const entries = new Map([ + [root, [fakeEntry('main.js', 'file'), fakeEntry('nested', 'directory')]], + [nested, [fakeEntry('payload.bin', 'file')]] + ]); + const metadata = new Map([ + [path.join(root, 'main.js'), { isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false, size: 11 }], + [nested, { isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, size: 0 }], + [path.join(nested, 'payload.bin'), { isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false, size: 31 }] + ]); + return { + root, + readDirectory: async (directory) => entries.get(directory) ?? [], + inspectPath: async (entryPath) => metadata.get(entryPath) + }; +} + +test('size boundary accepts exactly 14,500,000 bytes', () => { + assert.equal(assertDistSize(DIST_SIZE_LIMIT), DIST_SIZE_LIMIT); +}); + +test('size boundary rejects 14,500,001 bytes', () => { + assert.throws(() => assertDistSize(DIST_SIZE_LIMIT + 1), /exceeds the 14\.5 MB safety limit/); +}); + +test('directoryBytes recursively counts regular files', async () => { + const tree = fakeTree(); + assert.equal(await directoryBytes(tree.root, tree), 42); +}); + +test('directoryBytes rejects a symbolic link without following it', async () => { + const root = path.resolve('/virtual/dist'); + let inspected = false; + await assert.rejects( + directoryBytes(root, { + root, + readDirectory: async () => [fakeEntry('escape', 'symlink')], + inspectPath: async () => { + inspected = true; + return { isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false, size: 1 }; + } + }), + /unsupported dist entry: escape/ + ); + assert.equal(inspected, false); +}); + +test('directoryBytes rejects a link reported by lstat after a stale file Dirent', async () => { + const root = path.resolve('/virtual/dist'); + await assert.rejects( + directoryBytes(root, { + root, + readDirectory: async () => [fakeEntry('changed', 'file')], + inspectPath: async () => ({ + isDirectory: () => false, + isFile: () => false, + isSymbolicLink: () => true, + size: 0 + }) + }), + /unsupported dist entry: changed/ + ); +}); diff --git a/plugins/plugin-guard/test/guard.test.mjs b/plugins/plugin-guard/test/guard.test.mjs new file mode 100644 index 00000000..12ff218f --- /dev/null +++ b/plugins/plugin-guard/test/guard.test.mjs @@ -0,0 +1,42 @@ +import test from 'node:test';import assert from 'node:assert/strict';import {mkdtemp,writeFile,symlink,mkdir,rename,realpath as fsRealpath,readdir as fsReaddir,lstat as fsLstat,open as fsOpen}from 'node:fs/promises';import {tmpdir}from 'node:os';import path from 'node:path';import {fileSnapshotMatches,humanFindingCode,manifestRelative,readHandleBounded,scanPlugin,safeRelative,maskSecrets,toMarkdown}from '../src/core/guard.mjs'; +async function fixture(files={}){const root=await mkdtemp(path.join(tmpdir(),'guard-'));for(const [name,value] of Object.entries(files)){const p=path.join(root,name);await mkdir(path.dirname(p),{recursive:true});await writeFile(p,value)}return root} +test('rejects unsafe entrypoint paths and secret exposure',async()=>{const root=await fixture({'plugin.json':JSON.stringify({name:'x',version:'1',author:'a',main:'../oops',logo:'logo.svg',preload:'preload.cjs',platform:['darwin','win32','linux'],features:[{code:'bad',cmds:['files']}]}),'logo.svg':'x','preload.cjs':"const key='sk-abcdefghijklmnopqrstuvwxyz'"});const r=await scanPlugin(root);assert.ok(r.issues.some(x=>x.code==='entrypoint'));assert.ok(r.risks.some(x=>x.code==='secret'));assert.ok(!JSON.stringify(r).includes('abcdefghijklmnopqrstuvwxyz'));}); +test('does not follow symlinks',async()=>{const root=await fixture({'plugin.json':'{}'});await symlink('/tmp',path.join(root,'out'));const r=await scanPlugin(root);assert.ok(r.issues.some(x=>x.code==='symlink'));}); +test('path policy is platform-independent',()=>{assert.equal(safeRelative('../x'),false);assert.equal(safeRelative('C:\\x'),false);assert.equal(safeRelative('src/main.js'),true);assert.match(maskSecrets('token=abcdefghijklmnop'),/redacted/);}); +test('win32 scanner paths use POSIX separators for manifest entrypoint comparison',()=>{const root='C:\\plugins\\plugin-guard',index=new Set(['src\\ui\\index.html','preload\\index.cjs','logo.svg'].map((relative)=>manifestRelative(root,path.win32.join(root,relative),path.win32)));assert.deepEqual([...index],['src/ui/index.html','preload/index.cjs','logo.svg']);assert.ok(index.has('src/ui/index.html'));assert.ok(index.has('preload/index.cjs'));}); +test('final sanitizer covers prefixed, labeled, and complete or truncated private-key credentials',()=>{const values=[`github_pat_${'A'.repeat(24)}`,`ghp_${'B'.repeat(36)}`,`AKIA${'C'.repeat(16)}`,`AWS_SECRET_ACCESS_KEY=${'D'.repeat(40)}`,`Bearer ${'E'.repeat(24)}`],complete=`-----BEGIN PRIVATE KEY-----\n${'F'.repeat(64)}\n-----END PRIVATE KEY-----`,truncated=`-----BEGIN RSA PRIVATE KEY-----\n${'G'.repeat(64)}`;for(const value of [...values,complete,truncated]){const clean=maskSecrets(`before ${value} after`);assert.equal(clean.includes(value),false);assert.equal(clean.includes('BEGIN'),false);assert.match(clean,/redacted/);}}); +test('human Markdown localizes labels without changing stable report values',()=>{const finding={level:'high',code:'feature-code',file:'[redacted].js',message:'Feature code 包含 [redacted]。'},report={scannedAt:'now',files:1,bytes:1,issues:[finding],risks:[]},value=toMarkdown(report);assert.match(value,/`高` `功能代码`/);assert.match(value,/功能代码 包含/);assert.match(value,/已脱敏/);assert.doesNotMatch(value,/`high`|feature-code|Feature code|\[redacted\]/);assert.deepEqual(finding,{level:'high',code:'feature-code',file:'[redacted].js',message:'Feature code 包含 [redacted]。'});assert.equal(maskSecrets('token=abcdefghijklmnop'),'[redacted]');}); +test('every stable finding code has a deterministic Chinese human label',()=>{const expected={'dynamic-require':'动态依赖加载',eval:'动态代码执行','child-process':'子进程调用',shell:'命令解释器执行','whole-module-bridge':'整模块桥接暴露','remote-url':'远程网址','depth-limit':'目录深度超限','directory-race':'目录竞态风险',symlink:'符号链接','special-file':'特殊文件','case-collision':'路径大小写或 Unicode 冲突',manifest:'插件清单','manifest-field':'清单字段',entrypoint:'插件入口',categories:'分类配置',platform:'平台范围','feature-list':'功能列表','feature-code':'功能代码','feature-trigger':'功能触发器','manifest-json':'清单格式','manifest-read':'清单读取',containment:'路径越界','incomplete-file-read':'文件未完整读取','minified-preload':'预加载脚本可读性','relative-require-escape':'相对依赖越界','missing-relative-require':'相对依赖缺失','unknown-bridge':'未知桥接字段',secret:'疑似凭据','entry-invalidated':'入口已失效'};for(const [code,label] of Object.entries(expected))assert.equal(humanFindingCode(code),label);assert.equal(humanFindingCode('future-code'),'未分类检查项');}); +test('fails closed for unknown preload bridge fields',async()=>{const root=await fixture({'plugin.json':JSON.stringify({name:'x',version:'1',author:'a',main:'index.html',logo:'logo.svg',preload:'preload.cjs',platform:['darwin','win32','linux'],features:[]}), 'index.html':'ok','logo.svg':'x','preload.cjs':'bridge.readEverything = () => 1'});const r=await scanPlugin(root);assert.ok(r.risks.some(x=>x.code==='unknown-bridge'));}); +test('only returns masked report fields and follows Node relative-require candidates',async()=>{const root=await fixture({'plugin.json':JSON.stringify({name:'token=abcdefghijklmnop',version:'1',author:'a',main:'index.html',logo:'logo.svg',preload:'preload.cjs',categories:['other'],features:[{code:'safe'}]}), 'index.html':'ok','logo.svg':'x','preload.cjs':"require('./helper')",'helper.js':'module.exports=1','token=abcdefghijklmnop.js':'x'});const r=await scanPlugin(root);assert.ok(!JSON.stringify(r).includes('abcdefghijklmnop'));assert.equal(r.risks.some(x=>x.code==='missing-relative-require'),false);}); +test('scans full audited text beyond the former snippet boundary',async()=>{const root=await fixture({'plugin.json':JSON.stringify({name:'x',version:'1',author:'a',main:'index.html',logo:'logo.svg',preload:'preload.cjs',categories:['other'],features:[]}), 'index.html':'ok','logo.svg':'x','preload.cjs':'x'.repeat(100000)+'\neval(1)'});const r=await scanPlugin(root);assert.ok(r.risks.some(x=>x.code==='eval'));}); +test('audited handle reads stop at the collected size plus one',async()=>{const fake=(value)=>{let cursor=0;return{async read(target,offset,length){const bytesRead=Math.min(length,value.length-cursor);if(bytesRead>0)value.copy(target,offset,cursor,cursor+bytesRead);cursor+=bytesRead;return{bytesRead}}}};assert.equal((await readHandleBounded(fake(Buffer.from('abc')),3)).toString(),'abc');await assert.rejects(readHandleBounded(fake(Buffer.from('abcd')),3),/增长后超过已授权大小/);}); +test('audited file snapshots reject same-size identity or timestamp changes',()=>{const snapshot=(values={})=>({isFile:()=>true,dev:1,ino:2,size:3,mtimeMs:4,ctimeMs:5,...values});assert.equal(fileSnapshotMatches(snapshot(),snapshot()),true);assert.equal(fileSnapshotMatches(snapshot(),snapshot({ino:9})),false);assert.equal(fileSnapshotMatches(snapshot(),snapshot({ctimeMs:9})),false);}); +test('drops a whole deep branch when its directory changes in place after readdir',async()=>{ + const root=await fixture({'plugin.json':JSON.stringify({name:'x',version:'1',author:'a',main:'index.html',logo:'logo.svg',preload:'preload.cjs',platform:['darwin','win32','linux'],categories:['other'],features:[]}),'index.html':'ok','logo.svg':'x','preload.cjs':'bridge.scan = () => 1','deep/inside.js':'inside-safe'}); + const canonicalRoot=await fsRealpath(root),deep=path.join(canonicalRoot,'deep'),late=path.join(deep,'late.js'); + let mutated=false,openedDeep=false; + const r=await scanPlugin(root,{io:{ + readdir:async(directory,options)=>{ + const entries=await fsReaddir(directory,options); + if(!mutated&&path.resolve(directory)===path.resolve(deep)){await writeFile(late,'eval(1)');mutated=true} + return entries; + }, + lstat:async(target)=>{ + const info=await fsLstat(target); + if(!mutated||path.resolve(target)!==path.resolve(deep))return info; + return new Proxy(info,{get(value,key){if(key==='mtimeMs'||key==='ctimeMs')return Number(value[key])+1000;const item=Reflect.get(value,key,value);return typeof item==='function'?item.bind(value):item}}); + }, + open:async(target,flags)=>{if(path.resolve(target).startsWith(`${path.resolve(deep)}${path.sep}`))openedDeep=true;return fsOpen(target,flags)} + }}); + assert.equal(mutated,true); + assert.equal(openedDeep,false); + assert.ok(r.issues.some((finding)=>finding.code==='directory-race'&&finding.file==='deep')); + assert.equal(r.entries.some((entry)=>entry.rel==='deep/inside.js'||entry.rel==='deep/late.js'),false); + assert.equal(r.risks.some((finding)=>finding.file?.startsWith('deep/')),false); + assert.equal(JSON.stringify(r).includes('late.js'),false); +}); +test('drops every external entry when a deep directory is replaced before readdir',async()=>{const root=await fixture({'plugin.json':JSON.stringify({name:'x',version:'1',author:'a',main:'index.html',logo:'logo.svg',preload:'preload.cjs',platform:['darwin','win32','linux'],categories:['other'],features:[]}), 'index.html':'ok','logo.svg':'x','preload.cjs':'bridge.scan = () => 1','deep/inside.js':'inside'}),outside=await fixture({'outside-secret-marker.js':'eval(1)'}),canonicalRoot=await fsRealpath(root),deep=path.join(canonicalRoot,'deep'),backup=path.join(canonicalRoot,'deep-original');let swapped=false;const r=await scanPlugin(root,{io:{readdir:async(directory,options)=>{if(!swapped&&path.resolve(directory)===path.resolve(deep)){swapped=true;await rename(deep,backup);await symlink(outside,deep,process.platform==='win32'?'junction':'dir')}return fsReaddir(directory,options)}}});assert.equal(swapped,true);assert.ok(r.issues.some((finding)=>finding.code==='directory-race'));assert.equal(r.entries.some((entry)=>entry.rel.includes('outside-secret-marker')),false);assert.equal(r.risks.some((finding)=>finding.file?.includes('outside-secret-marker')),false);assert.equal(JSON.stringify(r).includes('outside-secret-marker'),false);}); +test('does not read and does not retain a file opened through a replaced parent directory',async()=>{const root=await fixture({'plugin.json':JSON.stringify({name:'x',version:'1',author:'a',main:'index.html',logo:'logo.svg',preload:'preload.cjs',platform:['darwin','win32','linux'],categories:['other'],features:[]}), 'index.html':'ok','logo.svg':'x','preload.cjs':'bridge.scan = () => 1','deep/target.js':'inside-safe'}),outside=await fixture({'target.js':'outside-secret-marker'}),canonicalRoot=await fsRealpath(root),deep=path.join(canonicalRoot,'deep'),backup=path.join(canonicalRoot,'deep-original'),target=path.join(deep,'target.js');let swapped=false,readCalled=false;const r=await scanPlugin(root,{io:{open:async(file,flags)=>{if(!swapped&&path.resolve(file)===path.resolve(target)){swapped=true;await rename(deep,backup);await symlink(outside,deep,process.platform==='win32'?'junction':'dir')}const handle=await fsOpen(file,flags);if(path.resolve(file)!==path.resolve(target))return handle;return{stat:(...args)=>handle.stat(...args),read:(...args)=>{readCalled=true;return handle.read(...args)},close:()=>handle.close()}}}});assert.equal(swapped,true);assert.equal(readCalled,false);assert.ok(r.issues.some((finding)=>finding.code==='directory-race'));assert.equal(r.risks.some((finding)=>finding.file?.startsWith('deep/')),false);assert.equal(r.entries.some((entry)=>entry.rel==='deep/target.js'),false);assert.equal(JSON.stringify(r).includes('outside-secret-marker'),false);}); +test('markdown report escapes untrusted file names and finding text',()=>{const value=toMarkdown({scannedAt:'now',files:1,bytes:1,issues:[{level:'high',code:'x`\n## forged',file:'bad`\n- injected.md',message:'line\n# fake'}],risks:[]});assert.ok(!value.includes('\n## forged'));assert.ok(!value.includes('\n- injected.md'));assert.ok(!value.includes('\n# fake'));}); +test('default human scan retains 5,000 findings, preserves totals, and marks copied reports truncated',async()=>{const hits=5001,root=await fixture({'plugin.json':JSON.stringify({name:'x',version:'1',author:'a',main:'index.html',logo:'logo.svg',preload:'preload.cjs',platform:['darwin','win32','linux'],categories:['other'],features:[]}), 'index.html':'ok','logo.svg':'x','preload.cjs':'bridge.unapproved = 1;\n'.repeat(hits)}),r=await scanPlugin(root);assert.equal(r.findingTotals.risks,hits);assert.equal(r.findingTotals.findings,hits);assert.equal(r.retainedFindings,5000);assert.equal(r.issues.length+r.risks.length,5000);assert.equal(r.findingsTruncated,true);const copied=toMarkdown(r);assert.match(copied,/已展示发现:5000 \/ 5001/);assert.match(copied,/证据已截断;汇总数量覆盖完整扫描结果。/);}); diff --git a/plugins/plugin-guard/test/manifest.test.mjs b/plugins/plugin-guard/test/manifest.test.mjs new file mode 100644 index 00000000..3792c7bd --- /dev/null +++ b/plugins/plugin-guard/test/manifest.test.mjs @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { access, readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +test('root manifest exposes directly loadable source entrypoints', async () => { + const manifest = JSON.parse(await readFile(path.join(root, 'plugin.json'), 'utf8')); + assert.equal(manifest.development, undefined); + assert.equal(manifest.main, 'src/ui/index.html'); + assert.equal(manifest.preload, 'preload/index.cjs'); + assert.equal(manifest.logo, 'logo.svg'); + for (const entry of [manifest.main, manifest.preload, manifest.logo]) await access(path.join(root, entry)); + assert.match(pathToFileURL(path.join(root, manifest.main)).href, /^file:/); +}); + +test('repository packaging invariants keep the built dist directory eligible', async () => { + const manifest = JSON.parse(await readFile(path.join(root, 'plugin.json'), 'utf8')); + const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')); + const buildScript = await readFile(path.join(root, 'scripts', 'build.mjs'), 'utf8'); + assert.equal(typeof packageJson.scripts?.build, 'string'); + assert.match(packageJson.scripts.build, /scripts\/build\.mjs/); + assert.equal(existsSync(path.join(root, path.dirname(manifest.main), 'plugin.json')), false); + for (const contract of ["delete m.development", "m.main='index.html'", "m.preload='preload/index.cjs'", "m.logo='logo.svg'"]) { + assert.ok(buildScript.includes(contract), `build script must preserve ${contract}`); + } +}); diff --git a/plugins/plugin-guard/test/mcp.test.mjs b/plugins/plugin-guard/test/mcp.test.mjs new file mode 100644 index 00000000..971d2deb --- /dev/null +++ b/plugins/plugin-guard/test/mcp.test.mjs @@ -0,0 +1,255 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { maskSecrets, toMarkdown } from '../src/core/guard.mjs'; + +const require = createRequire(import.meta.url); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const preload = require('../preload/index.cjs'); +const manifest = JSON.parse(await readFile(path.join(root, 'plugin.json'), 'utf8')); + +async function fixture(preloadSource = 'eval(1)') { + const directory = await mkdtemp(path.join(tmpdir(), 'guard-mcp-')); + await mkdir(path.join(directory, 'src')); + await writeFile(path.join(directory, 'plugin.json'), JSON.stringify({ name: 'safe', version: '1', author: 'a', main: 'index.html', preload: 'preload.cjs', logo: 'logo.svg', platform: ['darwin', 'win32', 'linux'], categories: ['other'], features: [] })); + await writeFile(path.join(directory, 'index.html'), 'ok'); + await writeFile(path.join(directory, 'preload.cjs'), preloadSource); + await writeFile(path.join(directory, 'logo.svg'), ''); + await writeFile(path.join(directory, 'src', 'token=abcdefghijklmnop.js'), 'ok'); + return directory; +} + +function highEscapingFindings(count = 400) { + return Array.from({ length: count }, (_, index) => `require('./item${String(index).padStart(3, '0')}-${'\\'.repeat(600)}')`).join('\n'); +} + +function pageMarkerIds(result, report) { + if (report === 'markdown') return [...result.markdown.matchAll(/item(\d{3})-/g)].map((match) => Number(match[1])); + return [...result.json.issues, ...result.json.risks].map((finding) => Number(finding.message.match(/item(\d{3})-/)?.[1])); +} + +test('manifest declaration and native registration stay one-to-one', () => { + const handlers = new Map(); + const target = { ztools: { registerTool(name, handler) { handlers.set(name, handler); } } }; + preload.attachPluginGuard(target); + assert.deepEqual([...handlers.keys()], Object.keys(manifest.tools)); + assert.equal(typeof handlers.get('scan_approved'), 'function'); + assert.equal(manifest.tools.scan_approved.inputSchema.properties.offset.maximum, 400); +}); + +test('older hosts keep the UI bridge without registering tools', () => { + const target = { ztools: {} }; + assert.doesNotThrow(() => preload.attachPluginGuard(target)); + assert.equal(typeof target.pluginGuard.choosePluginDirectory, 'function'); +}); + +test('registration failure does not block the human UI bridge', () => { + const target = { ztools: { registerTool() { throw Error('unsupported'); } } }; + assert.doesNotThrow(() => preload.attachPluginGuard(target)); + assert.equal(typeof target.pluginGuard.scan, 'function'); +}); + +test('agent scan requires a live human UI grant and rejects paths', async () => { + preload.__testClear(); + await assert.rejects(preload.scanApprovedForMcp({}), { code: 'WORKSPACE_APPROVAL_REQUIRED' }); + await assert.rejects(preload.scanApprovedForMcp({ path: '/tmp' }), { code: 'INVALID_TOOL_INPUT' }); + preload.__testGrant(await fixture(), 0); + await assert.rejects(preload.scanApprovedForMcp({}), { code: 'WORKSPACE_APPROVAL_REQUIRED' }); +}); + +test('real handler strips root and entries, masks values, and preserves totals', async () => { + const directory = await fixture(); + preload.__testGrant(directory); + const result = await preload.scanApprovedForMcp({ report: 'both', offset: 0, limit: 1 }); + assert.equal(Object.hasOwn(result.json, 'root'), false); + assert.equal(Object.hasOwn(result.json, 'entries'), false); + assert.equal(result.pagination.limit, 1); + assert.ok(result.totals.findings >= 1); + assert.match(result.markdown, /插件安全体检报告/); + assert.doesNotMatch(JSON.stringify(result), new RegExp(directory.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + assert.doesNotMatch(JSON.stringify(result), /abcdefghijklmnop/); +}); + +test('registered handler rejects a renamed-and-replaced approved directory before scanning it', async () => { + const handlers = new Map(); + preload.attachPluginGuard({ ztools: { registerTool(name, handler) { handlers.set(name, handler); } } }); + const directory = await fixture(); + const moved = `${directory}.old`; + preload.__testGrant(directory); + await rename(directory, moved); + await mkdir(directory); + await writeFile(path.join(directory, 'plugin.json'), JSON.stringify({ name: 'replacement-must-not-scan' })); + await assert.rejects(handlers.get('scan_approved')({}), (error) => { + assert.equal(error.code, 'APPROVED_DIRECTORY_UNAVAILABLE'); + assert.equal(error.message, '已授权的插件目录不可用,请在界面中重新选择。'); + assert.equal(error.message.includes(directory), false); + assert.equal(error.message.includes(moved), false); + assert.equal(error.message.includes('replacement-must-not-scan'), false); + return true; + }); + await assert.rejects(handlers.get('scan_approved')({}), { code: 'WORKSPACE_APPROVAL_REQUIRED' }); +}); + +test('human bridge applies the same directory-identity check before scanning', async () => { + const directory = await fixture(); + const moved = `${directory}.old`; + const target = { ztools: { showOpenDialog: async () => ({ filePaths: [directory] }) } }; + preload.attachPluginGuard(target); + await target.pluginGuard.choosePluginDirectory(); + await rename(directory, moved); + await mkdir(directory); + await assert.rejects(target.pluginGuard.scan(), (error) => { + assert.equal(error.code, 'APPROVED_DIRECTORY_UNAVAILABLE'); + assert.equal(error.message.includes(directory), false); + return true; + }); +}); + +test('plugin out expires a pending directory chooser and cannot restore MCP authorization', async () => { + const directory = await fixture(); + const handlers = new Map(); + let resolveDialog; + let onPluginOut; + const target = { ztools: { + showOpenDialog: () => new Promise((resolve) => { resolveDialog = resolve; }), + onPluginOut(listener) { onPluginOut = listener; }, + registerTool(name, handler) { handlers.set(name, handler); } + } }; + preload.attachPluginGuard(target); + const choosing = target.pluginGuard.choosePluginDirectory(); + onPluginOut(); + resolveDialog({ filePaths: [directory] }); + await assert.rejects(choosing, { code: 'SESSION_EXPIRED' }); + await assert.rejects(handlers.get('scan_approved')({}), { code: 'WORKSPACE_APPROVAL_REQUIRED' }); +}); + +test('registered handler sanitizes every credential-bearing manifest, message, and file field', async () => { + const handlers = new Map(); + preload.attachPluginGuard({ ztools: { registerTool(name, handler) { handlers.set(name, handler); } } }); + const github = `github_pat_${'A'.repeat(32)}`; + const ghp = `ghp_${'B'.repeat(36)}`; + const awsAccess = `AKIA${'C'.repeat(16)}`; + const awsSecret = 'D'.repeat(40); + const pemBody = 'E'.repeat(72); + const truncatedPem = `-----BEGIN PRIVATE KEY-----\n${pemBody}`; + const directory = await fixture(); + await writeFile(path.join(directory, 'plugin.json'), JSON.stringify({ + name: `${github} ${truncatedPem}`, + description: `Bearer ${'F'.repeat(24)}`, + version: `AWS_SECRET_ACCESS_KEY=${awsSecret}`, + author: 'a', + main: `${github}.html`, + preload: 'preload.cjs', + logo: 'logo.svg', + platform: ['darwin', 'win32', 'linux'], + categories: ['other'], + features: [{ code: awsAccess, explain: truncatedPem, cmds: ['scan'] }] + })); + await writeFile(path.join(directory, `${ghp}.js`), `eval(1); const key = '${awsAccess}';`); + preload.__testGrant(directory); + const result = await handlers.get('scan_approved')({ report: 'both', limit: 200 }); + const serialized = JSON.stringify(result); + for (const secret of [github, ghp, awsAccess, awsSecret, pemBody]) assert.equal(serialized.includes(secret), false); + assert.equal(serialized.includes('BEGIN PRIVATE KEY'), false); + assert.equal(serialized.includes('AWS_SECRET_ACCESS_KEY'), false); + assert.match(serialized, /redacted/i); +}); + +test('50,000 findings keep full totals while retaining and returning bounded evidence', async () => { + const hitCount = 50001; + const directory = await fixture('bridge.unapproved = 1;\n'.repeat(hitCount)); + preload.__testGrant(directory); + const result = await preload.scanApprovedForMcp({ report: 'json', offset: 200, limit: 200 }); + assert.equal(result.totals.risks, hitCount); + assert.equal(result.totals.findings, hitCount); + assert.ok(result.totals.retainedFindings <= 400); + assert.ok(result.json.issues.length + result.json.risks.length <= 200); + assert.ok(result.pagination.returned <= 200); + assert.equal(result.pagination.nextOffset, 400); + assert.equal(result.pagination.hasMore, false); +}); + +test('registered handler paginates every retained finding across json, markdown, and both under budget', async () => { + const handlers = new Map(); + preload.attachPluginGuard({ ztools: { registerTool(name, handler) { handlers.set(name, handler); } } }); + const directory = await fixture(highEscapingFindings()); + preload.__testGrant(directory); + const handler = handlers.get('scan_approved'); + const sawBudgetReduction = Object.create(null); + for (const report of ['json', 'markdown', 'both']) { + const ids = []; + let offset = 0; + while (true) { + const result = await handler({ report, offset, limit: 200 }); + assert.ok(Buffer.byteLength(JSON.stringify(result), 'utf8') <= preload.MCP_RESPONSE_BYTES); + assert.equal(result.totals.findings, 400); + assert.equal(result.totals.risks, 400); + assert.equal(result.totals.retainedFindings, 400); + assert.equal(result.pagination.offset, offset); + assert.equal(result.pagination.nextOffset, offset + result.pagination.returned); + assert.equal(result.responseTruncated, result.pagination.returned < Math.min(200, Math.max(0, 400 - offset))); + sawBudgetReduction[report] ||= result.responseTruncated; + const pageIds = pageMarkerIds(result, report); + assert.equal(pageIds.length, result.pagination.returned); + assert.equal(pageIds.every(Number.isInteger), true); + ids.push(...pageIds); + if (!result.pagination.hasMore) { + assert.equal(result.pagination.nextOffset, 400); + break; + } + assert.ok(result.pagination.returned > 0); + offset = result.pagination.nextOffset; + } + assert.equal(ids.length, 400); + assert.equal(new Set(ids).size, 400); + assert.deepEqual([...ids].sort((left, right) => left - right), Array.from({ length: 400 }, (_, index) => index)); + const terminal = await handler({ report, offset: 400, limit: 200 }); + assert.ok(Buffer.byteLength(JSON.stringify(terminal), 'utf8') <= preload.MCP_RESPONSE_BYTES); + assert.equal(terminal.pagination.returned, 0); + assert.equal(terminal.pagination.nextOffset, 400); + assert.equal(terminal.pagination.hasMore, false); + assert.equal(terminal.responseTruncated, false); + assert.equal(terminal.totals.findings, 400); + } + assert.equal(sawBudgetReduction.both, true); +}); + +test('maximum report=both page stays under the serialized response budget', () => { + const finding = { level: 'high', code: 'bounded', message: '密'.repeat(1000), file: '\\'.repeat(2000) }; + const report = { manifest: { name: 'safe', version: '1', features: [] }, manifestFeatureTotal: 0, files: 1, bytes: 1, scannedAt: 'now', issues: Array.from({ length: 400 }, () => finding), risks: [], findingTotals: { issues: 400, risks: 0, findings: 400 } }; + const result = preload.buildMcpResponse(report, { report: 'both', offset: 0, limit: 200 }, { maskSecrets, toMarkdown }); + assert.ok(Buffer.byteLength(JSON.stringify(result), 'utf8') <= preload.MCP_RESPONSE_BYTES); + assert.equal(result.totals.findings, 400); + assert.equal(result.json.totals.findings, 400); + assert.equal(result.responseTruncated, true); + assert.equal(result.pagination.responseTruncated, true); + assert.equal(result.json.pagination.responseTruncated, true); + assert.equal(result.pagination.hasMore, true); + assert.ok(result.pagination.returned < result.pagination.requestedLimit); +}); + +test('deleted approved directory revokes grant and returns a stable path-free error', async () => { + const directory = await fixture(); + preload.__testGrant(directory); + await rm(directory, { recursive: true, force: true }); + await assert.rejects(preload.scanApprovedForMcp({}), (error) => { + assert.equal(error.code, 'APPROVED_DIRECTORY_UNAVAILABLE'); + assert.equal(error.message, '已授权的插件目录不可用,请在界面中重新选择。'); + assert.equal(error.message.includes(directory), false); + return true; + }); + await assert.rejects(preload.scanApprovedForMcp({}), { code: 'WORKSPACE_APPROVAL_REQUIRED' }); +}); + +test('strict validation bounds report pagination and hostile keys', async () => { + await assert.rejects(preload.scanApprovedForMcp({ limit: 201 }), { code: 'INVALID_TOOL_INPUT' }); + await assert.rejects(preload.scanApprovedForMcp({ offset: -1 }), { code: 'INVALID_TOOL_INPUT' }); + await assert.rejects(preload.scanApprovedForMcp({ offset: 401 }), { code: 'INVALID_TOOL_INPUT' }); + await assert.rejects(preload.scanApprovedForMcp({ report: 'html' }), { code: 'INVALID_TOOL_INPUT' }); + await assert.rejects(preload.scanApprovedForMcp(JSON.parse('{"__proto__":{}}')), { code: 'INVALID_TOOL_INPUT' }); + await assert.rejects(preload.scanApprovedForMcp(Object.create({ report: 'json' })), { code: 'INVALID_TOOL_INPUT' }); +}); diff --git a/plugins/plugin-guard/test/renderer-bridge.test.mjs b/plugins/plugin-guard/test/renderer-bridge.test.mjs new file mode 100644 index 00000000..570a397d --- /dev/null +++ b/plugins/plugin-guard/test/renderer-bridge.test.mjs @@ -0,0 +1,3 @@ +import test from 'node:test';import assert from 'node:assert/strict';import {readFile} from 'node:fs/promises';import path from 'node:path';import {fileURLToPath} from 'node:url'; +const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'); +test('renderer has no Node core import, localizes human findings, and bridge remains narrow',async()=>{const ui=await readFile(path.join(root,'src','ui','app.mjs'),'utf8'),preload=await readFile(path.join(root,'preload','index.cjs'),'utf8'),html=await readFile(path.join(root,'src','ui','index.html'),'utf8'),logo=await readFile(path.join(root,'logo.svg'),'utf8');assert.ok(!/node:|\.\.\/core\//.test(ui));assert.ok(!ui.includes('innerHTML'));assert.match(ui,/function humanText/);assert.match(ui,/high:'高',medium:'中',low:'低'/);assert.match(ui,/symlink:'符号链接'/);assert.match(ui,/'manifest-field':'清单字段'/);assert.match(ui,/'feature-trigger':'功能触发器'/);assert.match(ui,/humanFindingCode\(finding\.code\)/);assert.match(ui,/功能代码:\$\{last\.manifest\.features/);assert.match(ui,/JSON\.stringify\(last,null,2\)/);assert.match(ui,/已展示 \$\{counts\.shown\.toLocaleString\(\)\} \/ \$\{counts\.total\.toLocaleString\(\)\} 项发现/);assert.match(ui,/证据已截断;汇总数量覆盖完整扫描结果。/);assert.match(html,/lang="zh-CN"/);assert.match(logo,/aria-label="插件安全体检"/);assert.doesNotMatch(logo,/aria-label="Plugin Guard"/);assert.match(preload,/Object\.freeze\(bridge\)/);assert.ok(!/require\(['\"]node:child_process/.test(preload));});