From 8abda5f51f02fe06e96723d6105bdfec197a3e3f Mon Sep 17 00:00:00 2001 From: wangzihao Date: Mon, 31 Aug 2026 17:04:12 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(share-sanitizer):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=88=86=E4=BA=AB=E5=89=8D=E8=84=B1=E6=95=8F=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI-Co-Authored-By: Codex --- plugins/share-sanitizer/.gitignore | 2 + plugins/share-sanitizer/CHANGELOG.md | 5 ++ plugins/share-sanitizer/README.md | 14 ++++ plugins/share-sanitizer/logo.svg | 1 + plugins/share-sanitizer/package-lock.json | 7 ++ plugins/share-sanitizer/package.json | 8 ++ plugins/share-sanitizer/plugin.json | 14 ++++ plugins/share-sanitizer/preload/index.cjs | 6 ++ plugins/share-sanitizer/scripts/build.mjs | 8 ++ .../share-sanitizer/scripts/verify-dist.mjs | 2 + plugins/share-sanitizer/src/core/sanitize.mjs | 76 +++++++++++++++++++ plugins/share-sanitizer/src/ui/app.mjs | 18 +++++ plugins/share-sanitizer/src/ui/index.html | 1 + plugins/share-sanitizer/src/ui/style.css | 1 + .../share-sanitizer/test/manifest.test.mjs | 4 + .../share-sanitizer/test/sanitize.test.mjs | 7 ++ 16 files changed, 174 insertions(+) create mode 100644 plugins/share-sanitizer/.gitignore create mode 100644 plugins/share-sanitizer/CHANGELOG.md create mode 100644 plugins/share-sanitizer/README.md create mode 100644 plugins/share-sanitizer/logo.svg create mode 100644 plugins/share-sanitizer/package-lock.json create mode 100644 plugins/share-sanitizer/package.json create mode 100644 plugins/share-sanitizer/plugin.json create mode 100644 plugins/share-sanitizer/preload/index.cjs create mode 100644 plugins/share-sanitizer/scripts/build.mjs create mode 100644 plugins/share-sanitizer/scripts/verify-dist.mjs create mode 100644 plugins/share-sanitizer/src/core/sanitize.mjs create mode 100644 plugins/share-sanitizer/src/ui/app.mjs create mode 100644 plugins/share-sanitizer/src/ui/index.html create mode 100644 plugins/share-sanitizer/src/ui/style.css create mode 100644 plugins/share-sanitizer/test/manifest.test.mjs create mode 100644 plugins/share-sanitizer/test/sanitize.test.mjs 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..54d0588c --- /dev/null +++ b/plugins/share-sanitizer/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 + +- Initial local-first text and image redaction workbench. diff --git a/plugins/share-sanitizer/README.md b/plugins/share-sanitizer/README.md new file mode 100644 index 00000000..ea58279d --- /dev/null +++ b/plugins/share-sanitizer/README.md @@ -0,0 +1,14 @@ +# Share Sanitizer + +Local-first review desk for removing sensitive data before text or an image is shared. + +It detects email addresses, Chinese phone and ID numbers, IPv4 addresses, absolute paths, Bearer/API credentials and JWTs. Rules can be disabled and approved values can be whitelisted. Image output is drawn onto a new canvas, which removes image metadata; the user can add manual rectangular masks. Browser OCR is deliberately optional: when `TextDetector` is unavailable the UI says so and never claims OCR completed. + +## Safety and portability + +- The detector is pure JavaScript and tests POSIX, Windows and Linux path shapes without relying on the host OS. +- The narrow preload bridge only exposes host clipboard actions (`copyText` and `copyImage`) when those APIs exist; renderer code never receives a filesystem path. +- Input is capped at 1 MiB and 500 findings to keep the UI responsive. +- No network, external fonts, native add-ons, or shell commands are used. + +Run `npm test`, `npm run build`, and `npm run verify-dist`. Runtime loading on physical Windows/macOS/Linux ZTools hosts remains to be tested. diff --git a/plugins/share-sanitizer/logo.svg b/plugins/share-sanitizer/logo.svg new file mode 100644 index 00000000..6df2320f --- /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..93784881 --- /dev/null +++ b/plugins/share-sanitizer/package.json @@ -0,0 +1,8 @@ +{ + "name": "share-sanitizer", + "version": "0.1.0", + "private": true, + "type": "module", + "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..205c4ac3 --- /dev/null +++ b/plugins/share-sanitizer/plugin.json @@ -0,0 +1,14 @@ +{ + "name": "share-sanitizer", + "title": "分享前脱敏", + "version": "0.1.0", + "description": "Review and redact sensitive text and images before sharing.", + "author": "harris", + "main": "dist/index.html", + "logo": "dist/logo.svg", + "preload": "dist/preload/index.cjs", + "platform": ["darwin", "win32", "linux"], + "categories": ["productivity", "text", "media"], + "features": [{"code": "sanitize-share", "explain": "Sanitize selected text, files, or images before sharing.", "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}]}], + "development": {"main": "src/ui/index.html", "preload": "preload/index.cjs"} +} diff --git a/plugins/share-sanitizer/preload/index.cjs b/plugins/share-sanitizer/preload/index.cjs new file mode 100644 index 00000000..ad22971f --- /dev/null +++ b/plugins/share-sanitizer/preload/index.cjs @@ -0,0 +1,6 @@ +'use strict'; +const bridge = {}; +const api = globalThis.ztools || {}; +if (typeof api.copyText === 'function') bridge.copyText = (text) => api.copyText(String(text)); +if (typeof api.copyImage === 'function') bridge.copyImage = (dataUrl) => api.copyImage(String(dataUrl)); +globalThis.shareSanitizer = Object.freeze(bridge); 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/verify-dist.mjs b/plugins/share-sanitizer/scripts/verify-dist.mjs new file mode 100644 index 00000000..d5b33010 --- /dev/null +++ b/plugins/share-sanitizer/scripts/verify-dist.mjs @@ -0,0 +1,2 @@ +import { access, 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)),'..'), 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)); const app=await readFile(path.join(dist,'app.mjs'),'utf8'); if(app.includes("../core/")) throw new Error('dist UI import escaped package'); console.log('share-sanitizer dist verified'); diff --git a/plugins/share-sanitizer/src/core/sanitize.mjs b/plugins/share-sanitizer/src/core/sanitize.mjs new file mode 100644 index 00000000..fd4490b2 --- /dev/null +++ b/plugins/share-sanitizer/src/core/sanitize.mjs @@ -0,0 +1,76 @@ +const MAX_INPUT = 1024 * 1024; +const MAX_FINDINGS = 500; +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) { + regex.lastIndex = 0; + const findings = []; + let match; + while ((match = regex.exec(text)) && findings.length < MAX_FINDINGS) { + 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 (match.index === regex.lastIndex) regex.lastIndex += 1; + } + return findings; +} + +export function detectSensitive(text, options = {}) { + const source = String(text ?? ''); + if (new TextEncoder().encode(source).byteLength > (options.maxInput || MAX_INPUT)) throw new RangeError('Text exceeds the 1 MiB safety limit.'); + const enabled = { ...DEFAULT_ENABLED, ...(options.enabled || {}) }; + const list = options.whitelist || []; if (!Array.isArray(list) || list.length > 200) throw new RangeError('Whitelist exceeds the 200-value safety limit.'); + 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('Too many rules enabled.'); + let findings = []; + for (const [type, regex] of Object.entries(RULES)) if (enabled[type]) findings = findings.concat(runRule(type, regex, source, whitelist)); + 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 >= (options.maxFindings || MAX_FINDINGS)) break; + } + return nonOverlapping; +} + +export function redactText(text, options = {}) { + const source = String(text ?? ''); + const findings = detectSensitive(source, options); + const replacement = options.replacement || '[REDACTED]'; + let cursor = 0; + let output = ''; + for (const finding of findings) { + output += source.slice(cursor, finding.start) + replacement; + cursor = finding.end; + } + return { text: output + source.slice(cursor), findings }; +} + +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, 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..7657dcc7 --- /dev/null +++ b/plugins/share-sanitizer/src/ui/app.mjs @@ -0,0 +1,18 @@ +import { detectSensitive, imageExportDecision, redactText } from '../core/sanitize.mjs'; +const $ = (id) => document.getElementById(id); +const input = $('input'), status = $('status'), findings = $('findings'); +let masks = [], image = null, dragging = null; +const allowed = () => $('whitelist').value.split('\n').map((v) => v.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 = `${item.type} · ${item.value}`; findings.append(li); } $('summary').textContent = `${result.length} item(s) need review.`; status.textContent = result.length ? 'Review required' : 'No matched sensitive data'; return result; } +$('scan').addEventListener('click',()=>{try{scan()}catch(error){status.textContent=error.message}}); +$('redact').addEventListener('click', () => { try { const result = redactText(input.value, { whitelist: allowed() }); input.value = result.text; scan(); status.textContent = `Redacted ${result.findings.length} item(s)`; } catch (error) { status.textContent = error.message; } }); +$('copy').addEventListener('click', async () => { try { const current=detectSensitive(input.value,{whitelist:allowed()}); if(current.length){scan();status.textContent='Sensitive matches remain. Apply redaction before copying.';return} if (globalThis.shareSanitizer?.copyText) await globalThis.shareSanitizer.copyText(input.value); else await navigator.clipboard.writeText(input.value); status.textContent = 'Safe text copied'; } catch (error) { status.textContent = error.message || 'Copy unavailable in this host'; } }); +const canvas = $('canvas'), ctx = canvas.getContext('2d'); +function draw() { if (!image) return; canvas.width = image.naturalWidth; canvas.height = image.naturalHeight; ctx.drawImage(image, 0, 0); ctx.fillStyle = '#ef5c5c'; for (const m of masks) ctx.fillRect(m.x,m.y,m.w,m.h); } +function loadImage(file){if(!file||!/^image\/(?:png|jpeg|webp)$/i.test(file.type)||file.size>20*1024*1024){status.textContent='Only PNG/JPEG/WebP images up to 20 MiB are accepted.';return}const url=URL.createObjectURL(file);image=new Image();image.onload=()=>{if(image.naturalWidth*image.naturalHeight>40_000_000){status.textContent='Image exceeds the 40 megapixel safety limit.';image=null;URL.revokeObjectURL(url);return}masks=[];$('imageDesk').hidden=false;draw();URL.revokeObjectURL(url)};image.onerror=()=>{image=null;URL.revokeObjectURL(url);status.textContent='Image decoding failed; nothing was changed.'};image.src=url;$('ocr').textContent='TextDetector'in globalThis?'Browser text detection is available, but is not run automatically.':'OCR is unavailable in this host; use manual masks.';} +$('image').addEventListener('change', (event) => loadImage(event.target.files?.[0])); +function pos(event){const r=canvas.getBoundingClientRect(),sx=canvas.width/r.width,sy=canvas.height/r.height;return{x:(event.clientX-r.left)*sx,y:(event.clientY-r.top)*sy};} +canvas.addEventListener('pointerdown',(e)=>{dragging=pos(e);canvas.setPointerCapture(e.pointerId)});canvas.addEventListener('pointerup',(e)=>{if(!dragging)return;const end=pos(e);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('No manual masks were added. Export only to strip metadata, without claiming visual redaction?')){status.textContent='Image export cancelled; add masks for a visually redacted image.';return}decision=imageExportDecision(masks.length,true)}const data=canvas.toDataURL('image/png');if(globalThis.shareSanitizer?.copyImage)await globalThis.shareSanitizer.copyImage(data);else{const a=document.createElement('a');a.href=data;a.download='sanitized.png';a.click()}status.textContent=decision.mode==='metadata-only'?'Metadata-only image export completed; no visual redaction was applied.':'Redacted image exported'}catch{status.textContent='Image export unavailable'}}); +async function consumeEntry(action){const payload=action?.payload;const type=action?.type;if(type==='over'||typeof payload==='string'){input.value=String(payload||'').slice(0,1024*1024);scan();return}const file=Array.isArray(payload)?payload[0]:payload;if(!file)return;if(type==='img'||/^image\//i.test(file.type||'')){loadImage(file);return}if((file.size||0)>1024*1024||!/(?:\.txt|\.md|\.json|\.log)$/i.test(file.name||'')){status.textContent='Only selected text files up to 1 MiB are accepted.';return}if(typeof file.text!=='function'){status.textContent='Selected text is unavailable in this host.';return}input.value=await file.text();scan();} +globalThis.ztools?.onPluginEnter?.((action)=>{consumeEntry(action).catch(()=>{status.textContent='Could not load plugin entry data safely.'})}); diff --git a/plugins/share-sanitizer/src/ui/index.html b/plugins/share-sanitizer/src/ui/index.html new file mode 100644 index 00000000..e7ababe6 --- /dev/null +++ b/plugins/share-sanitizer/src/ui/index.html @@ -0,0 +1 @@ +Share Sanitizer

LOCAL REVIEW DESK

Share Sanitizer

Ready

diff --git a/plugins/share-sanitizer/src/ui/style.css b/plugins/share-sanitizer/src/ui/style.css new file mode 100644 index 00000000..b1f397f1 --- /dev/null +++ b/plugins/share-sanitizer/src/ui/style.css @@ -0,0 +1 @@ +:root{color-scheme:dark;font-family:ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#081720;color:#e7f5fa}*{box-sizing:border-box}body{margin:0;min-width:320px;background:radial-gradient(circle at 80% 0,#17445e,#081720 45%)}main{max-width:1200px;margin:auto;padding:32px 20px}.eyebrow{letter-spacing:.16em;font:700 11px ui-monospace,SFMono-Regular,Menlo,monospace;color:#58c7e8;margin:0}h1{margin:4px 0;font-size:clamp(28px,4vw,48px)}h2{margin-top:0}header,.controls{display:flex;justify-content:space-between;align-items:center;gap:12px}.grid{display:grid;grid-template-columns:minmax(0,2fr) minmax(260px,1fr);gap:18px;margin-top:20px}.panel{background:#102638cc;border:1px solid #2d6176;border-radius:14px;padding:18px;box-shadow:0 16px 35px #0004}label{display:block;font-weight:700;margin-bottom:8px}textarea{width:100%;min-height:280px;resize:vertical;border:1px solid #386e83;border-radius:9px;padding:12px;background:#07141d;color:inherit;font:14px ui-monospace,SFMono-Regular,Menlo,monospace}.controls{justify-content:flex-start;flex-wrap:wrap;margin-top:12px}button,.file{border:1px solid #58c7e8;border-radius:8px;padding:9px 12px;background:#123b50;color:#e7f5fa;font:inherit;font-weight:700;cursor:pointer}.danger{background:#a33842;border-color:#ff7a7a}.secondary{background:transparent}.file{display:inline-block;margin-top:12px}.file input{display:none}button:focus-visible,.file:focus-within,textarea:focus-visible{outline:3px solid #f3b24f;outline-offset:3px}.hint,#status{color:#9fc1cf;font-size:13px}ol{padding-left:20px;max-height:320px;overflow:auto}li{margin:7px 0;padding:6px;border-left:4px solid #ef5c5c;background:#192d3a}.image-desk{margin-top:18px}canvas{max-width:100%;display:block;border:1px dashed #58c7e8;margin:12px 0;cursor:crosshair}@media(max-width:760px){main{padding:20px 12px}.grid{grid-template-columns:1fr}header{align-items:flex-start;flex-direction:column}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important;animation:none!important}} diff --git a/plugins/share-sanitizer/test/manifest.test.mjs b/plugins/share-sanitizer/test/manifest.test.mjs new file mode 100644 index 00000000..7cc410f8 --- /dev/null +++ b/plugins/share-sanitizer/test/manifest.test.mjs @@ -0,0 +1,4 @@ +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('manifest feature contract is narrow and preload bridge has no path exposure',async()=>{const manifest=JSON.parse(await readFile(path.join(root,'plugin.json'),'utf8')),preload=await readFile(path.join(root,'preload','index.cjs'),'utf8');assert.deepEqual(manifest.categories,['productivity','text','media']);const cmds=manifest.features[0].cmds;assert.equal(cmds.find((item)=>item?.type==='files').maxLength,1);assert.ok(cmds.find((item)=>item?.type==='img'));assert.ok(cmds.find((item)=>item?.type==='over'));assert.ok(!preload.includes('getPathForFile'));}); +test('renderer clears dynamic findings without HTML injection and labels metadata-only export accurately',async()=>{const app=await readFile(path.join(root,'src','ui','app.mjs'),'utf8'),html=await readFile(path.join(root,'src','ui','index.html'),'utf8');assert.ok(!app.includes('innerHTML'));assert.match(html,/Export re-encoded image/);assert.doesNotMatch(html,/Copy cleaned image/);}); diff --git a/plugins/share-sanitizer/test/sanitize.test.mjs b/plugins/share-sanitizer/test/sanitize.test.mjs new file mode 100644 index 00000000..18d4b8ec --- /dev/null +++ b/plugins/share-sanitizer/test/sanitize.test.mjs @@ -0,0 +1,7 @@ +import test from 'node:test'; import assert from 'node:assert/strict'; import { detectSensitive, imageExportDecision, redactText } from '../src/core/sanitize.mjs'; +test('detects sensitive values with stable non-overlapping spans',()=>{const text='a@b.com 13800138000 11010519491231002X 192.168.0.1 C:\\Users\\me\\x /var/tmp/x Bearer abcdefghijklmnop eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.signature';const out=detectSensitive(text);assert.ok(out.some(x=>x.type==='email'));assert.ok(out.some(x=>x.type==='phone_cn'));assert.ok(out.some(x=>x.type==='id_cn'));assert.ok(out.some(x=>x.type==='win_path'));assert.ok(out.every((x,i)=>!i||out[i-1].end<=x.start));}); +test('does not accept invalid Chinese IDs or phone substrings',()=>{const out=detectSensitive('x113800138000 110105194912310021');assert.equal(out.some(x=>x.type==='phone_cn'||x.type==='id_cn'),false);}); +test('whitelist and replacement are deterministic',()=>{const out=redactText('safe@x.com bad@y.com',{whitelist:['safe@x.com'],replacement:'***'});assert.equal(out.text,'safe@x.com ***');assert.equal(out.findings.length,1);}); +test('platform path shapes are found without host dependence',()=>{const b=String.fromCharCode(92);const out=detectSensitive(`D:${b}a${b}b ${b}${b}server${b}share${b}x /opt/app/x`);assert.ok(out.filter(x=>x.type==='win_path').length>=2);assert.ok(out.some(x=>x.type==='posix_path'));}); +test('caps byte input and whitelist growth',()=>{assert.throws(()=>detectSensitive('x'.repeat(1024*1024+1)),/safety limit/);assert.throws(()=>detectSensitive('x',{whitelist:Array.from({length:201},(_,i)=>String(i))}),/Whitelist/);}); +test('image export needs explicit metadata-only confirmation without masks',()=>{assert.equal(imageExportDecision(0).ok,false);assert.deepEqual(imageExportDecision(0,true),{ok:true,mode:'metadata-only'});assert.deepEqual(imageExportDecision(1),{ok:true,mode:'redacted'});}); From 9b109cc4996733b6a7de87e37dbe9e1e8a226e53 Mon Sep 17 00:00:00 2001 From: wangzihao Date: Tue, 1 Sep 2026 16:20:35 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E5=88=86?= =?UTF-8?q?=E4=BA=AB=E8=84=B1=E6=95=8F=E4=B8=8E=20MCP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 完善中文界面、会话清理和文本图片脱敏流程 - 提供敏感信息检测与安全替换工具 AI-Co-Authored-By: Codex --- plugins/share-sanitizer/CHANGELOG.md | 9 +- plugins/share-sanitizer/README.md | 35 +- plugins/share-sanitizer/logo.svg | 2 +- plugins/share-sanitizer/plugin.json | 70 +++- plugins/share-sanitizer/preload/index.cjs | 306 +++++++++++++++++- plugins/share-sanitizer/scripts/dist-size.mjs | 45 +++ .../share-sanitizer/scripts/verify-dist.mjs | 22 +- plugins/share-sanitizer/src/core/sanitize.mjs | 92 +++++- plugins/share-sanitizer/src/ui/app.mjs | 208 +++++++++++- plugins/share-sanitizer/src/ui/index.html | 2 +- plugins/share-sanitizer/src/ui/style.css | 2 +- .../share-sanitizer/test/dist-size.test.mjs | 79 +++++ plugins/share-sanitizer/test/entry.test.mjs | 135 ++++++++ .../share-sanitizer/test/manifest.test.mjs | 5 +- plugins/share-sanitizer/test/mcp.test.mjs | 123 +++++++ .../share-sanitizer/test/sanitize.test.mjs | 6 +- 16 files changed, 1081 insertions(+), 60 deletions(-) create mode 100644 plugins/share-sanitizer/scripts/dist-size.mjs create mode 100644 plugins/share-sanitizer/test/dist-size.test.mjs create mode 100644 plugins/share-sanitizer/test/entry.test.mjs create mode 100644 plugins/share-sanitizer/test/mcp.test.mjs diff --git a/plugins/share-sanitizer/CHANGELOG.md b/plugins/share-sanitizer/CHANGELOG.md index 54d0588c..ff3308ca 100644 --- a/plugins/share-sanitizer/CHANGELOG.md +++ b/plugins/share-sanitizer/CHANGELOG.md @@ -1,5 +1,10 @@ -# Changelog +# 更新日志 ## 0.1.0 -- Initial local-first text and image redaction workbench. +- 提供本地优先的文本与图片脱敏工作台。 +- 为 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 index ea58279d..39b2b498 100644 --- a/plugins/share-sanitizer/README.md +++ b/plugins/share-sanitizer/README.md @@ -1,14 +1,31 @@ -# Share Sanitizer +# 分享脱敏台 -Local-first review desk for removing sensitive data before text or an image is shared. +界面、状态提示、确认对话框和图片导出提示均使用简体中文;MCP 工具名、schema 字段和错误码保持稳定,便于 Agent 调用。 -It detects email addresses, Chinese phone and ID numbers, IPv4 addresses, absolute paths, Bearer/API credentials and JWTs. Rules can be disabled and approved values can be whitelisted. Image output is drawn onto a new canvas, which removes image metadata; the user can add manual rectangular masks. Browser OCR is deliberately optional: when `TextDetector` is unavailable the UI says so and never claims OCR completed. +这是一款本地优先的分享前审查工具,用于在文本或图片对外分享前移除敏感数据。 -## Safety and portability +它可检测电子邮箱、中国手机号和身份证号、IPv4 地址、绝对路径、Bearer/API 凭据以及 JWT。用户可关闭特定规则,也可将已确认安全的值加入白名单。图片会绘制到新的画布(`canvas`),从而移除原图元数据;用户还可手动添加矩形遮罩。浏览器 OCR 是明确的可选能力:当 `TextDetector` 不可用时,界面会明确说明,不会声称 OCR 已完成。 -- The detector is pure JavaScript and tests POSIX, Windows and Linux path shapes without relying on the host OS. -- The narrow preload bridge only exposes host clipboard actions (`copyText` and `copyImage`) when those APIs exist; renderer code never receives a filesystem path. -- Input is capped at 1 MiB and 500 findings to keep the UI responsive. -- No network, external fonts, native add-ons, or shell commands are used. +## 安全与跨平台 -Run `npm test`, `npm run build`, and `npm run verify-dist`. Runtime loading on physical Windows/macOS/Linux ZTools hosts remains to be tested. +- 检测器由纯 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 index 6df2320f..13e3c48f 100644 --- a/plugins/share-sanitizer/logo.svg +++ b/plugins/share-sanitizer/logo.svg @@ -1 +1 @@ - + diff --git a/plugins/share-sanitizer/plugin.json b/plugins/share-sanitizer/plugin.json index 205c4ac3..89adfd73 100644 --- a/plugins/share-sanitizer/plugin.json +++ b/plugins/share-sanitizer/plugin.json @@ -2,13 +2,71 @@ "name": "share-sanitizer", "title": "分享前脱敏", "version": "0.1.0", - "description": "Review and redact sensitive text and images before sharing.", + "description": "在分享前检查并脱敏敏感文本和图片。", "author": "harris", - "main": "dist/index.html", - "logo": "dist/logo.svg", - "preload": "dist/preload/index.cjs", + "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": "Sanitize selected text, files, or images before sharing.", "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}]}], - "development": {"main": "src/ui/index.html", "preload": "preload/index.cjs"} + "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 index ad22971f..8237cc25 100644 --- a/plugins/share-sanitizer/preload/index.cjs +++ b/plugins/share-sanitizer/preload/index.cjs @@ -1,6 +1,302 @@ 'use strict'; -const bridge = {}; -const api = globalThis.ztools || {}; -if (typeof api.copyText === 'function') bridge.copyText = (text) => api.copyText(String(text)); -if (typeof api.copyImage === 'function') bridge.copyImage = (dataUrl) => api.copyImage(String(dataUrl)); -globalThis.shareSanitizer = Object.freeze(bridge); + +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/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 index d5b33010..d69fcf7c 100644 --- a/plugins/share-sanitizer/scripts/verify-dist.mjs +++ b/plugins/share-sanitizer/scripts/verify-dist.mjs @@ -1,2 +1,20 @@ -import { access, 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)),'..'), 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)); const app=await readFile(path.join(dist,'app.mjs'),'utf8'); if(app.includes("../core/")) throw new Error('dist UI import escaped package'); console.log('share-sanitizer dist verified'); +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 index fd4490b2..1cbc03df 100644 --- a/plugins/share-sanitizer/src/core/sanitize.mjs +++ b/plugins/share-sanitizer/src/core/sanitize.mjs @@ -1,5 +1,6 @@ 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: /(? limit) { + truncated = true; + break; + } if (match.index === regex.lastIndex) regex.lastIndex += 1; } - return findings; + return { findings, truncated }; } -export function detectSensitive(text, options = {}) { +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('Text exceeds the 1 MiB safety limit.'); + 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('Whitelist exceeds the 200-value safety limit.'); + 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('Too many rules enabled.'); + if (Object.keys(enabled).filter((key)=>enabled[key] && Object.prototype.hasOwnProperty.call(RULES,key)).length > Object.keys(RULES).length) throw new RangeError('启用的规则过多。'); let findings = []; - for (const [type, regex] of Object.entries(RULES)) if (enabled[type]) findings = findings.concat(runRule(type, regex, source, whitelist)); + 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 >= (options.maxFindings || MAX_FINDINGS)) break; + if (nonOverlapping.length > maxFindings) break; } - return nonOverlapping; + 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 findings = detectSensitive(source, options); - const replacement = options.replacement || '[REDACTED]'; + 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; } - return { text: output + source.slice(cursor), findings }; + 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) { @@ -73,4 +137,4 @@ export function imageExportDecision(maskCount, metadataConfirmed = false) { return metadataConfirmed ? { ok: true, mode: 'metadata-only' } : { ok: false, mode: 'confirm-metadata-only' }; } -export const defaults = Object.freeze({ MAX_INPUT, MAX_FINDINGS, DEFAULT_ENABLED }); +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 index 7657dcc7..f944061f 100644 --- a/plugins/share-sanitizer/src/ui/app.mjs +++ b/plugins/share-sanitizer/src/ui/app.mjs @@ -1,18 +1,194 @@ import { detectSensitive, imageExportDecision, redactText } from '../core/sanitize.mjs'; + const $ = (id) => document.getElementById(id); -const input = $('input'), status = $('status'), findings = $('findings'); -let masks = [], image = null, dragging = null; -const allowed = () => $('whitelist').value.split('\n').map((v) => v.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 = `${item.type} · ${item.value}`; findings.append(li); } $('summary').textContent = `${result.length} item(s) need review.`; status.textContent = result.length ? 'Review required' : 'No matched sensitive data'; return result; } -$('scan').addEventListener('click',()=>{try{scan()}catch(error){status.textContent=error.message}}); -$('redact').addEventListener('click', () => { try { const result = redactText(input.value, { whitelist: allowed() }); input.value = result.text; scan(); status.textContent = `Redacted ${result.findings.length} item(s)`; } catch (error) { status.textContent = error.message; } }); -$('copy').addEventListener('click', async () => { try { const current=detectSensitive(input.value,{whitelist:allowed()}); if(current.length){scan();status.textContent='Sensitive matches remain. Apply redaction before copying.';return} if (globalThis.shareSanitizer?.copyText) await globalThis.shareSanitizer.copyText(input.value); else await navigator.clipboard.writeText(input.value); status.textContent = 'Safe text copied'; } catch (error) { status.textContent = error.message || 'Copy unavailable in this host'; } }); -const canvas = $('canvas'), ctx = canvas.getContext('2d'); -function draw() { if (!image) return; canvas.width = image.naturalWidth; canvas.height = image.naturalHeight; ctx.drawImage(image, 0, 0); ctx.fillStyle = '#ef5c5c'; for (const m of masks) ctx.fillRect(m.x,m.y,m.w,m.h); } -function loadImage(file){if(!file||!/^image\/(?:png|jpeg|webp)$/i.test(file.type)||file.size>20*1024*1024){status.textContent='Only PNG/JPEG/WebP images up to 20 MiB are accepted.';return}const url=URL.createObjectURL(file);image=new Image();image.onload=()=>{if(image.naturalWidth*image.naturalHeight>40_000_000){status.textContent='Image exceeds the 40 megapixel safety limit.';image=null;URL.revokeObjectURL(url);return}masks=[];$('imageDesk').hidden=false;draw();URL.revokeObjectURL(url)};image.onerror=()=>{image=null;URL.revokeObjectURL(url);status.textContent='Image decoding failed; nothing was changed.'};image.src=url;$('ocr').textContent='TextDetector'in globalThis?'Browser text detection is available, but is not run automatically.':'OCR is unavailable in this host; use manual masks.';} -$('image').addEventListener('change', (event) => loadImage(event.target.files?.[0])); -function pos(event){const r=canvas.getBoundingClientRect(),sx=canvas.width/r.width,sy=canvas.height/r.height;return{x:(event.clientX-r.left)*sx,y:(event.clientY-r.top)*sy};} -canvas.addEventListener('pointerdown',(e)=>{dragging=pos(e);canvas.setPointerCapture(e.pointerId)});canvas.addEventListener('pointerup',(e)=>{if(!dragging)return;const end=pos(e);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('No manual masks were added. Export only to strip metadata, without claiming visual redaction?')){status.textContent='Image export cancelled; add masks for a visually redacted image.';return}decision=imageExportDecision(masks.length,true)}const data=canvas.toDataURL('image/png');if(globalThis.shareSanitizer?.copyImage)await globalThis.shareSanitizer.copyImage(data);else{const a=document.createElement('a');a.href=data;a.download='sanitized.png';a.click()}status.textContent=decision.mode==='metadata-only'?'Metadata-only image export completed; no visual redaction was applied.':'Redacted image exported'}catch{status.textContent='Image export unavailable'}}); -async function consumeEntry(action){const payload=action?.payload;const type=action?.type;if(type==='over'||typeof payload==='string'){input.value=String(payload||'').slice(0,1024*1024);scan();return}const file=Array.isArray(payload)?payload[0]:payload;if(!file)return;if(type==='img'||/^image\//i.test(file.type||'')){loadImage(file);return}if((file.size||0)>1024*1024||!/(?:\.txt|\.md|\.json|\.log)$/i.test(file.name||'')){status.textContent='Only selected text files up to 1 MiB are accepted.';return}if(typeof file.text!=='function'){status.textContent='Selected text is unavailable in this host.';return}input.value=await file.text();scan();} -globalThis.ztools?.onPluginEnter?.((action)=>{consumeEntry(action).catch(()=>{status.textContent='Could not load plugin entry data safely.'})}); +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 index e7ababe6..49ad47ba 100644 --- a/plugins/share-sanitizer/src/ui/index.html +++ b/plugins/share-sanitizer/src/ui/index.html @@ -1 +1 @@ -Share Sanitizer

LOCAL REVIEW DESK

Share Sanitizer

Ready

+分享前脱敏

本地脱敏工作台

分享前脱敏

就绪

diff --git a/plugins/share-sanitizer/src/ui/style.css b/plugins/share-sanitizer/src/ui/style.css index b1f397f1..966a902b 100644 --- a/plugins/share-sanitizer/src/ui/style.css +++ b/plugins/share-sanitizer/src/ui/style.css @@ -1 +1 @@ -:root{color-scheme:dark;font-family:ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#081720;color:#e7f5fa}*{box-sizing:border-box}body{margin:0;min-width:320px;background:radial-gradient(circle at 80% 0,#17445e,#081720 45%)}main{max-width:1200px;margin:auto;padding:32px 20px}.eyebrow{letter-spacing:.16em;font:700 11px ui-monospace,SFMono-Regular,Menlo,monospace;color:#58c7e8;margin:0}h1{margin:4px 0;font-size:clamp(28px,4vw,48px)}h2{margin-top:0}header,.controls{display:flex;justify-content:space-between;align-items:center;gap:12px}.grid{display:grid;grid-template-columns:minmax(0,2fr) minmax(260px,1fr);gap:18px;margin-top:20px}.panel{background:#102638cc;border:1px solid #2d6176;border-radius:14px;padding:18px;box-shadow:0 16px 35px #0004}label{display:block;font-weight:700;margin-bottom:8px}textarea{width:100%;min-height:280px;resize:vertical;border:1px solid #386e83;border-radius:9px;padding:12px;background:#07141d;color:inherit;font:14px ui-monospace,SFMono-Regular,Menlo,monospace}.controls{justify-content:flex-start;flex-wrap:wrap;margin-top:12px}button,.file{border:1px solid #58c7e8;border-radius:8px;padding:9px 12px;background:#123b50;color:#e7f5fa;font:inherit;font-weight:700;cursor:pointer}.danger{background:#a33842;border-color:#ff7a7a}.secondary{background:transparent}.file{display:inline-block;margin-top:12px}.file input{display:none}button:focus-visible,.file:focus-within,textarea:focus-visible{outline:3px solid #f3b24f;outline-offset:3px}.hint,#status{color:#9fc1cf;font-size:13px}ol{padding-left:20px;max-height:320px;overflow:auto}li{margin:7px 0;padding:6px;border-left:4px solid #ef5c5c;background:#192d3a}.image-desk{margin-top:18px}canvas{max-width:100%;display:block;border:1px dashed #58c7e8;margin:12px 0;cursor:crosshair}@media(max-width:760px){main{padding:20px 12px}.grid{grid-template-columns:1fr}header{align-items:flex-start;flex-direction:column}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important;animation:none!important}} +:root{color-scheme:dark;font-family:ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#081720;color:#e7f5fa}*{box-sizing:border-box}body{margin:0;min-width:320px;background:radial-gradient(circle at 80% 0,#17445e,#081720 45%)}main{max-width:1200px;margin:auto;padding:32px 20px}.eyebrow{letter-spacing:.16em;font:700 11px ui-monospace,SFMono-Regular,Menlo,monospace;color:#58c7e8;margin:0}h1{margin:4px 0;font-size:clamp(28px,4vw,48px)}h2{margin-top:0}header,.controls{display:flex;justify-content:space-between;align-items:center;gap:12px}.grid{display:grid;grid-template-columns:minmax(0,2fr) minmax(260px,1fr);gap:18px;margin-top:20px}.panel{background:#102638cc;border:1px solid #2d6176;border-radius:14px;padding:18px;box-shadow:0 16px 35px #0004}label{display:block;font-weight:700;margin-bottom:8px}textarea{width:100%;min-height:280px;resize:vertical;border:1px solid #386e83;border-radius:9px;padding:12px;background:#07141d;color:inherit;font:14px ui-monospace,SFMono-Regular,Menlo,monospace}.controls{justify-content:flex-start;flex-wrap:wrap;margin-top:12px}button,.file{border:1px solid #58c7e8;border-radius:8px;padding:9px 12px;background:#123b50;color:#e7f5fa;font:inherit;font-weight:700;cursor:pointer;white-space:normal;overflow-wrap:anywhere;text-align:center}.danger{background:#a33842;border-color:#ff7a7a}.secondary{background:transparent}.file{display:inline-block;margin-top:12px}.file input{display:none}button:focus-visible,.file:focus-within,textarea:focus-visible{outline:3px solid #f3b24f;outline-offset:3px}.hint,#status,#summary,.panel p,label{overflow-wrap:anywhere}.panel p{color:#9fc1cf;text-wrap:pretty}.hint,#status{color:#9fc1cf;font-size:13px}ol{padding-left:20px;max-height:320px;overflow:auto}li{margin:7px 0;padding:6px;border-left:4px solid #ef5c5c;background:#192d3a;overflow-wrap:anywhere}.image-desk{margin-top:18px}canvas{max-width:100%;display:block;border:1px dashed #58c7e8;margin:12px 0;cursor:crosshair}@media(max-width:760px){main{padding:20px 12px}.grid{grid-template-columns:1fr}header{align-items:flex-start;flex-direction:column}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important;animation:none!important}} diff --git a/plugins/share-sanitizer/test/dist-size.test.mjs b/plugins/share-sanitizer/test/dist-size.test.mjs new file mode 100644 index 00000000..5cbbd97f --- /dev/null +++ b/plugins/share-sanitizer/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, 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/share-sanitizer/test/entry.test.mjs b/plugins/share-sanitizer/test/entry.test.mjs new file mode 100644 index 00000000..cb2f8db4 --- /dev/null +++ b/plugins/share-sanitizer/test/entry.test.mjs @@ -0,0 +1,135 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const preload = require('../preload/index.cjs'); + +test('host text and image payloads are dispatched by their declared type', async () => { + assert.deepEqual(await preload.consumeHostEntry({ type: 'over', payload: 'a@b.com' }), { kind: 'text', text: 'a@b.com' }); + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const dataUrl = `data:image/png;base64,${png.toString('base64')}`; + assert.deepEqual(await preload.consumeHostEntry({ type: 'img', payload: dataUrl }), { kind: 'image', dataUrl }); + await assert.rejects(preload.consumeHostEntry({ type: 'img', payload: 'plain text' }), { code: 'INVALID_TOOL_INPUT' }); +}); + +test('host-selected file paths are read only through the preload entry callback', async (t) => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'share-sanitizer-entry-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const textPath = path.join(directory, 'note.txt'); + await writeFile(textPath, 'secret@example.com'); + assert.deepEqual( + await preload.consumeHostEntry({ type: 'files', payload: [{ path: textPath, name: 'note.txt', isFile: true, isDirectory: false }] }), + { kind: 'text', text: 'secret@example.com' } + ); + const imagePath = path.join(directory, 'shot.png'); + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + await writeFile(imagePath, png); + const image = await preload.consumeHostEntry({ type: 'files', payload: [{ path: imagePath, name: 'shot.png', isFile: true, isDirectory: false }] }); + assert.equal(image.kind, 'image'); + assert.equal(image.dataUrl, `data:image/png;base64,${png.toString('base64')}`); + const linkPath = path.join(directory, 'link.txt'); + await symlink(textPath, linkPath); + await assert.rejects( + preload.consumeHostEntry({ type: 'files', payload: [{ path: linkPath, name: 'link.txt', isFile: true, isDirectory: false }] }), + { code: 'INVALID_TOOL_INPUT' } + ); +}); + +test('preload owns the single host entry handler and relays sanitized data to the UI bridge', async () => { + let hostHandler; + const target = { ztools: { onPluginEnter(handler) { hostHandler = handler; } } }; + const bridge = preload.attachShareSanitizer(target); + const received = new Promise((resolve) => bridge.onEntry(resolve)); + hostHandler({ type: 'over', payload: 'selected text' }); + assert.deepEqual(await received, { kind: 'text', text: 'selected text' }); + assert.equal(Object.hasOwn(bridge, 'readFile'), false); +}); + +test('void-return lifecycle hooks are registered once and clear each visit without re-subscribing', async () => { + let enterHandler; + let outHandler; + let enterSubscriptions = 0; + let outSubscriptions = 0; + const target = { + ztools: { + onPluginEnter(handler) { enterHandler = handler; enterSubscriptions += 1; }, + onPluginOut(handler) { outHandler = handler; outSubscriptions += 1; } + } + }; + const bridge = preload.attachShareSanitizer(target); + const received = []; + bridge.onEntry((entry) => received.push(entry)); + await enterHandler({ type: 'over', payload: 'first secret' }); + await new Promise((resolve) => setImmediate(resolve)); + outHandler(false); + assert.deepEqual(received.map((entry) => entry.kind), ['text', 'reset']); + assert.equal(enterSubscriptions, 1); + assert.equal(outSubscriptions, 1); + await enterHandler({ type: 'over', payload: 'late secret' }); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(received.map((entry) => entry.kind), ['text', 'reset', 'text']); + + outHandler(false); + await enterHandler({ type: 'over', payload: 'fresh secret' }); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(received.map((entry) => entry.kind), ['text', 'reset', 'text', 'reset', 'text']); + assert.equal(enterSubscriptions, 1); + assert.equal(outSubscriptions, 1); +}); + +test('exit before UI registration drops pending data with real void-return lifecycle hooks', async () => { + let enterHandler; + let outHandler; + const target = { + ztools: { + onPluginEnter(handler) { enterHandler = handler; }, + onPluginOut(handler) { outHandler = handler; } + } + }; + const bridge = preload.attachShareSanitizer(target); + await enterHandler({ type: 'over', payload: 'must not survive exit' }); + outHandler(false); + await new Promise((resolve) => setImmediate(resolve)); + const received = []; + bridge.onEntry((entry) => received.push(entry)); + assert.deepEqual(received, []); +}); + +test('plugin exit invalidates a pending entry already queued for UI delivery', async () => { + let enterHandler; + let outHandler; + const bridge = preload.attachShareSanitizer({ + ztools: { + onPluginEnter(handler) { enterHandler = handler; }, + onPluginOut(handler) { outHandler = handler; } + } + }); + await enterHandler({ type: 'over', payload: 'must-not-survive@example.com' }); + await new Promise((resolve) => setImmediate(resolve)); + const received = []; + bridge.onEntry((entry) => received.push(entry)); + outHandler(false); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(received, [{ kind: 'reset' }]); +}); + +test('file identity checks reject same-size replacements and in-place changes', () => { + const snapshot = (values = {}) => ({ isFile: () => true, dev: 1, ino: 2, size: 8, mtimeMs: 3, ctimeMs: 4, ...values }); + assert.equal(preload.fileSnapshotMatches(snapshot(), snapshot()), true); + assert.equal(preload.fileSnapshotMatches(snapshot(), snapshot({ ino: 9 })), false); + assert.equal(preload.fileSnapshotMatches(snapshot(), snapshot({ mtimeMs: 9 })), false); + assert.equal(preload.fileSnapshotMatches(snapshot(), snapshot({ ctimeMs: 9 })), false); +}); + +test('file handle reads stop at limit plus one without unbounded allocation', async () => { + const fakeHandle = (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 preload.readHandleBounded(fakeHandle(Buffer.from('abc')), 3)).toString(), 'abc'); + await assert.rejects(preload.readHandleBounded(fakeHandle(Buffer.from('abcd')), 3), { code: 'INVALID_TOOL_INPUT' }); +}); diff --git a/plugins/share-sanitizer/test/manifest.test.mjs b/plugins/share-sanitizer/test/manifest.test.mjs index 7cc410f8..2255a16b 100644 --- a/plugins/share-sanitizer/test/manifest.test.mjs +++ b/plugins/share-sanitizer/test/manifest.test.mjs @@ -1,4 +1,5 @@ -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'; +import test from 'node:test';import assert from 'node:assert/strict';import {access,readFile} from 'node:fs/promises';import path from 'node:path';import {fileURLToPath,pathToFileURL} from 'node:url'; const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'); test('manifest feature contract is narrow and preload bridge has no path exposure',async()=>{const manifest=JSON.parse(await readFile(path.join(root,'plugin.json'),'utf8')),preload=await readFile(path.join(root,'preload','index.cjs'),'utf8');assert.deepEqual(manifest.categories,['productivity','text','media']);const cmds=manifest.features[0].cmds;assert.equal(cmds.find((item)=>item?.type==='files').maxLength,1);assert.ok(cmds.find((item)=>item?.type==='img'));assert.ok(cmds.find((item)=>item?.type==='over'));assert.ok(!preload.includes('getPathForFile'));}); -test('renderer clears dynamic findings without HTML injection and labels metadata-only export accurately',async()=>{const app=await readFile(path.join(root,'src','ui','app.mjs'),'utf8'),html=await readFile(path.join(root,'src','ui','index.html'),'utf8');assert.ok(!app.includes('innerHTML'));assert.match(html,/Export re-encoded image/);assert.doesNotMatch(html,/Copy cleaned image/);}); +test('source manifest opens directly in ZTools development projects',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');await access(path.join(root,manifest.main));await access(path.join(root,manifest.preload));assert.match(pathToFileURL(path.join(root,manifest.main)).href,/^file:/);}); +test('renderer clears dynamic findings without HTML injection and keeps one stable entry subscriber',async()=>{const app=await readFile(path.join(root,'src','ui','app.mjs'),'utf8'),html=await readFile(path.join(root,'src','ui','index.html'),'utf8'),logo=await readFile(path.join(root,'logo.svg'),'utf8');assert.ok(!app.includes('innerHTML'));assert.match(app,/预览已截断/);assert.match(app,/replacement: '【已脱敏】'/);assert.match(app,/resetSensitiveState/);assert.match(app,/releaseImageObjectUrl/);assert.match(app,/function subscribeEntry/);assert.match(app,/if \(stopEntry\) return/);assert.doesNotMatch(app,/queueMicrotask\(subscribeEntry\)/);assert.match(app,/stopEntry\?\./);assert.match(html,/导出重新编码的图片/);assert.doesNotMatch(html,/Copy cleaned image/);assert.match(html,/lang="zh-CN"/);assert.match(logo,/aria-label="分享前脱敏"/);}); diff --git a/plugins/share-sanitizer/test/mcp.test.mjs b/plugins/share-sanitizer/test/mcp.test.mjs new file mode 100644 index 00000000..24dd9270 --- /dev/null +++ b/plugins/share-sanitizer/test/mcp.test.mjs @@ -0,0 +1,123 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +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')); + +test('manifest declarations and native registrations stay one-to-one', () => { + const handlers = new Map(); + const target = { ztools: { registerTool(name, handler) { handlers.set(name, handler); } } }; + preload.attachShareSanitizer(target); + assert.deepEqual([...handlers.keys()].sort(), Object.keys(manifest.tools).sort()); + assert.equal(manifest.tools.detect_text.inputSchema.properties.text.maxLength, preload.MCP_TEXT_BYTES); + assert.equal(manifest.tools.redact_text.inputSchema.properties.text.maxLength, preload.MCP_REDACT_TEXT_BYTES); + assert.equal(typeof target.shareSanitizer, 'object'); +}); + +test('older hosts degrade to the unchanged human bridge', () => { + const copied = []; + const target = { ztools: { copyText(value) { copied.push(value); } } }; + assert.doesNotThrow(() => preload.attachShareSanitizer(target)); + target.shareSanitizer.copyText('ok'); + assert.deepEqual(copied, ['ok']); +}); + +test('one failed registration does not block the UI or the other tool', () => { + const names = []; + const target = { ztools: { registerTool(name) { if (name === 'detect_text') throw Error('unsupported'); names.push(name); } } }; + assert.doesNotThrow(() => preload.attachShareSanitizer(target)); + assert.equal(typeof target.shareSanitizer, 'object'); + assert.deepEqual(names, ['redact_text']); +}); + +test('detect handler never returns matched values', async () => { + const result = await preload.detectForMcp({ text: 'mail a@b.com and token=abcdefghijklmnop' }); + assert.equal(result.counts.total, result.findings.length); + assert.equal(result.counts.returned, result.findings.length); + assert.equal(result.truncated, false); + assert.ok(result.findings.length >= 2); + assert.ok(result.findings.every((item) => Object.keys(item).sort().join(',') === 'confidence,end,start,type')); + assert.doesNotMatch(JSON.stringify(result), /a@b\.com|abcdefghijklmnop/); +}); + +test('redact handler and documentation agree that whitelisted text is preserved only in output text', async () => { + const result = await preload.redactForMcp({ text: 'safe@x.com bad@y.com', whitelist: ['safe@x.com'], replacement: '***', enabled: { email: true, jwt: false } }); + const readme = await readFile(path.join(root, 'README.md'), 'utf8'); + assert.equal(result.text, 'safe@x.com ***'); + assert.equal(result.counts.total, 1); + assert.equal(Object.hasOwn(result.findings[0], 'value'), false); + assert.doesNotMatch(JSON.stringify(result.findings), /safe@x\.com|bad@y\.com/); + assert.match(readme, /白名单中的值则会按设计原样保留/); + assert.match(readme, /必须控制清理后文本的传播范围/); +}); + +test('strict MCP validation rejects unknown, hostile and oversized input', async () => { + await assert.rejects(preload.detectForMcp({ text: 'x', maxInput: 2 }), { code: 'INVALID_TOOL_INPUT' }); + await assert.rejects(preload.detectForMcp({ text: 'x', enabled: { unknown: true } }), { code: 'INVALID_TOOL_INPUT' }); + await assert.rejects(preload.detectForMcp({ text: 'x', enabled: { email: 'yes' } }), { code: 'INVALID_TOOL_INPUT' }); + await assert.rejects(preload.detectForMcp(JSON.parse('{"text":"x","__proto__":{}}')), { code: 'INVALID_TOOL_INPUT' }); + await assert.rejects(preload.detectForMcp(Object.create({ text: 'inherited' })), { code: 'INVALID_TOOL_INPUT' }); + await assert.rejects(preload.detectForMcp({ text: 'x'.repeat(512 * 1024 + 1) }), { code: 'INVALID_TOOL_INPUT' }); + await assert.rejects(preload.redactForMcp({ text: 'x', replacement: 'r'.repeat(65) }), { code: 'INVALID_TOOL_INPUT' }); + await assert.rejects(preload.redactForMcp({ text: 'bad@y.com', replacement: 'bad@y.com', enabled: { email: true } }), (error) => error?.code === 'INVALID_TOOL_INPUT' && /replacement 不能包含/.test(error.message)); + await assert.rejects(preload.redactForMcp({ text: 'a@b.com/abcdefghijkl', replacement: 'Bearer ', enabled: { email: true, bearer: true, phone_cn: false, ipv4: false, win_path: false, posix_path: false, api_key: false, jwt: false, id_cn: false } }), (error) => error?.code === 'INVALID_TOOL_INPUT' && /脱敏后的文本仍包含/.test(error.message)); + await assert.rejects(preload.detectForMcp({ text: 'x', whitelist: Array.from({ length: 201 }, () => 'x') }), { code: 'INVALID_TOOL_INPUT' }); + await assert.rejects(preload.redactForMcp({ text: 'x'.repeat(448 * 1024 + 1) }), { code: 'INVALID_TOOL_INPUT' }); + await assert.rejects(preload.detectForMcp({ text: 'x', whitelist: Array.from({ length: 17 }, () => 'w'.repeat(4096)) }), { code: 'INVALID_TOOL_INPUT' }); + await assert.rejects(preload.detectForMcp({ text: String.fromCharCode(0).repeat(200 * 1024) }), { code: 'INVALID_TOOL_INPUT' }); +}); + +test('redact handler fully cleans match 501 while bounding returned evidence', async () => { + const secrets = Array.from({ length: 501 }, (_, index) => `person${index}@x.example`); + const result = await preload.redactForMcp({ + text: secrets.join(' '), + replacement: '***', + enabled: { email: true, phone_cn: false, ipv4: false, win_path: false, posix_path: false, bearer: false, api_key: false, jwt: false, id_cn: false } + }); + assert.equal(result.counts.total, 501); + assert.equal(result.counts.returned, 500); + assert.equal(result.findings.length, 500); + assert.equal(result.truncated, true); + assert.equal(result.text.split('***').length - 1, 501); + assert.ok(Buffer.byteLength(JSON.stringify(result), 'utf8') <= preload.MCP_RESPONSE_BYTES); + for (const secret of secrets) assert.equal(result.text.includes(secret), false); +}); + +test('detect handler declares truncated evidence instead of implying 500 is the total', async () => { + const result = await preload.detectForMcp({ + text: Array.from({ length: 501 }, (_, index) => `person${index}@x.example`).join(' '), + enabled: { email: true, phone_cn: false, ipv4: false, win_path: false, posix_path: false, bearer: false, api_key: false, jwt: false, id_cn: false } + }); + assert.equal(result.counts.total, null); + assert.equal(result.counts.returned, 500); + assert.equal(result.counts.totalAtLeast, 501); + assert.equal(result.truncated, true); +}); + +test('redact handler rejects serialized response amplification without returning text', async () => { + const repeatedEmail = Array.from({ length: 9000 }, () => 'a@b.co').join(' '); + await assert.rejects( + preload.redactForMcp({ text: repeatedEmail, replacement: 'r'.repeat(64), enabled: { email: true } }), + (error) => error?.code === 'INVALID_TOOL_INPUT' && /工具响应超过 512 KiB 的 MCP 上限/.test(error.message) + ); + const escapedReplacement = String.fromCharCode(0).repeat(64); + const escapedExpansion = Array.from({ length: 2000 }, () => 'a@b.co').join(' '); + await assert.rejects( + preload.redactForMcp({ text: escapedExpansion, replacement: escapedReplacement, enabled: { email: true } }), + (error) => error?.code === 'INVALID_TOOL_INPUT' && /工具响应超过 512 KiB 的 MCP 上限/.test(error.message) + ); +}); + +test('redact handler refuses inputs that exceed complete redaction capacity', async () => { + const source = Array.from({ length: 10001 }, (_, index) => `p${index}@x.co`).join(' '); + await assert.rejects( + preload.redactForMcp({ text: source, enabled: { email: true } }), + (error) => error?.code === 'INVALID_TOOL_INPUT' && /10,000 项完整脱敏安全上限/.test(error.message) + ); +}); diff --git a/plugins/share-sanitizer/test/sanitize.test.mjs b/plugins/share-sanitizer/test/sanitize.test.mjs index 18d4b8ec..27e31924 100644 --- a/plugins/share-sanitizer/test/sanitize.test.mjs +++ b/plugins/share-sanitizer/test/sanitize.test.mjs @@ -2,6 +2,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { test('detects sensitive values with stable non-overlapping spans',()=>{const text='a@b.com 13800138000 11010519491231002X 192.168.0.1 C:\\Users\\me\\x /var/tmp/x Bearer abcdefghijklmnop eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.signature';const out=detectSensitive(text);assert.ok(out.some(x=>x.type==='email'));assert.ok(out.some(x=>x.type==='phone_cn'));assert.ok(out.some(x=>x.type==='id_cn'));assert.ok(out.some(x=>x.type==='win_path'));assert.ok(out.every((x,i)=>!i||out[i-1].end<=x.start));}); test('does not accept invalid Chinese IDs or phone substrings',()=>{const out=detectSensitive('x113800138000 110105194912310021');assert.equal(out.some(x=>x.type==='phone_cn'||x.type==='id_cn'),false);}); test('whitelist and replacement are deterministic',()=>{const out=redactText('safe@x.com bad@y.com',{whitelist:['safe@x.com'],replacement:'***'});assert.equal(out.text,'safe@x.com ***');assert.equal(out.findings.length,1);}); +test('replacement fails closed when it contains a non-whitelisted value matched by an enabled rule',()=>{assert.throws(()=>redactText('bad@y.com',{replacement:'other@y.com',enabled:{email:true}}),/replacement 不能包含/);const allowed=redactText('bad@y.com',{replacement:'other@y.com',whitelist:['other@y.com'],enabled:{email:true}});assert.equal(allowed.text,'other@y.com');const out=redactText('bad@y.com',{replacement:'【邮件已隐藏】',enabled:{email:true}});assert.equal(out.text,'【邮件已隐藏】');assert.equal(out.text.includes('bad@y.com'),false);}); +test('replacement fails closed when boundary concatenation creates a new secret unless explicitly whitelisted',()=>{const enabled={email:true,bearer:true,phone_cn:false,ipv4:false,win_path:false,posix_path:false,api_key:false,jwt:false,id_cn:false};assert.throws(()=>redactText('a@b.com/abcdefghijkl',{replacement:'Bearer ',enabled}),/脱敏后的文本仍包含/);const out=redactText('a@b.com/abcdefghijkl',{replacement:'Bearer ',enabled,whitelist:['Bearer /abcdefghijkl']});assert.equal(out.text,'Bearer /abcdefghijkl');}); +test('detection marks a bounded preview while redaction covers matches beyond item 500',()=>{const secrets=Array.from({length:501},(_,i)=>`person${i}@x.example`);const source=secrets.join(' ');const preview=detectSensitive(source,{enabled:{email:true,phone_cn:false,ipv4:false,win_path:false,posix_path:false,bearer:false,api_key:false,jwt:false,id_cn:false}});assert.equal(preview.length,500);assert.equal(preview.truncated,true);assert.equal(preview.total,null);assert.equal(preview.totalAtLeast,501);const out=redactText(source,{replacement:'***',enabled:{email:true,phone_cn:false,ipv4:false,win_path:false,posix_path:false,bearer:false,api_key:false,jwt:false,id_cn:false}});assert.equal(out.findings.length,501);assert.equal(out.total,501);assert.equal(out.truncated,false);assert.equal(out.text.split('***').length-1,501);for(const secret of secrets)assert.equal(out.text.includes(secret),false);}); +test('redaction rejects rather than returning a partial result beyond its complete-match limit',()=>{const source=Array.from({length:10001},(_,i)=>`p${i}@x.co`).join(' ');assert.throws(()=>redactText(source,{enabled:{email:true,phone_cn:false,ipv4:false,win_path:false,posix_path:false,bearer:false,api_key:false,jwt:false,id_cn:false}}),/10,000 项完整脱敏安全上限/);}); test('platform path shapes are found without host dependence',()=>{const b=String.fromCharCode(92);const out=detectSensitive(`D:${b}a${b}b ${b}${b}server${b}share${b}x /opt/app/x`);assert.ok(out.filter(x=>x.type==='win_path').length>=2);assert.ok(out.some(x=>x.type==='posix_path'));}); -test('caps byte input and whitelist growth',()=>{assert.throws(()=>detectSensitive('x'.repeat(1024*1024+1)),/safety limit/);assert.throws(()=>detectSensitive('x',{whitelist:Array.from({length:201},(_,i)=>String(i))}),/Whitelist/);}); +test('caps byte input and whitelist growth',()=>{assert.throws(()=>detectSensitive('x'.repeat(1024*1024+1)),/安全上限/);assert.throws(()=>detectSensitive('x',{whitelist:Array.from({length:201},(_,i)=>String(i))}),/允许列表/);}); test('image export needs explicit metadata-only confirmation without masks',()=>{assert.equal(imageExportDecision(0).ok,false);assert.deepEqual(imageExportDecision(0,true),{ok:true,mode:'metadata-only'});assert.deepEqual(imageExportDecision(1),{ok:true,mode:'redacted'});}); From ca739e73cee140da2b52dc3bd188a59a5c426b03 Mon Sep 17 00:00:00 2001 From: wangzihao Date: Tue, 1 Sep 2026 17:27:46 +0800 Subject: [PATCH 3/3] fix(ztools-spec): fix platform placement and preload module declaration --- plugins/share-sanitizer/package.json | 12 +++++++++--- plugins/share-sanitizer/preload/package.json | 3 +++ 2 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 plugins/share-sanitizer/preload/package.json diff --git a/plugins/share-sanitizer/package.json b/plugins/share-sanitizer/package.json index 93784881..d4eaf21d 100644 --- a/plugins/share-sanitizer/package.json +++ b/plugins/share-sanitizer/package.json @@ -2,7 +2,13 @@ "name": "share-sanitizer", "version": "0.1.0", "private": true, - "type": "module", - "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"} + "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/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" +}