本地脱敏工作台
分享前脱敏
图片脱敏画布
在图片上拖动即可添加永久遮罩。导出时会重新编码像素并移除元数据。
diff --git a/plugins/share-sanitizer/.gitignore b/plugins/share-sanitizer/.gitignore new file mode 100644 index 00000000..1eae0cf6 --- /dev/null +++ b/plugins/share-sanitizer/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/plugins/share-sanitizer/CHANGELOG.md b/plugins/share-sanitizer/CHANGELOG.md new file mode 100644 index 00000000..ff3308ca --- /dev/null +++ b/plugins/share-sanitizer/CHANGELOG.md @@ -0,0 +1,10 @@ +# 更新日志 + +## 0.1.0 + +- 提供本地优先的文本与图片脱敏工作台。 +- 为 ZTools 2.4+ 新增有界的原生 MCP 检测与脱敏工具,并在旧版宿主上安全降级。 +- 将脱敏调整为“全部完成或整体拒绝”;超过 500 项的预览会报告明确的截断总数,并对序列化 MCP 响应执行 512 KiB 上限。 +- 根目录改用源文件入口,修复相对 `development.main` 无法被 ZTools 正确加载的问题,并增加 14.5 MB 发布体积安全门禁。 +- 对齐 ZTools 宿主的 `over`、`img` 和 `files` 启动载荷契约,选中的文件系统路径始终保留在 preload 边界内。 +- 将人类界面、状态提示、确认对话框和入口错误提示完整本地化为简体中文。 diff --git a/plugins/share-sanitizer/README.md b/plugins/share-sanitizer/README.md new file mode 100644 index 00000000..39b2b498 --- /dev/null +++ b/plugins/share-sanitizer/README.md @@ -0,0 +1,31 @@ +# 分享脱敏台 + +界面、状态提示、确认对话框和图片导出提示均使用简体中文;MCP 工具名、schema 字段和错误码保持稳定,便于 Agent 调用。 + +这是一款本地优先的分享前审查工具,用于在文本或图片对外分享前移除敏感数据。 + +它可检测电子邮箱、中国手机号和身份证号、IPv4 地址、绝对路径、Bearer/API 凭据以及 JWT。用户可关闭特定规则,也可将已确认安全的值加入白名单。图片会绘制到新的画布(`canvas`),从而移除原图元数据;用户还可手动添加矩形遮罩。浏览器 OCR 是明确的可选能力:当 `TextDetector` 不可用时,界面会明确说明,不会声称 OCR 已完成。 + +## 安全与跨平台 + +- 检测器由纯 JavaScript 实现,会在不依赖当前宿主操作系统的情况下测试 POSIX、Windows 和 Linux 路径形式。 +- 精简的 preload 桥仅在宿主支持时暴露剪贴板操作 `copyText` 和 `copyImage`,并且只会从 `onPluginEnter` 转发通过校验的文本或图片内容;渲染层不会获得文件系统路径。 +- ZTools 的 `over`、`img` 和单文件载荷会按类型处理。宿主选择的文本文件最多按 UTF-8 解码 1 MiB,图片最多进行 20 MiB 的签名检查;符号链接、目录、不支持的扩展名或选择后发生变化的文件均会按失败关闭处理。 +- 人工输入上限为 1 MiB。检测预览最多展示 500 个发现项,并明确标记预览已截断;完整脱敏最多处理 10,000 个匹配,超过上限时会拒绝整个操作,而不是返回只完成一部分的结果。 +- 不使用网络、外部字体、原生附加组件或 shell 命令。 + +## Agent / MCP 使用 + +ZTools 2.4 及更高版本可将清单中的 `detect_text` 和 `redact_text` 工具暴露给 Agent,完整名称分别为 `share_sanitizer_detect_text` 和 `share_sanitizer_redact_text`。两者与人工界面共用同一套本地检测器。检测输入的 UTF-8 上限为 512 KiB;脱敏输入上限为 448 KiB,以确保普通输出与有界证据之和不超过宿主边界。白名单最多 200 项、总大小最多 64 KiB;替换文本最多 64 个字符。替换文本自身必须安全,拼接入原文后的完整输出也会按同一启用规则和白名单再次检查;任一非白名单敏感值都会拒绝整个请求,避免借由边界拼接把刚移除的敏感值重新写回输出。校验后的序列化输入还有 768 KiB 的硬上限。未知字段、规则名、非布尔规则开关以及尝试改写内部上限的输入均会被拒绝。 + +检测发现项只包含 `type`、`start`、`end` 和 `confidence`,以及汇总计数;匹配到的敏感原值绝不会出现在发现项中。Agent 证据最多保留 500 项,并提供 `counts.total`、`counts.returned` 和 `truncated`;`counts.byType` 描述已返回的证据。当检测扫描被截断时,`counts.total` 为 `null`,`counts.totalAtLeast` 表示已证明的数量下界。脱敏必须处理完所有匹配后才会返回,并报告精确总数;超过 10,000 个匹配的完整脱敏会被拒绝。最终 MCP 结果的序列化上限为 512 KiB,因此替换或 JSON 转义导致的体积膨胀会被拒绝,而不会产生数 MiB 的响应。 + +脱敏工具会返回清理后的文本,白名单中的值则会按设计原样保留。因此调用方和 Agent 必须控制清理后文本的传播范围;发现项仍不会回显白名单项或匹配原值。旧版 ZTools 宿主没有 `registerTool` 时,人工界面仍可使用,但不会提供 MCP 工具。 + +运行 `npm test`、`npm run build` 和 `npm run verify-dist` 进行验证。在实体 Windows/macOS/Linux ZTools 宿主中的运行时加载仍待测试。 + +## 本地 ZTools 开发 + +在 ZTools 开发者工具中添加根目录的 `plugin.json`。其 `main`、`preload` 和 `logo` 均指向可读的源文件,因此插件可通过 ZTools 的文件 URL 加载器直接打开。不要添加相对路径的 `development.main`:当前 ZTools 版本会将该字段直接传给 `loadURL`,而相对文件系统路径并不是有效 URL。仓库构建仍会生成并打包可独立发布的 `dist/` 清单。 + +`npm run build` 还会对未压缩的 `dist/` 执行 14.5 MB(14,500,000 字节)安全门禁,为 15 MB 的插件大小限制预留打包开销。 diff --git a/plugins/share-sanitizer/logo.svg b/plugins/share-sanitizer/logo.svg new file mode 100644 index 00000000..13e3c48f --- /dev/null +++ b/plugins/share-sanitizer/logo.svg @@ -0,0 +1 @@ + diff --git a/plugins/share-sanitizer/package-lock.json b/plugins/share-sanitizer/package-lock.json new file mode 100644 index 00000000..ad296542 --- /dev/null +++ b/plugins/share-sanitizer/package-lock.json @@ -0,0 +1,7 @@ +{ + "name": "share-sanitizer", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": {"": {"name": "share-sanitizer", "version": "0.1.0", "engines": {"node": ">=16"}}} +} diff --git a/plugins/share-sanitizer/package.json b/plugins/share-sanitizer/package.json new file mode 100644 index 00000000..d4eaf21d --- /dev/null +++ b/plugins/share-sanitizer/package.json @@ -0,0 +1,14 @@ +{ + "name": "share-sanitizer", + "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/share-sanitizer/plugin.json b/plugins/share-sanitizer/plugin.json new file mode 100644 index 00000000..89adfd73 --- /dev/null +++ b/plugins/share-sanitizer/plugin.json @@ -0,0 +1,72 @@ +{ + "name": "share-sanitizer", + "title": "分享前脱敏", + "version": "0.1.0", + "description": "在分享前检查并脱敏敏感文本和图片。", + "author": "harris", + "main": "src/ui/index.html", + "logo": "logo.svg", + "preload": "preload/index.cjs", + "platform": ["darwin", "win32", "linux"], + "categories": ["productivity", "text", "media"], + "features": [{"code": "sanitize-share", "explain": "在分享前脱敏选中的文本、文件或图片。", "icon": "logo.svg", "cmds": ["分享脱敏", "敏感信息清理", {"type":"files","fileType":"file","label":"脱敏文件","extensions":["txt","md","json","log","png","jpg","jpeg","webp"],"maxLength":1}, {"type":"img","label":"脱敏图片"}, {"type":"over","label":"脱敏选中文字","minLength":1,"maxLength":200000}]}], + "tools": { + "detect_text": { + "title": "检测分享文本中的敏感信息", + "description": "离线检测文本并仅返回不含原值的类型、位置、置信度和统计。", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "text": {"type": "string", "maxLength": 524288}, + "whitelist": {"type": "array", "maxItems": 200, "items": {"type": "string", "maxLength": 4096}}, + "enabled": { + "type": "object", + "additionalProperties": false, + "properties": { + "email": {"type": "boolean"}, + "phone_cn": {"type": "boolean"}, + "ipv4": {"type": "boolean"}, + "win_path": {"type": "boolean"}, + "posix_path": {"type": "boolean"}, + "bearer": {"type": "boolean"}, + "api_key": {"type": "boolean"}, + "jwt": {"type": "boolean"}, + "id_cn": {"type": "boolean"} + } + } + }, + "required": ["text"] + } + }, + "redact_text": { + "title": "脱敏分享文本", + "description": "离线替换敏感片段,并返回清理后的文本及不含原值的发现统计;replacement 与最终拼接输出均不得包含任何启用规则可识别的非白名单敏感值。", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "text": {"type": "string", "maxLength": 458752}, + "replacement": {"type": "string", "maxLength": 64, "description": "可选安全替换文本;若自身或与上下文拼接后的输出命中任一启用规则且不在白名单中,则拒绝整个请求。"}, + "whitelist": {"type": "array", "maxItems": 200, "items": {"type": "string", "maxLength": 4096}}, + "enabled": { + "type": "object", + "additionalProperties": false, + "properties": { + "email": {"type": "boolean"}, + "phone_cn": {"type": "boolean"}, + "ipv4": {"type": "boolean"}, + "win_path": {"type": "boolean"}, + "posix_path": {"type": "boolean"}, + "bearer": {"type": "boolean"}, + "api_key": {"type": "boolean"}, + "jwt": {"type": "boolean"}, + "id_cn": {"type": "boolean"} + } + } + }, + "required": ["text"] + } + } + } +} diff --git a/plugins/share-sanitizer/preload/index.cjs b/plugins/share-sanitizer/preload/index.cjs new file mode 100644 index 00000000..8237cc25 --- /dev/null +++ b/plugins/share-sanitizer/preload/index.cjs @@ -0,0 +1,302 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); +const { TextDecoder } = require('node:util'); + +const TOOL_NAMES = Object.freeze({ detect: 'detect_text', redact: 'redact_text' }); +const RULE_NAMES = Object.freeze(['email', 'phone_cn', 'ipv4', 'win_path', 'posix_path', 'bearer', 'api_key', 'jwt', 'id_cn']); +const RULE_SET = new Set(RULE_NAMES); +const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']); +const MCP_TEXT_BYTES = 512 * 1024; +const MCP_REDACT_TEXT_BYTES = 448 * 1024; +const MCP_INPUT_BYTES = 768 * 1024; +const MCP_RESPONSE_BYTES = 512 * 1024; +const MCP_FINDING_LIMIT = 500; +const MAX_WHITELIST = 200; +const MAX_WHITELIST_ITEM_BYTES = 4096; +const MAX_WHITELIST_BYTES = 64 * 1024; +const ENTRY_TEXT_BYTES = 1024 * 1024; +const ENTRY_IMAGE_BYTES = 20 * 1024 * 1024; +const ENTRY_TEXT_EXTENSIONS = new Set(['.txt', '.md', '.json', '.log']); +const ENTRY_IMAGE_TYPES = new Map([ + ['.png', 'image/png'], + ['.jpg', 'image/jpeg'], + ['.jpeg', 'image/jpeg'], + ['.webp', 'image/webp'] +]); +const registeredHosts = new WeakSet(); +const entryRelays = new WeakMap(); +let corePromise; + +function invalid(message) { + return Object.assign(new TypeError(message), { code: 'INVALID_TOOL_INPUT' }); +} + +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 validateText(value, maxBytes) { + if (typeof value !== 'string') throw invalid('text 必须是字符串。'); + if (Buffer.byteLength(value, 'utf8') > maxBytes) throw invalid(`text 超过 ${maxBytes / 1024} KiB 的 MCP 上限。`); + return value; +} + +function validateWhitelist(value) { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length > MAX_WHITELIST) throw invalid('whitelist 最多只能包含 200 个字符串。'); + let totalBytes = 0; + for (const item of value) { + if (typeof item !== 'string' || Buffer.byteLength(item, 'utf8') > MAX_WHITELIST_ITEM_BYTES) throw invalid('whitelist 包含无效值。'); + totalBytes += Buffer.byteLength(item, 'utf8'); + } + if (totalBytes > MAX_WHITELIST_BYTES) throw invalid('whitelist 超过 64 KiB 的 MCP 上限。'); + return value.slice(); +} + +function validateEnabled(value) { + if (value === undefined) return undefined; + validateObject(value, RULE_SET, 'enabled'); + const enabled = {}; + for (const key of Object.keys(value)) { + if (typeof value[key] !== 'boolean') throw invalid(`enabled.${key} 必须是布尔值。`); + enabled[key] = value[key]; + } + return enabled; +} + +function validateInput(input, allowReplacement) { + const allowed = new Set(['text', 'whitelist', 'enabled', ...(allowReplacement ? ['replacement'] : [])]); + validateObject(input, allowed, '工具输入'); + const output = { text: validateText(input.text, allowReplacement ? MCP_REDACT_TEXT_BYTES : MCP_TEXT_BYTES), whitelist: validateWhitelist(input.whitelist), enabled: validateEnabled(input.enabled) }; + if (allowReplacement && input.replacement !== undefined) { + if (typeof input.replacement !== 'string' || Array.from(input.replacement).length > 64) throw invalid('replacement 必须是不超过 64 个字符的字符串。'); + output.replacement = input.replacement; + } + if (Buffer.byteLength(JSON.stringify(output), 'utf8') > MCP_INPUT_BYTES) throw invalid('工具输入超过 768 KiB 的 MCP 序列化上限。'); + return output; +} + +function loadCore() { + if (!corePromise) { + const packaged = path.join(__dirname, '..', 'core', 'sanitize.mjs'); + const corePath = fs.existsSync(packaged) ? packaged : path.join(__dirname, '..', 'src', 'core', 'sanitize.mjs'); + corePromise = import(pathToFileURL(corePath).href); + } + return corePromise; +} + +function safeFindings(findings, metadata = {}) { + const selected = findings.slice(0, MCP_FINDING_LIMIT); + const byType = Object.create(null); + const safe = selected.map(({ type, start, end, confidence }) => { + byType[type] = (byType[type] || 0) + 1; + return { type, start, end, confidence }; + }); + const hasTotal = Object.hasOwn(metadata, 'total'); + const total = hasTotal ? metadata.total : findings.length; + const truncated = Boolean(metadata.truncated || findings.length > safe.length); + const counts = { total, returned: safe.length, byType }; + if (total === null) counts.totalAtLeast = Number.isSafeInteger(metadata.totalAtLeast) ? metadata.totalAtLeast : safe.length; + return { findings: safe, counts, truncated }; +} + +function enforceResponseBudget(result) { + let serialized; + try { serialized = JSON.stringify(result); } catch { throw invalid('工具响应无法安全序列化。'); } + if (Buffer.byteLength(serialized, 'utf8') > MCP_RESPONSE_BYTES) throw invalid('工具响应超过 512 KiB 的 MCP 上限;请缩短 text 或 replacement。'); + return result; +} + +async function detectForMcp(input) { + const value = validateInput(input, false); + const { inspectSensitive } = await loadCore(); + const result = inspectSensitive(value.text, { whitelist: value.whitelist, enabled: value.enabled, maxFindings: MCP_FINDING_LIMIT }); + return enforceResponseBudget(safeFindings(result.findings, result)); +} + +async function redactForMcp(input) { + const value = validateInput(input, true); + const { redactText } = await loadCore(); + let result; + try { + result = redactText(value.text, { whitelist: value.whitelist, enabled: value.enabled, ...(value.replacement === undefined ? {} : { replacement: value.replacement }) }); + } catch (error) { + if (error instanceof RangeError) throw invalid(error.message); + throw error; + } + return enforceResponseBudget({ text: result.text, ...safeFindings(result.findings, result) }); +} + +function isPlainObject(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function validateImageDataUrl(value) { + if (typeof value !== 'string') throw invalid('图片入口必须是 data URL。'); + const match = /^data:(image\/(?:png|jpeg|webp));base64,([A-Za-z0-9+/]*={0,2})$/i.exec(value); + if (!match) throw invalid('图片入口必须是 PNG、JPEG 或 WebP 的 data URL。'); + if (match[2].length > Math.ceil(ENTRY_IMAGE_BYTES / 3) * 4 + 4) throw invalid('图片入口超过 20 MiB 上限。'); + const bytes = Buffer.from(match[2], 'base64'); + if (bytes.length === 0 || bytes.length > ENTRY_IMAGE_BYTES) throw invalid('图片入口超过 20 MiB 上限。'); + if (!matchesImageSignature(bytes, match[1].toLowerCase())) throw invalid('图片数据与声明的类型不匹配。'); + return value; +} + +function matchesImageSignature(buffer, mime) { + if (mime === 'image/png') return buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); + if (mime === 'image/jpeg') return buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff; + return buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP'; +} + +function fileSnapshotMatches(left, right) { + if (!left || !right || !left.isFile() || !right.isFile()) return false; + return ['dev', 'ino', 'size', 'mtimeMs', 'ctimeMs'].every((key) => Object.is(left[key], right[key])); +} + +async function readHandleBounded(handle, limit) { + if (!Number.isSafeInteger(limit) || limit < 0) throw invalid('文件读取上限无效。'); + const buffer = Buffer.allocUnsafe(limit + 1); + let offset = 0; + while (offset < buffer.length) { + const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, null); + if (bytesRead === 0) break; + offset += bytesRead; + } + if (offset > limit) throw invalid('所选文件超过大小上限。'); + return buffer.subarray(0, offset); +} + +async function readHostSelectedFile(file) { + if (!isPlainObject(file) || file.isFile !== true || file.isDirectory === true) throw invalid('所选入口必须是文件。'); + if (typeof file.path !== 'string' || file.path.length === 0 || file.path.length > 4096 || file.path.includes('\0') || !path.isAbsolute(file.path)) throw invalid('所选文件路径无效。'); + if (typeof file.name !== 'string' || file.name.length === 0 || file.name.length > 512 || path.basename(file.path) !== file.name) throw invalid('所选文件名无效。'); + const extension = path.extname(file.name).toLowerCase(); + const mime = ENTRY_IMAGE_TYPES.get(extension); + if (!mime && !ENTRY_TEXT_EXTENSIONS.has(extension)) throw invalid('不支持所选文件类型。'); + const limit = mime ? ENTRY_IMAGE_BYTES : ENTRY_TEXT_BYTES; + const before = await fs.promises.lstat(file.path); + if (!before.isFile() || before.isSymbolicLink() || before.size > limit) throw invalid('所选文件不可用或过大。'); + const noFollow = Number.isInteger(fs.constants.O_NOFOLLOW) ? fs.constants.O_NOFOLLOW : 0; + let handle; + let content; + let opened; + let openedAfter; + try { + handle = await fs.promises.open(file.path, fs.constants.O_RDONLY | noFollow); + opened = await handle.stat(); + if (!fileSnapshotMatches(before, opened) || opened.size > limit) throw invalid('所选文件在读取前已发生变化。'); + content = await readHandleBounded(handle, limit); + openedAfter = await handle.stat(); + } finally { + await handle?.close(); + } + const after = await fs.promises.lstat(file.path); + if (after.isSymbolicLink() || !fileSnapshotMatches(opened, openedAfter) || !fileSnapshotMatches(openedAfter, after) || content.length > limit) throw invalid('所选文件在读取过程中发生变化。'); + if (mime) { + if (!matchesImageSignature(content, mime)) throw invalid('所选图片的文件签名与扩展名不匹配。'); + return { kind: 'image', dataUrl: `data:${mime};base64,${content.toString('base64')}` }; + } + return { kind: 'text', text: new TextDecoder('utf-8', { fatal: true }).decode(content) }; +} + +async function consumeHostEntry(action) { + if (!isPlainObject(action)) throw invalid('插件入口参数无效。'); + if (action.type === 'over') { + if (typeof action.payload !== 'string' || Buffer.byteLength(action.payload, 'utf8') > ENTRY_TEXT_BYTES) throw invalid('所选文本超过 1 MiB 上限。'); + return { kind: 'text', text: action.payload }; + } + if (action.type === 'img') return { kind: 'image', dataUrl: validateImageDataUrl(action.payload) }; + if (action.type === 'files') { + if (!Array.isArray(action.payload) || action.payload.length !== 1) throw invalid('必须且只能选择一个文件。'); + return readHostSelectedFile(action.payload[0]); + } + return null; +} + +function createEntryRelay(api) { + // ZTools lifecycle hooks return void. A preload can outlive individual + // plugin visits, so subscribe exactly once here rather than treating their + // return values as disposers or subscribing again after every `out` event. + const state = { callback: null, pending: null, token: 0 }; + const deliver = (entry, token = state.token) => { + if (!entry || token !== state.token) return; + if (!state.callback) { state.pending = entry; return; } + try { state.callback(entry); } catch {} + }; + const clearForExit = () => { + state.token += 1; + state.pending = null; + // Keeping a callback that has no sensitive state is safe and lets a + // persistent renderer receive later entries without a second host hook. + try { state.callback?.({ kind: 'reset' }); } catch {} + }; + if (typeof api.onPluginEnter === 'function') { + try { + api.onPluginEnter((action) => { + const token = state.token; + Promise.resolve(consumeHostEntry(action)).then( + (entry) => deliver(entry, token), + () => deliver({ kind: 'error', message: '无法安全载入插件入口数据。' }, token) + ); + }); + } catch {} + } + if (typeof api.onPluginOut === 'function') { + try { api.onPluginOut(clearForExit); } catch {} + } + return (next) => { + if (typeof next !== 'function') throw new TypeError('入口回调必须是函数。'); + state.callback = next; + if (state.pending) { + const entry = state.pending; + state.pending = null; + const token = state.token; + queueMicrotask(() => deliver(entry, token)); + } + return () => { if (state.callback === next) state.callback = null; }; + }; +} + +function registerTools(target) { + const api = target?.ztools; + if (!api || typeof api.registerTool !== 'function' || registeredHosts.has(api)) return false; + let registered = false; + for (const [name, handler] of [[TOOL_NAMES.detect, detectForMcp], [TOOL_NAMES.redact, redactForMcp]]) { + try { api.registerTool.call(api, name, handler); registered = true; } catch {} + } + registeredHosts.add(api); + return registered; +} + +function attachShareSanitizer(target) { + if (!target || (typeof target !== 'object' && typeof target !== 'function')) throw new TypeError('需要一个类 window 目标。'); + const api = target.ztools || {}; + let onEntry = entryRelays.get(api); + if (!onEntry) { + onEntry = createEntryRelay(api); + if (api && (typeof api === 'object' || typeof api === 'function')) entryRelays.set(api, onEntry); + } + const bridge = {}; + if (typeof api.copyText === 'function') bridge.copyText = (text) => api.copyText(String(text)); + if (typeof api.copyImage === 'function') bridge.copyImage = (dataUrl) => api.copyImage(String(dataUrl)); + bridge.onEntry = onEntry; + target.shareSanitizer = Object.freeze(bridge); + registerTools(target); + return target.shareSanitizer; +} + +if (typeof globalThis !== 'undefined') attachShareSanitizer(globalThis); + +module.exports = { TOOL_NAMES, MCP_TEXT_BYTES, MCP_REDACT_TEXT_BYTES, MCP_INPUT_BYTES, MCP_RESPONSE_BYTES, MCP_FINDING_LIMIT, MAX_WHITELIST_BYTES, ENTRY_TEXT_BYTES, ENTRY_IMAGE_BYTES, validateInput, safeFindings, enforceResponseBudget, validateImageDataUrl, fileSnapshotMatches, readHandleBounded, readHostSelectedFile, consumeHostEntry, detectForMcp, redactForMcp, registerTools, attachShareSanitizer }; diff --git a/plugins/share-sanitizer/preload/package.json b/plugins/share-sanitizer/preload/package.json new file mode 100644 index 00000000..5bbefffb --- /dev/null +++ b/plugins/share-sanitizer/preload/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/plugins/share-sanitizer/scripts/build.mjs b/plugins/share-sanitizer/scripts/build.mjs new file mode 100644 index 00000000..abafd325 --- /dev/null +++ b/plugins/share-sanitizer/scripts/build.mjs @@ -0,0 +1,8 @@ +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)), '..'); const 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/sanitize.mjs'", "'./core/sanitize.mjs'")); +const manifest=JSON.parse(await readFile(path.join(root,'plugin.json'),'utf8')); delete manifest.development; manifest.main='index.html'; manifest.logo='logo.svg'; manifest.preload='preload/index.cjs'; await writeFile(path.join(dist,'plugin.json'),JSON.stringify(manifest,null,2)+'\n'); diff --git a/plugins/share-sanitizer/scripts/dist-size.mjs b/plugins/share-sanitizer/scripts/dist-size.mjs new file mode 100644 index 00000000..5b9cea9c --- /dev/null +++ b/plugins/share-sanitizer/scripts/dist-size.mjs @@ -0,0 +1,45 @@ +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); + + // Dirent rejects links and special files before following anything. lstat + // closes the race/type-mismatch gap and deliberately never follows links. + 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/share-sanitizer/scripts/verify-dist.mjs b/plugins/share-sanitizer/scripts/verify-dist.mjs new file mode 100644 index 00000000..d69fcf7c --- /dev/null +++ b/plugins/share-sanitizer/scripts/verify-dist.mjs @@ -0,0 +1,20 @@ +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/sanitize.mjs']) await access(path.join(dist, file)); +for (const tool of ['detect_text', 'redact_text']) if (!manifest.tools?.[tool]) throw new Error(`missing MCP tool ${tool}`); +const app = await readFile(path.join(dist, 'app.mjs'), 'utf8'); +if (app.includes('../core/')) throw new Error('dist UI import escaped package'); +const sourcePreload = await readFile(path.join(root, 'preload', 'index.cjs'), 'utf8'); +const distPreload = await readFile(path.join(dist, 'preload', 'index.cjs'), 'utf8'); +if (sourcePreload !== distPreload) throw new Error('dist preload is stale'); +const distBytes = await directoryBytes(dist); +assertDistSize(distBytes); +console.log(`share-sanitizer dist verified (${distBytes} bytes)`); diff --git a/plugins/share-sanitizer/src/core/sanitize.mjs b/plugins/share-sanitizer/src/core/sanitize.mjs new file mode 100644 index 00000000..1cbc03df --- /dev/null +++ b/plugins/share-sanitizer/src/core/sanitize.mjs @@ -0,0 +1,140 @@ +const MAX_INPUT = 1024 * 1024; +const MAX_FINDINGS = 500; +const MAX_REDACTION_FINDINGS = 10_000; +const RULES = { + email: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,63}\b/gi, + phone_cn: /(?:"|?*\s\r\n]*/g, + posix_path: /(? [key, true]))); + +function chineseIdValid(value) { + if (!/^\d{17}[\dXx]$/.test(value)) return false; + const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]; + const check = '10X98765432'; + let sum = 0; + for (let i = 0; i < 17; i += 1) sum += Number(value[i]) * weights[i]; + return check[sum % 11].toLowerCase() === value[17].toLowerCase(); +} + +function runRule(type, regex, text, whitelist, limit) { + regex.lastIndex = 0; + const findings = []; + let truncated = false; + let match; + while ((match = regex.exec(text))) { + const value = match[0]; + if (type === 'id_cn' && !chineseIdValid(value)) continue; + if (whitelist.has(value.toLowerCase())) continue; + findings.push({ start: match.index, end: match.index + value.length, value, type, confidence: type === 'id_cn' ? 0.99 : 0.92 }); + if (findings.length > limit) { + truncated = true; + break; + } + if (match.index === regex.lastIndex) regex.lastIndex += 1; + } + return { findings, truncated }; +} + +function findingLimit(value, fallback) { + const limit = value === undefined ? fallback : value; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_REDACTION_FINDINGS) throw new RangeError(`发现数量上限必须介于 1 到 ${MAX_REDACTION_FINDINGS} 之间。`); + return limit; +} + +export function inspectSensitive(text, options = {}) { + const source = String(text ?? ''); + if (new TextEncoder().encode(source).byteLength > (options.maxInput || MAX_INPUT)) throw new RangeError('文本超过 1 MiB 安全上限。'); + const maxFindings = findingLimit(options.maxFindings, MAX_FINDINGS); + const enabled = { ...DEFAULT_ENABLED, ...(options.enabled || {}) }; + const list = options.whitelist || []; if (!Array.isArray(list) || list.length > 200) throw new RangeError('允许列表超过 200 项安全上限。'); + const whitelist = new Set(list.map((item) => String(item).toLowerCase())); + if (Object.keys(enabled).filter((key)=>enabled[key] && Object.prototype.hasOwnProperty.call(RULES,key)).length > Object.keys(RULES).length) throw new RangeError('启用的规则过多。'); + let findings = []; + let candidateTruncated = false; + for (const [type, regex] of Object.entries(RULES)) { + if (!enabled[type]) continue; + const result = runRule(type, regex, source, whitelist, maxFindings); + findings = findings.concat(result.findings); + candidateTruncated ||= result.truncated; + } + findings.sort((a, b) => a.start - b.start || b.end - a.end || a.type.localeCompare(b.type)); + const nonOverlapping = []; + for (const item of findings) { + const prior = nonOverlapping[nonOverlapping.length - 1]; + if (!prior || item.start >= prior.end) nonOverlapping.push(item); + if (nonOverlapping.length > maxFindings) break; + } + const truncated = candidateTruncated || nonOverlapping.length > maxFindings; + return { + findings: nonOverlapping.slice(0, maxFindings), + truncated, + total: truncated ? null : nonOverlapping.length, + totalAtLeast: truncated && nonOverlapping.length > maxFindings ? maxFindings + 1 : nonOverlapping.length + }; +} + +export function detectSensitive(text, options = {}) { + const result = inspectSensitive(text, options); + Object.defineProperties(result.findings, { + truncated: { value: result.truncated, enumerable: false }, + total: { value: result.total, enumerable: false }, + totalAtLeast: { value: result.totalAtLeast, enumerable: false } + }); + return result.findings; +} + +export function redactText(text, options = {}) { + const source = String(text ?? ''); + const maxFindings = findingLimit(options.maxFindings, MAX_REDACTION_FINDINGS); + const result = inspectSensitive(source, { ...options, maxFindings }); + if (result.truncated) throw new RangeError(`文本超过 ${maxFindings.toLocaleString('zh-CN')} 项完整脱敏安全上限;请拆分内容后再脱敏。`); + const findings = result.findings; + const replacement = options.replacement ?? '[REDACTED]'; + if (typeof replacement !== 'string') throw new RangeError('replacement 必须是字符串。'); + // Do not let a caller use the replacement field to put a value matched by an + // enabled rule straight back into every redacted span. An exact value in the + // caller's explicit whitelist remains an intentional exception. + const replacementFindings = inspectSensitive(replacement, { + enabled: options.enabled, + whitelist: options.whitelist, + maxFindings: MAX_FINDINGS + }); + if (replacementFindings.findings.length > 0) { + throw new RangeError('replacement 不能包含任何启用脱敏规则可识别的敏感值。'); + } + let cursor = 0; + let output = ''; + for (const finding of findings) { + output += source.slice(cursor, finding.start) + replacement; + cursor = finding.end; + } + output += source.slice(cursor); + // A replacement can be harmless in isolation but create a secret when it + // joins surrounding source text (for example `Bearer ` + `/abcdefghijkl`). + // Re-scan the complete result with the caller's enabled rules and explicit + // whitelist. Whitelisted full values remain an intentional user decision. + const outputFindings = inspectSensitive(output, { + enabled: options.enabled, + whitelist: options.whitelist, + maxFindings: MAX_FINDINGS, + maxInput: Math.max(MAX_INPUT, new TextEncoder().encode(output).byteLength) + }); + if (outputFindings.findings.length > 0 || outputFindings.truncated) { + throw new RangeError('脱敏后的文本仍包含任何启用规则可识别的非白名单敏感值。'); + } + return { text: output, findings, total: findings.length, truncated: false }; +} + +export function imageExportDecision(maskCount, metadataConfirmed = false) { + if (Number(maskCount) > 0) return { ok: true, mode: 'redacted' }; + return metadataConfirmed ? { ok: true, mode: 'metadata-only' } : { ok: false, mode: 'confirm-metadata-only' }; +} + +export const defaults = Object.freeze({ MAX_INPUT, MAX_FINDINGS, MAX_REDACTION_FINDINGS, DEFAULT_ENABLED }); diff --git a/plugins/share-sanitizer/src/ui/app.mjs b/plugins/share-sanitizer/src/ui/app.mjs new file mode 100644 index 00000000..f944061f --- /dev/null +++ b/plugins/share-sanitizer/src/ui/app.mjs @@ -0,0 +1,194 @@ +import { detectSensitive, imageExportDecision, redactText } from '../core/sanitize.mjs'; + +const $ = (id) => document.getElementById(id); +const input = $('input'); +const status = $('status'); +const findings = $('findings'); +const canvas = $('canvas'); +const ctx = canvas.getContext('2d'); +const imageInput = $('image'); +let masks = []; +let image = null; +let dragging = null; +let imageObjectUrl = null; +let imageLoadEpoch = 0; +let stopEntry = null; +let unloading = false; + +const typeLabels = Object.freeze({ email: '邮箱', phone_cn: '中国大陆手机号', ipv4: 'IPv4 地址', win_path: 'Windows 路径', posix_path: 'POSIX 路径', bearer: 'Bearer 凭据', api_key: 'API 密钥', jwt: 'JWT', id_cn: '中国大陆身份证号' }); +const allowed = () => $('whitelist').value.split('\n').map((value) => value.trim()).filter(Boolean); + +function scan() { + const result = detectSensitive(input.value, { whitelist: allowed() }); + findings.replaceChildren(); + for (const item of result) { + const li = document.createElement('li'); + li.textContent = `${typeLabels[item.type] || item.type} · ${item.value}`; + findings.append(li); + } + $('summary').textContent = result.truncated ? `至少有 ${result.length} 项需要检查;当前展示前 ${result.length} 项。` : `有 ${result.length} 项需要检查。`; + status.textContent = result.length ? (result.truncated ? '需要检查 · 预览已截断' : '需要检查') : '未匹配到敏感数据'; + return result; +} + +function releaseImageObjectUrl() { + if (!imageObjectUrl) return; + const objectUrl = imageObjectUrl; + imageObjectUrl = null; + try { URL.revokeObjectURL(objectUrl); } catch {} +} + +function clearImageState(clearFileInput = true) { + imageLoadEpoch += 1; + if (image) { + image.onload = null; + image.onerror = null; + try { image.src = ''; } catch {} + } + image = null; + masks = []; + dragging = null; + releaseImageObjectUrl(); + canvas.width = 0; + canvas.height = 0; + ctx.clearRect(0, 0, 0, 0); + $('imageDesk').hidden = true; + $('ocr').textContent = ''; + if (clearFileInput) imageInput.value = ''; +} + +function resetSensitiveState() { + input.value = ''; + $('whitelist').value = ''; + findings.replaceChildren(); + $('summary').textContent = '尚未发现风险。'; + clearImageState(true); + status.textContent = '已在退出时清除敏感内容'; +} + +$('scan').addEventListener('click', () => { try { scan(); } catch (error) { status.textContent = error.message; } }); +$('redact').addEventListener('click', () => { + try { + const result = redactText(input.value, { whitelist: allowed(), replacement: '【已脱敏】' }); + input.value = result.text; + scan(); + status.textContent = `已脱敏 ${result.findings.length} 项`; + } catch (error) { status.textContent = error.message; } +}); +$('copy').addEventListener('click', async () => { + try { + const current = detectSensitive(input.value, { whitelist: allowed() }); + if (current.length) { scan(); status.textContent = '仍有敏感内容,请先应用脱敏再复制。'; return; } + if (globalThis.shareSanitizer?.copyText) await globalThis.shareSanitizer.copyText(input.value); + else await navigator.clipboard.writeText(input.value); + status.textContent = '已复制安全文本'; + } catch (error) { status.textContent = error.message || '当前环境无法复制'; } +}); + +function draw() { + if (!image) return; + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + ctx.drawImage(image, 0, 0); + ctx.fillStyle = '#ef5c5c'; + for (const mask of masks) ctx.fillRect(mask.x, mask.y, mask.w, mask.h); +} + +function loadImageSource(source, objectUrl = null) { + clearImageState(false); + const loadEpoch = imageLoadEpoch; + imageObjectUrl = objectUrl; + const nextImage = new Image(); + image = nextImage; + nextImage.onload = () => { + if (loadEpoch !== imageLoadEpoch || image !== nextImage) return; + if (nextImage.naturalWidth * nextImage.naturalHeight > 40_000_000) { + clearImageState(false); + status.textContent = '图片超过 4000 万像素安全上限。'; + return; + } + masks = []; + $('imageDesk').hidden = false; + draw(); + releaseImageObjectUrl(); + }; + nextImage.onerror = () => { + if (loadEpoch !== imageLoadEpoch || image !== nextImage) return; + clearImageState(false); + status.textContent = '图片解码失败,未进行任何修改。'; + }; + nextImage.src = source; + $('ocr').textContent = 'TextDetector' in globalThis ? '当前浏览器支持文字检测,但不会自动运行。' : '当前环境不支持 OCR,请手动添加遮罩。'; +} + +function loadImage(file) { + if (!file || !/^image\/(?:png|jpeg|webp)$/i.test(file.type) || file.size > 20 * 1024 * 1024) { + status.textContent = '仅支持不超过 20 MiB 的 PNG、JPEG 或 WebP 图片。'; + return; + } + const objectUrl = URL.createObjectURL(file); + loadImageSource(objectUrl, objectUrl); +} + +function loadImageDataUrl(dataUrl) { + if (typeof dataUrl !== 'string' || dataUrl.length > 28 * 1024 * 1024 || !/^data:image\/(?:png|jpeg|webp);base64,/i.test(dataUrl)) { + status.textContent = '所选图片数据无效或过大。'; + return; + } + loadImageSource(dataUrl); +} + +imageInput.addEventListener('change', (event) => loadImage(event.target.files?.[0])); + +function pos(event) { + const rect = canvas.getBoundingClientRect(); + return { x: (event.clientX - rect.left) * canvas.width / rect.width, y: (event.clientY - rect.top) * canvas.height / rect.height }; +} + +canvas.addEventListener('pointerdown', (event) => { if (!image) return; dragging = pos(event); canvas.setPointerCapture(event.pointerId); }); +canvas.addEventListener('pointerup', (event) => { + if (!dragging || !image) return; + const end = pos(event); + masks.push({ x: Math.min(dragging.x, end.x), y: Math.min(dragging.y, end.y), w: Math.abs(end.x - dragging.x), h: Math.abs(end.y - dragging.y) }); + dragging = null; + draw(); +}); +canvas.addEventListener('pointercancel', () => { dragging = null; }); +$('clearMasks').addEventListener('click', () => { masks = []; draw(); }); +$('exportImage').addEventListener('click', async () => { + try { + let decision = imageExportDecision(masks.length); + if (!decision.ok) { + if (!confirm('尚未添加手动遮罩。是否仅移除元数据后导出,不视为完成视觉脱敏?')) { status.textContent = '已取消图片导出;请添加遮罩后再导出视觉脱敏图片。'; return; } + decision = imageExportDecision(masks.length, true); + } + const data = canvas.toDataURL('image/png'); + if (globalThis.shareSanitizer?.copyImage) await globalThis.shareSanitizer.copyImage(data); + else { const anchor = document.createElement('a'); anchor.href = data; anchor.download = '已脱敏.png'; anchor.click(); } + status.textContent = decision.mode === 'metadata-only' ? '仅移除元数据的图片已导出,未应用视觉脱敏。' : '已导出脱敏图片'; + } catch { status.textContent = '当前环境无法导出图片'; } +}); + +function consumeEntry(entry) { + if (entry?.kind === 'reset') { + resetSensitiveState(); + return; + } + if (entry?.kind === 'text') { input.value = entry.text; scan(); return; } + if (entry?.kind === 'image') { loadImageDataUrl(entry.dataUrl); return; } + if (entry?.kind === 'error') status.textContent = entry.message; +} + +function subscribeEntry() { + if (unloading) return; + if (stopEntry) return; + stopEntry = globalThis.shareSanitizer?.onEntry?.(consumeEntry) || null; +} + +subscribeEntry(); +globalThis.addEventListener?.('pagehide', () => { + unloading = true; + stopEntry?.(); + stopEntry = null; + resetSensitiveState(); +}); diff --git a/plugins/share-sanitizer/src/ui/index.html b/plugins/share-sanitizer/src/ui/index.html new file mode 100644 index 00000000..49ad47ba --- /dev/null +++ b/plugins/share-sanitizer/src/ui/index.html @@ -0,0 +1 @@ +
本地脱敏工作台
在图片上拖动即可添加永久遮罩。导出时会重新编码像素并移除元数据。