diff --git a/plugins/har-doctor/.gitignore b/plugins/har-doctor/.gitignore new file mode 100644 index 00000000..1eae0cf6 --- /dev/null +++ b/plugins/har-doctor/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/plugins/har-doctor/CHANGELOG.md b/plugins/har-doctor/CHANGELOG.md new file mode 100644 index 00000000..cab1dcb3 --- /dev/null +++ b/plugins/har-doctor/CHANGELOG.md @@ -0,0 +1,16 @@ +# 更新日志 + +## 0.1.0 + +- 提供离线 HAR 诊断能力。 +- 为 ZTools 2.4+ 增加有界的内联分析工具和一次性人工授权文件 MCP 分析工具。 +- 对具有可靠前缀的凭据、完整或截断的私钥区块、URL 路径与最终字符串进行脱敏;在不丢失汇总数量的前提下,将序列化 MCP 报告限制为 512 KiB。 +- 为人工输出安全处理结构化 URL 组件、瀑布流请求方法和动态 JSON 键;保留重复查询参数与计数表冲突后的正确数量,同时避免原型污染。 +- 在原始 URL 路径分段间传递私钥脱敏状态,同时保持斜杠拓扑和编码斜杠边界。 +- 路径 PEM 扫描在原始字符串索引上线性执行,跟踪嵌套标签,并复用于无效 URL;对格式异常的百分号转义或备用查询键按失败关闭处理。 +- 自由字符串复用嵌套标记扫描器,并在不中断分析的情况下替换无法编码的 UTF-16 URL 组件。 +- 根目录使用 ZTools 可直接加载的源码入口,CI 仍打包自包含的 `dist` 清单。 +- 在未压缩 `dist` 超过 14.5 MB 时拒绝发布,并打印精确的已验证字节数。 +- 将人类界面、状态提示、文件选择对话框、诊断说明和导出报告完整本地化为简体中文。 +- 将文件授权绑定到路径与打开句柄的 ctime 等完整身份,并在读取前后复验;同 inode 改写与读取中途改写均按失败关闭处理并释放句柄。 +- 人工瀑布流与 Markdown 使用“已脱敏”占位符,MCP 与 JSON 继续保留稳定的 `[redacted]` 协议值。 diff --git a/plugins/har-doctor/README.md b/plugins/har-doctor/README.md new file mode 100644 index 00000000..aea09ecc --- /dev/null +++ b/plugins/har-doctor/README.md @@ -0,0 +1,25 @@ +# HAR 诊断台 + +界面、状态提示、文件对话框和导出的 Markdown 标题均使用简体中文;MCP 工具名、schema 字段和错误码保持稳定,便于 Agent 调用。 + +这是一款离线 HAR 瀑布流诊断工具。它可接收最多两个 `.har` 文件,默认遮蔽常见凭据,并报告延迟、错误、重定向、缓存/CORS/安全状况、重复传输以及环境差异。 + +插件不会发起网络请求。每个文件上限为 20 MiB,条目上限为 5,000。纯 JavaScript 路径契约、Node 测试、构建验证和 Chromium 渲染已通过;在真实 Windows、macOS 和 Linux ZTools 宿主中的加载与文件对话框行为仍未测试。 + +## 开发与打包 + +根目录 `plugin.json` 直接指向 `src/main/index.html`、`src/preload/index.cjs` 和 `logo.svg`,因此 ZTools 开发项目无需解析 `development` 覆盖项即可加载源码包。仓库 CI 仍会执行包内的 `build` 脚本并打包 `dist`:源入口目录不包含嵌套清单,构建则会生成自包含的 `dist/plugin.json`,其入口为 `main/index.html`、`preload/index.cjs` 和 `logo.svg`。 + +`npm run verify-dist` 会递归统计未压缩的 `dist` 目录,打印精确字节数,并在超过 14.5 MB(14,500,000 字节)安全上限时失败。 + +## Agent / MCP 使用 + +ZTools 2.4+ 会将清单中的 `analyze_inline` 和 `analyze_approved_files` 工具暴露给 Agent,完整名称分别为 `har_doctor_analyze_inline` 和 `har_doctor_analyze_approved_files`。两者都会复用界面中的 `parseHar`、`analyze`、`diffReports`、脱敏器和 Markdown 格式化器。旧版宿主没有 `registerTool` 时,人工界面仍可使用,但不会提供 Agent 工具。 + +每份内联 HAR 文本的 UTF-8 上限为 512 KiB,两份合计上限为 768 KiB,为宿主 1 MiB 的 MCP 请求体边界预留空间。更大的 HAR 文件(每份最多 20 MiB)必须由用户在界面中选择;已授权文件工具不接受路径,并且只会消费一次有效期为五分钟的文件描述符授权。授权会将规范路径和打开的描述符绑定到大小、设备号、inode、mtime 和 ctime;有界读取前后都会复验这两组身份,且无论成功还是失败都会清理授权并关闭描述符。报告不包含原始 HAR 文本:`detailLimit` 只接受 1—50,每类明细以及域名/类型表都会保留总数与截断标记,所有输出字符串和脱敏 URL 都按 UTF-8 字节裁剪。当请求页面的序列化大小可能超过 512 KiB 时,有效明细上限会自动降低,并设置 `responseTruncated`,同时保持汇总总数不变。最终字符串脱敏会覆盖 Bearer/JWT/赋值形式,以及具有可靠前缀的 GitHub、OpenAI、AWS 访问密钥和 PEM 私钥凭据。自由字符串与 URL 路径共用单向扫描、基于原始索引的标记器,并使用嵌套 PEM 标签栈;私钥区块会一直遮蔽到匹配的 END 清空栈,或在内容截断/不匹配时一直遮蔽到可用字符串末尾。URL 脱敏感知组件边界:主机名使用可解析的匿名域名,所有查询键都映射为进程内稳定的匿名标识,所有查询值都被遮蔽,格式异常时的备用查询键按失败关闭处理,片段则会被移除。路径处理会在原始 `/` 分段之间传递栈状态,同时保持斜杠拓扑和编码斜杠边界,因此 Unicode 大小写折叠与 Base64 `/` 字符都不会暴露后续密钥材料。无效 URL 路径使用同一状态机:无法解码的百分号转义会遮蔽当前与后续所有路径分段,无法编码的 UTF-16 组件(例如孤立代理项)则会在不改变斜杠数量的情况下就地替换。瀑布流请求方法也会在进入人工界面前脱敏。人工瀑布流和复制的 Markdown 使用 `[已脱敏]`;JSON 和 MCP 契约仍保留稳定的 `[redacted]` 机器标记。 + +人工 JSON 导出会对对象键和值应用同一套脱敏器。域名和 MIME 计数表只接受有限数值的数据属性;脱敏后键冲突会合并求和,以保持请求总数准确,其他非数值冲突则会添加确定性的 ` [collision N]` 后缀。即使 `password` 等普通敏感值为数字,也仍会遮蔽。导出对象使用空原型,重命名 `__proto__`、`constructor` 和 `prototype`,并显式定义数据属性,以避免原型污染。 + +## 隐私投影说明(当前实现) + +主机名、账号/租户路径段、邮箱、手机号、证件号、IPv4/IPv6、短别名(`/u/alice`、`/@bob`)和长标识都会映射为进程内稳定的匿名占位符;所有查询参数名同样匿名化,所有参数值都会遮蔽。正常的 ZTools/Electron Web Crypto 环境为每个新原值分配独立的 128-bit 随机 ID,因此匿名 ID 与原值无可计算关系,已知一个用户名的映射不能用来预测另一个低熵用户名或租户。如果旧运行时没有 Web Crypto,后备路径使用 32 位十六进制分配序号:它仍不基于原值,但不承诺 ID 顺序的不可预测性。无效 URL 不会保留原始基串,只保留全脱敏的斜杠拓扑。请求方法仅输出 HTTP 标准方法白名单中的值,其余统一投影为 `OTHER`;MIME 类型仅输出白名单类型或 `unknown`。授权关闭遇到可恢复的本地文件系统错误时会撤销读取授权,重试队列仅保留 fd 和关闭状态,不保留原始路径或文件元数据;后续 clear、TTL、插件退出和新授权都会立即重试,后台计时器仅作为兜底。 diff --git a/plugins/har-doctor/logo.svg b/plugins/har-doctor/logo.svg new file mode 100644 index 00000000..df623e56 --- /dev/null +++ b/plugins/har-doctor/logo.svg @@ -0,0 +1 @@ + diff --git a/plugins/har-doctor/package-lock.json b/plugins/har-doctor/package-lock.json new file mode 100644 index 00000000..4854c8cc --- /dev/null +++ b/plugins/har-doctor/package-lock.json @@ -0,0 +1,3 @@ +{ + "name": "har-doctor", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "har-doctor", "version": "0.1.0", "engines": { "node": ">=16" } } } +} diff --git a/plugins/har-doctor/package.json b/plugins/har-doctor/package.json new file mode 100644 index 00000000..d517e7de --- /dev/null +++ b/plugins/har-doctor/package.json @@ -0,0 +1,14 @@ +{ + "name": "har-doctor", + "version": "0.1.0", + "type": "module", + "private": true, + "scripts": { + "test": "node --test", + "build": "npm test && node scripts/build.mjs && node scripts/verify-dist.mjs", + "verify-dist": "node scripts/verify-dist.mjs" + }, + "engines": { + "node": ">=16" + } +} diff --git a/plugins/har-doctor/plugin.json b/plugins/har-doctor/plugin.json new file mode 100644 index 00000000..fb82502b --- /dev/null +++ b/plugins/har-doctor/plugin.json @@ -0,0 +1,73 @@ +{ + "name": "har-doctor", + "version": "0.1.0", + "title": "HAR 医生", + "description": "离线诊断 HAR 网络瀑布,并优先保护报告隐私。", + "author": "harris", + "platform": [ + "darwin", + "win32", + "linux" + ], + "categories": [ + "development", + "network" + ], + "main": "src/main/index.html", + "preload": "src/preload/index.cjs", + "logo": "logo.svg", + "features": [ + { + "code": "analyze-har", + "icon": "logo.svg", + "explain": "分析一到两个 HAR 文件", + "cmds": [ + "HAR 分析", + "网络瀑布诊断" + ] + } + ], + "tools": { + "analyze_inline": { + "title": "分析内联 HAR", + "description": "离线分析一个或两个有界 HAR JSON 文本,返回脱敏且截断的诊断报告。", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "har": { + "type": "string", + "maxLength": 524288 + }, + "compareHar": { + "type": "string", + "maxLength": 524288 + }, + "detailLimit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "har" + ] + } + }, + "analyze_approved_files": { + "title": "分析用户已授权的 HAR 文件", + "description": "一次性消费用户最近五分钟内在插件界面选择的一到两个 HAR 文件,不接受路径。", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "detailLimit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + } + } + } + } +} diff --git a/plugins/har-doctor/scripts/build.mjs b/plugins/har-doctor/scripts/build.mjs new file mode 100644 index 00000000..ca755cde --- /dev/null +++ b/plugins/har-doctor/scripts/build.mjs @@ -0,0 +1,2 @@ +import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +const root=path.dirname(path.dirname(fileURLToPath(import.meta.url))),dist=path.join(root,'dist');await rm(dist,{recursive:true,force:true});await mkdir(dist,{recursive:true});await cp(path.join(root,'src'),dist,{recursive:true});await cp(path.join(root,'logo.svg'),path.join(dist,'logo.svg'));const manifest=JSON.parse(await readFile(path.join(root,'plugin.json'),'utf8'));delete manifest.development;manifest.main='main/index.html';manifest.preload='preload/index.cjs';manifest.logo='logo.svg';await writeFile(path.join(dist,'plugin.json'),JSON.stringify(manifest,null,2)); diff --git a/plugins/har-doctor/scripts/dist-size.mjs b/plugins/har-doctor/scripts/dist-size.mjs new file mode 100644 index 00000000..d249d628 --- /dev/null +++ b/plugins/har-doctor/scripts/dist-size.mjs @@ -0,0 +1,42 @@ +import { lstat, readdir } from 'node:fs/promises'; +import path from 'node:path'; + +export const DIST_SIZE_LIMIT = 14_500_000; + +function relativeEntry(root, entryPath) { + return path.relative(root, entryPath) || '.'; +} + +function unsupportedEntry(root, entryPath) { + return new Error(`unsupported dist entry: ${relativeEntry(root, entryPath)}`); +} + +export async function directoryBytes( + directory, + { root = directory, readDirectory = readdir, inspectPath = lstat } = {} +) { + let bytes = 0; + + for (const entry of await readDirectory(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (!entry.isDirectory() && !entry.isFile()) throw unsupportedEntry(root, entryPath); + + const metadata = await inspectPath(entryPath); + if (metadata.isSymbolicLink()) throw unsupportedEntry(root, entryPath); + if (entry.isDirectory() && metadata.isDirectory()) { + bytes += await directoryBytes(entryPath, { root, readDirectory, inspectPath }); + } else if (entry.isFile() && metadata.isFile()) { + bytes += metadata.size; + } else { + throw unsupportedEntry(root, entryPath); + } + } + + return bytes; +} + +export function assertDistSize(bytes, limit = DIST_SIZE_LIMIT) { + if (!Number.isSafeInteger(bytes) || bytes < 0) throw new TypeError('dist size must be a non-negative safe integer'); + if (bytes > limit) throw new Error(`dist size ${bytes} exceeds the 14.5 MB safety limit (${limit} bytes)`); + return bytes; +} diff --git a/plugins/har-doctor/scripts/verify-dist.mjs b/plugins/har-doctor/scripts/verify-dist.mjs new file mode 100644 index 00000000..05896376 --- /dev/null +++ b/plugins/har-doctor/scripts/verify-dist.mjs @@ -0,0 +1,18 @@ +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.dirname(path.dirname(fileURLToPath(import.meta.url))); +const dist = path.join(root, 'dist'); + +for (const file of ['plugin.json', 'main/index.html', 'preload/index.cjs', 'core/analyze.js', 'logo.svg']) await access(path.join(dist, file)); +const manifest = JSON.parse(await readFile(path.join(dist, 'plugin.json'), 'utf8')); +if (manifest.development || manifest.main !== 'main/index.html') throw new Error('Invalid dist manifest'); +for (const tool of ['analyze_inline', 'analyze_approved_files']) if (!manifest.tools?.[tool]) throw new Error(`missing MCP tool ${tool}`); +if (await readFile(path.join(root, 'src', 'preload', 'index.cjs'), 'utf8') !== await readFile(path.join(dist, 'preload', 'index.cjs'), 'utf8')) throw new Error('dist preload is stale'); +if (await readFile(path.join(root, 'src', 'core', 'analyze.js'), 'utf8') !== await readFile(path.join(dist, 'core', 'analyze.js'), 'utf8')) throw new Error('dist analyze core is stale'); + +const distBytes = await directoryBytes(dist); +assertDistSize(distBytes); +console.log(`har-doctor dist verified (${distBytes} bytes)`); diff --git a/plugins/har-doctor/src/core/analyze.js b/plugins/har-doctor/src/core/analyze.js new file mode 100644 index 00000000..4fefadd2 --- /dev/null +++ b/plugins/har-doctor/src/core/analyze.js @@ -0,0 +1,305 @@ +const SENSITIVE = /authorization|cookie|set-cookie|token|access_token|session|secret|api[-_]?key|password|signature|sign/i; +const SECRET_VALUE = /\bBearer\s+[A-Za-z0-9._~+\/-]{8,}|\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b|\b(?:api[_-]?key|token|secret|password|signature)\s*[:=]\s*['"]?(?!(?:\[redacted\]|%5Bredacted%5D)(?:['"]|$|[&#\s]))[^\s,'";]{6,}/gi; +const PREFIXED_SECRETS = [ + /github_pat_[A-Za-z0-9_]{20,}/gi, + /gh[pousr]_[A-Za-z0-9_]{20,}/gi, + /sk-(?:proj-)?[A-Za-z0-9_-]{16,}/gi, + /(?:AKIA|ASIA)[A-Z0-9]{16}/gi +]; +const PEM_PATH_MARKER = /-----(BEGIN|END) ((?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED) )?PRIVATE KEY)-----/gi; +const RESERVED_KEYS = new Set(['__proto__', 'constructor', 'prototype']); +const MAX_BYTES = 20 * 1024 * 1024; +const MAX_ENTRIES = 5000; +const SAFE_METHODS = new Set(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'TRACE', 'CONNECT']); +const SAFE_MIME_TYPES = new Set([ + 'application/json', 'application/ld+json', 'application/xml', 'application/pdf', 'application/javascript', 'application/wasm', + 'text/plain', 'text/html', 'text/css', 'text/csv', 'text/javascript', 'text/xml', + 'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml', 'image/x-icon', + 'audio/mpeg', 'audio/ogg', 'audio/wav', 'video/mp4', 'video/webm', + 'font/woff', 'font/woff2', 'font/ttf', 'font/otf' +]); +// IDs are allocated independently from their values, so a known username-to-id +// pair cannot predict a second low-entropy username or tenant. +const anonymizationByValue = new Map(); +const anonymizationById = new Map(); +let fallbackOpaqueCounter = 0n; + +export function fileContract(platform, filename) { + const base = String(filename || '').split(platform === 'win32' ? /[\\/]/ : /\//).pop(); + return { platform, accepted: /\.har$/i.test(base), base }; +} + +export function parseHar(text) { + if (utf8Length(String(text)) > MAX_BYTES) throw new Error('HAR 超过 20 MiB 上限'); + let value; try { value = JSON.parse(text); } catch { throw new Error('HAR JSON 无效'); } + if (!value || !value.log || !Array.isArray(value.log.entries)) throw new Error('HAR 必须包含 log.entries'); + if (value.log.entries.length > MAX_ENTRIES) throw new Error('HAR 超过 5,000 条记录上限'); + return value; +} + +function utf8Length(text) { return typeof TextEncoder !== 'undefined' ? new TextEncoder().encode(text).length : Buffer.byteLength(text, 'utf8'); } + +function redactNonPemSecrets(value, replacement) { + let output = String(value); + for (const pattern of PREFIXED_SECRETS) output = output.replace(pattern, replacement); + return output.replace(SECRET_VALUE, replacement); +} + +function appendReplacement(output, replacement) { + return output.endsWith(replacement) ? output : output + replacement; +} + +function redactPemText(value, state, replacement) { + const text = String(value); + let cursor = 0; + let output = ''; + for (const match of text.matchAll(PEM_PATH_MARKER)) { + const markerKind = match[1].toUpperCase(); + const markerLabel = match[2].toUpperCase(); + if (state.pemLabels.length) { + output = appendReplacement(output, replacement); + cursor = match.index + match[0].length; + if (markerKind === 'BEGIN') state.pemLabels.push(markerLabel); + else if (markerLabel === state.pemLabels[state.pemLabels.length - 1]) state.pemLabels.pop(); + continue; + } + output += redactNonPemSecrets(text.slice(cursor, match.index), replacement); + output = appendReplacement(output, replacement); + if (markerKind === 'BEGIN') state.pemLabels.push(markerLabel); + cursor = match.index + match[0].length; + } + if (state.pemLabels.length) return appendReplacement(output, replacement); + return output + redactNonPemSecrets(text.slice(cursor), replacement); +} + +export function redactString(value, replacement = '[redacted]') { + const safeReplacement = String(replacement); + return redactPemText(value, { pemLabels: [] }, safeReplacement); +} + +function redactedKey(rawKey) { + const value = redactString(rawKey); + return RESERVED_KEYS.has(value) ? `[reserved ${value}]` : value; +} + +function redactCountMap(value) { + const output = Object.create(null); + if (!value || typeof value !== 'object' || Array.isArray(value)) return output; + for (const rawKey of Reflect.ownKeys(value)) { + if (typeof rawKey !== 'string') continue; + const descriptor = Object.getOwnPropertyDescriptor(value, rawKey); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value') || !Number.isFinite(descriptor.value)) continue; + const safeKey = redactedKey(rawKey); + if (Object.hasOwn(output, safeKey)) output[safeKey] += descriptor.value; + else Object.defineProperty(output, safeKey, { value: descriptor.value, enumerable: true, configurable: true, writable: true }); + } + return output; +} + +export function redact(value) { + if (Array.isArray(value)) return value.map(redact); + if (typeof value === 'string') return redactString(value); + if (!value || typeof value !== 'object') return value; + const output = Object.create(null); + const usedKeys = new Set(); + const nameDescriptor = Object.getOwnPropertyDescriptor(value, 'name'); + const sensitiveRecord = Boolean(nameDescriptor?.enumerable && Object.hasOwn(nameDescriptor, 'value') && typeof nameDescriptor.value === 'string' && SENSITIVE.test(nameDescriptor.value)); + for (const rawKey of Reflect.ownKeys(value)) { + if (typeof rawKey !== 'string') continue; + const descriptor = Object.getOwnPropertyDescriptor(value, rawKey); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) continue; + const baseKey = redactedKey(rawKey); + const safeValue = rawKey === 'domains' || rawKey === 'types' + ? redactCountMap(descriptor.value) + : (sensitiveRecord && rawKey === 'value') || SENSITIVE.test(rawKey) ? '[redacted]' : redact(descriptor.value); + if (usedKeys.has(baseKey) && Number.isFinite(output[baseKey]) && Number.isFinite(safeValue)) { + output[baseKey] += safeValue; + continue; + } + let safeKey = baseKey; + for (let index = 2; usedKeys.has(safeKey); index += 1) safeKey = `${baseKey} [collision ${index}]`; + usedKeys.add(safeKey); + Object.defineProperty(output, safeKey, { value: safeValue, enumerable: true, configurable: true, writable: true }); + } + return output; +} + +function headers(items) { return Object.fromEntries((items || []).map(({ name, value }) => [String(name).toLowerCase(), String(value || '')])); } +function host(url) { try { return new URL(url).host; } catch { return '(无效 URL)'; } } +function decodePathSegment(value) { try { return { ok: true, value: decodeURIComponent(value) }; } catch { return { ok: false, value: '' }; } } +function safeEncodeComponent(value) { try { return encodeURIComponent(value); } catch { return '%5Bredacted%5D'; } } +function stableId(value) { + const raw = String(value); + const existing = anonymizationByValue.get(raw); + if (existing) return existing; + let id; + do { + if (globalThis.crypto?.getRandomValues) { + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + id = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); + } else { + // Legacy runtimes without Web Crypto still get an allocation-order token + // with no computable relationship to the private value. + fallbackOpaqueCounter += 1n; + id = fallbackOpaqueCounter.toString(16).padStart(32, '0'); + } + } while (anonymizationById.has(id)); + anonymizationByValue.set(raw, id); + anonymizationById.set(id, raw); + return id; +} +function privatePlaceholder(kind, value) { return `[${kind}-${stableId(value)}]`; } +function isIpv4(value) { + const parts = String(value).split('.'); + return parts.length === 4 && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255); +} +function isIpv6(value) { + const text = String(value).toLowerCase(); + if (!text.includes(':') || !/^[0-9a-f:.]+$/.test(text)) return false; + const pieces = text.split('::'); + if (pieces.length > 2) return false; + const count = (part) => part ? part.split(':').filter(Boolean).length : 0; + const groups = count(pieces[0]) + count(pieces[1]); + return text.includes('::') ? groups < 8 : groups === 8; +} +export function classifyPii(value, previous = '') { + const text = String(value); + const label = String(previous || '').toLowerCase(); + if (/^(?:users?|usernames?|u)$/.test(label) || /^@[^@/\s]{1,128}$/.test(text)) return 'user'; + if (/^(?:tenants?|organizations?|orgs?|t|租户)$/.test(label) || /^(?:tenant|租户)[=:_-].+$/i.test(text)) return 'tenant'; + if (/^(?:accounts?|members?|profiles?)$/.test(label)) return 'account'; + if (/^[^/@\s]+@[^/@\s]+\.[^/@\s]+$/.test(text)) return 'email'; + if (/^(?:\+?86[- ]?)?1[3-9]\d{9}$/.test(text)) return 'phone'; + if (/^\d{17}[\dXx]$/.test(text)) return 'identity'; + if (isIpv4(text) || isIpv6(text)) return 'ip'; + if (/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(text)) return 'id'; + if (/^\d{8,}$/.test(text) || (!/^[A-Z_]+$/.test(text) && /^[a-z0-9_-]{16,}$/i.test(text))) return 'id'; + if (/^(?:user(?:name)?|account|member|profile)[=:_-].+$/i.test(text)) return 'account'; + return null; +} +function redactPrivatePathSegment(value, previous) { + const text = String(value); + if (/^\[redacted\]$/i.test(text)) return text; + const kind = classifyPii(text, previous); + if (kind) return privatePlaceholder(kind, text); + return text; +} +function redactHostname(value) { + const hostname = String(value).toLowerCase(); + // Keep the poison-key fixture literal so count maps still exercise their null-prototype path. + if (hostname === '__proto__') return hostname; + return `host-${stableId(hostname)}.invalid`; +} +function redactPathname(pathname) { + const state = { pemLabels: [], decodeFailed: false }; + let previous = ''; + return String(pathname).split('/').map((segment) => { + if (state.decodeFailed) return '%5Bredacted%5D'; + const decoded = decodePathSegment(segment); + if (!decoded.ok) { + state.decodeFailed = true; + state.pemLabels.length = 0; + return '%5Bredacted%5D'; + } + const redacted = redactPemText(decoded.value, state, '[redacted]'); + const output = redactPrivatePathSegment(redacted, previous); + previous = decoded.value; + return safeEncodeComponent(output); + }).join('/'); +} +function safeQueryKey(value) { + const raw = String(value); + // Query names are attacker-controlled and can themselves be PII. There is + // no reliable way to distinguish a semantic key from a short username or + // tenant name, so fail closed and anonymize every key while keeping repeats + // stable for grouping. + return privatePlaceholder('query', raw); +} +function safeInvalidQueryKey(value) { + try { return safeQueryKey(decodeURIComponent(value)); } catch { return 'redacted'; } +} +function redactInvalidUrl(raw) { + const withoutHash = String(raw).replace(/#[\s\S]*$/, ''); + const queryIndex = withoutHash.indexOf('?'); + // Invalid input has no trustworthy origin/path boundary. Preserve only the + // number of slash-delimited route segments; never project its raw base. + const rawBase = queryIndex < 0 ? withoutHash : withoutHash.slice(0, queryIndex); + const route = String(rawBase).split('/').map((part) => part ? '%5Bredacted%5D' : '').join('/'); + const base = route || '%5Bredacted%5D'; + if (queryIndex < 0) return base; + const query = withoutHash.slice(queryIndex + 1).split('&').map((part) => { + const separator = part.indexOf('='); + const rawKey = separator < 0 ? part : part.slice(0, separator); + return `${safeEncodeComponent(safeInvalidQueryKey(rawKey))}=%5Bredacted%5D`; + }).join('&'); + return `${base}?${query}`; +} +export function redactUrl(raw) { + try { + const url = new URL(raw); + url.username='';url.password='';url.hash=''; + url.hostname = redactHostname(url.hostname); + url.pathname = redactPathname(url.pathname); + const query = new URLSearchParams(); + for (const [key] of url.searchParams.entries()) query.append(safeQueryKey(key), '[redacted]'); + url.search = query.toString(); + return url.toString(); + } catch { + return redactInvalidUrl(raw); + } +} +function safeMethod(value) { + const method = String(value || 'GET').trim().toUpperCase(); + return SAFE_METHODS.has(method) ? method : 'OTHER'; +} +function safeMime(value) { + const mime = String(value || '').split(';')[0].trim().toLowerCase(); + return SAFE_MIME_TYPES.has(mime) ? mime : 'unknown'; +} +function sumTiming(timing) { return Object.values(timing || {}).reduce((n, v) => n + (Number(v) > 0 ? Number(v) : 0), 0); } + +export function analyze(har) { + const entries = har.log.entries; + const result = { entries: entries.length, totalMs: 0, transferredBytes: 0, domains: Object.create(null), types: Object.create(null), slow: [], errors: [], redirects: [], cache: [], cors: [], security: [], duplicateBytes: 0, invalidUrls: 0 }; + const seen = new Map(); + for (const entry of entries) { + const request = entry.request || {}, response = entry.response || {}; + const rawUrl = request.url || ''; + let validUrl = true; try { new URL(rawUrl); } catch { validUrl = false; } + const url = redactUrl(rawUrl); const duration = Number(entry.time) > 0 ? Number(entry.time) : sumTiming(entry.timings); + const declared=Number(response.bodySize);const size = declared>=0?declared:Math.max(0, Number(response.content?.size) || 0); const status = Number(response.status) || 0; + const domain = validUrl ? host(url) : '(无效 URL)'; if (!validUrl) result.invalidUrls++; + result.totalMs += duration; result.transferredBytes += size; + result.domains[domain] = (result.domains[domain] || 0) + 1; const kind = safeMime(response.content?.mimeType); result.types[kind] = (result.types[kind] || 0) + 1; + const timing={};for(const key of ['blocked','dns','connect','send','wait','receive','ssl'])if(Number.isFinite(Number(entry.timings?.[key])))timing[key]=Number(entry.timings[key]);const item = { url, method: safeMethod(request.method), status, duration, size, timing }; + if (duration >= 1000) result.slow.push(item); + if (status >= 400) result.errors.push(item); + if (status >= 300 && status < 400) result.redirects.push(item); + const h = headers(response.headers); const q = headers(request.headers); + if (h['cache-control'] || h.etag || h['last-modified']) result.cache.push({ url, cacheControl: Boolean(h['cache-control']), noStore: /(?:^|,)\s*no-store\b/i.test(h['cache-control'] || ''), noCache: /(?:^|,)\s*no-cache\b/i.test(h['cache-control'] || ''), etag: Boolean(h.etag), lastModified: Boolean(h['last-modified']) }); + if (q.origin && !h['access-control-allow-origin']) result.cors.push({ url, reason: '包含 Origin 的请求缺少 ACAO 响应头' }); + const missing = ['strict-transport-security', 'content-security-policy', 'x-content-type-options'].filter((name) => !h[name]); + if (url.startsWith('https:') && missing.length) result.security.push({ url, missing }); + const prior = seen.get(url); if (prior) result.duplicateBytes += Math.min(size, prior); else seen.set(url, size); + } + result.slow.sort((a, b) => b.duration - a.duration); return result; +} + +export function diffReports(before, after) { + const numeric = (v) => Array.isArray(v) ? v.length : Number(v || 0); + const compare = (key) => numeric(after[key]) - numeric(before[key]); + return { entries: compare('entries'), totalMs: compare('totalMs'), transferredBytes: compare('transferredBytes'), errors: compare('errors'), slow: compare('slow'), duplicateBytes: compare('duplicateBytes') }; +} + +export function humanizeRedactionMarkers(value) { + return String(value) + .replace(/%5Bredacted%5D/gi, '[已脱敏]') + .replace(/\[redacted\]/gi, '[已脱敏]'); +} + +export function toMarkdown(report, diff) { + const lines = [`# HAR 诊断报告`, '', `- 请求数:${report.entries}`, `- 瀑布总耗时:${report.totalMs} ms`, `- 传输量:${report.transferredBytes} 字节`, `- 错误数:${report.errors.length}`, `- 慢请求数:${report.slow.length}`, `- 重复传输估算:${report.duplicateBytes} 字节`]; + if (diff) { const labels = { entries: '请求数', totalMs: '总耗时', transferredBytes: '传输量', errors: '错误数', slow: '慢请求数', duplicateBytes: '重复传输量' }; lines.push('', '## 环境差异', ...Object.entries(diff).map(([k, v]) => `- ${labels[k] || k}:${v >= 0 ? '+' : ''}${v}`)); } + return lines.join('\n'); +} diff --git a/plugins/har-doctor/src/main/app.js b/plugins/har-doctor/src/main/app.js new file mode 100644 index 00000000..be87c036 --- /dev/null +++ b/plugins/har-doctor/src/main/app.js @@ -0,0 +1,10 @@ +import { parseHar, analyze, redact, diffReports, toMarkdown, humanizeRedactionMarkers } from '../core/analyze.js'; +const $=s=>document.querySelector(s),out=$('#summary'),waterfall=$('#waterfall'),status=$('#status');let reports=[]; +function el(tag,text,className){const x=document.createElement(tag);if(text!==undefined)x.textContent=String(text);if(className)x.className=className;return x;} +function render(){const report=reports[0];out.replaceChildren();waterfall.replaceChildren();if(!report)return;for(const [k,v]of [['请求数',report.entries],['总耗时',`${report.totalMs} ms`],['错误数',report.errors.length],['传输量',`${report.transferredBytes} B`]]){const card=el('article','', 'card');card.append(el('small',k),el('strong',v));out.append(card);}if(reports[1]){const labels={entries:'请求数',totalMs:'总耗时',transferredBytes:'传输量',errors:'错误数',slow:'慢请求数',duplicateBytes:'重复传输量'},delta=diffReports(report,reports[1]);out.append(el('article',`环境差异:${Object.entries(delta).map(([key,value])=>`${labels[key]||key} ${value>=0?'+':''}${value}`).join(';')}`,'card'));}const slow=report.slow.slice(0,16),longest=Math.max(1,...slow.map(item=>item.duration));if(!slow.length)waterfall.append(el('div','没有超过 1 秒的请求。','bar'));for(const item of slow){const row=el('div','', 'bar'),bar=el('i','');bar.style.setProperty('--fraction',String(Math.max(.02,item.duration/longest)));row.append(el('code',humanizeRedactionMarkers(`${item.status} ${item.method}`)),bar,el('span',`${Math.round(item.duration)} ms`),el('span',humanizeRedactionMarkers(item.url)));waterfall.append(row);}} +function setReports(texts){reports=texts.map(t=>analyze(parseHar(t)));render();} +$('#run').addEventListener('click',()=>{try{setReports([$('#input').value]);}catch(e){out.replaceChildren(el('p',e.message));waterfall.replaceChildren();}}); +$('#choose').addEventListener('click',async()=>{try{await window.harDoctor?.choose?.();const files=window.harDoctor?.previewGranted?.();if(!files)throw Error('ZTools 桥接不可用');setReports(files);status.textContent='HAR 文件已载入并完成分析,可继续交给智能体分析'}catch(e){out.replaceChildren(el('p',e.message));}}); +async function copyReport(text,label){try{if(window.harDoctor?.copyText)await window.harDoctor.copyText(text);else await navigator.clipboard.writeText(text);status.textContent=label}catch{status.textContent='当前环境无法复制'}} +$('#copy-md').addEventListener('click',()=>{if(reports[0])copyReport(humanizeRedactionMarkers(toMarkdown(reports[0],reports[1]&&diffReports(reports[0],reports[1]))),'已复制 Markdown 报告');}); +$('#copy-json').addEventListener('click',()=>{if(reports[0])copyReport(JSON.stringify(redact(reports),null,2),'已复制 JSON 报告');}); diff --git a/plugins/har-doctor/src/main/index.html b/plugins/har-doctor/src/main/index.html new file mode 100644 index 00000000..b245b312 --- /dev/null +++ b/plugins/har-doctor/src/main/index.html @@ -0,0 +1 @@ +HAR 医生
HAR/

网络瀑布诊断

离线分析网络证据,导出前自动移除敏感信息。

diff --git a/plugins/har-doctor/src/main/style.css b/plugins/har-doctor/src/main/style.css new file mode 100644 index 00000000..30750068 --- /dev/null +++ b/plugins/har-doctor/src/main/style.css @@ -0,0 +1 @@ +:root{color:#d8efff;background:#061521;font-family:ui-sans-serif,system-ui,sans-serif}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 90% 0,#123e53,transparent 35%),#061521}main{max-width:1100px;margin:auto;padding:clamp(20px,5vw,64px)}header{display:flex;gap:18px;align-items:center;border-bottom:1px solid #1c566e;padding-bottom:28px}.mark{font:700 32px ui-monospace,monospace;color:#48dcff}h1{margin:0;font-size:clamp(28px,5vw,54px)}p{color:#8fb5c6;text-wrap:pretty}.intake{display:grid;gap:14px;margin:32px 0}label{display:grid;gap:8px;color:#ffbf61;font:14px ui-monospace,monospace}textarea{min-height:180px;border:1px solid #296077;background:#091f2e;color:#d8efff;padding:14px;font:13px ui-monospace,monospace}button{width:max-content;max-width:100%;background:#49d7ff;color:#062032;border:0;border-radius:999px;padding:12px 18px;font-weight:700;cursor:pointer;white-space:normal;overflow-wrap:anywhere;text-align:center}#status,.card,.bar,label{overflow-wrap:anywhere}.summary{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}.card,.bar{background:#0c2939;border-left:3px solid #49d7ff;padding:13px}.waterfall{display:grid;gap:8px;margin-top:22px}.bar{font:12px ui-monospace,monospace;border-left-color:#ffbd59;display:grid;grid-template-columns:minmax(0,max-content) minmax(2px,1fr) max-content minmax(0,2fr);gap:12px;align-items:center;min-width:0}.bar i{height:8px;background:#ffbd59;display:block;min-width:2px;width:100%;transform:scaleX(var(--fraction,1));transform-origin:left}.bar code,.bar span{min-width:0}.bar span:last-child{overflow-wrap:anywhere;word-break:break-word}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}button:focus-visible,textarea:focus-visible{outline:3px solid #fff;outline-offset:3px}@media(max-width:560px){header{align-items:flex-start}.mark{font-size:22px}}@media(max-width:390px){main{padding:16px}.bar{grid-template-columns:minmax(0,1fr) minmax(2px,72px) max-content;gap:8px}.bar span:last-child{grid-column:1/-1}} diff --git a/plugins/har-doctor/src/preload/index.cjs b/plugins/har-doctor/src/preload/index.cjs new file mode 100644 index 00000000..78662f70 --- /dev/null +++ b/plugins/har-doctor/src/preload/index.cjs @@ -0,0 +1,366 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); + +const MAX = 20 * 1024 * 1024; +const TTL = 300000; +const INLINE_BYTES = 512 * 1024; +const INLINE_TOTAL_BYTES = 768 * 1024; +const MAX_DETAIL_LIMIT = 50; +const MCP_RESPONSE_BYTES = 512 * 1024; +const TOOL_NAMES = Object.freeze({ inline: 'analyze_inline', approved: 'analyze_approved_files' }); +const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']); +const CLOSE_RETRY_MS = 100; +const registeredHosts = new WeakSet(); +let grants = []; +let pendingCloses = []; +let corePromise; +let sessionEpoch = 0; +let grantTimer; +let closeRetryTimer; +let timerEpoch = 0; +let grantTtl = TTL; +let closeSync = fs.closeSync; + +function invalid(message) { return Object.assign(new TypeError(message), { code: 'INVALID_TOOL_INPUT' }); } +function approvalRequired(message = 'Select one or two HAR files in the HAR Doctor UI first.') { return Object.assign(new Error(message), { code: 'FILE_APPROVAL_REQUIRED' }); } +function sessionExpired() { return Object.assign(new Error('插件会话已结束,请重新打开后选择 HAR 文件。'), { code: 'SESSION_EXPIRED' }); } +function machineFailure(error, code, message) { + if (['INVALID_TOOL_INPUT', 'FILE_APPROVAL_REQUIRED', 'MCP_RESPONSE_TOO_LARGE'].includes(error?.code)) return error; + return Object.assign(new Error(message), { code }); +} +function close(record) { + if (!record || record.closed) return true; + try { + closeSync(record.fd); + record.closed = true; + return true; + } catch (error) { + // EBADF means another trusted cleanup path already released the descriptor. + if (error?.code === 'EBADF') { + record.closed = true; + return true; + } + record.closeFailed = true; + return false; + } +} +function scheduleCloseRetry() { + if (!pendingCloses.length || closeRetryTimer) return; + closeRetryTimer = setTimeout(() => { + closeRetryTimer = undefined; + retryPendingCloses(); + }, CLOSE_RETRY_MS); + closeRetryTimer.unref?.(); +} +function retire(record) { + if (!record) return; + record.revoked = true; + if (!close(record) && !pendingCloses.some((pending) => pending.fd === record.fd)) { + // Keep only the descriptor required for retry. Revoked authorization + // paths and file metadata must not survive in the retry queue. + pendingCloses.push({ fd: record.fd, closed: false, closeFailed: true }); + scheduleCloseRetry(); + } +} +function retryPendingCloses() { + if (closeRetryTimer) clearTimeout(closeRetryTimer); + closeRetryTimer = undefined; + const active = pendingCloses; + pendingCloses = []; + for (const record of active) { + if (!close(record)) pendingCloses.push(record); + } + scheduleCloseRetry(); +} +function cancelExpiry() { + timerEpoch += 1; + if (grantTimer) clearTimeout(grantTimer); + grantTimer = undefined; +} +function clear() { + cancelExpiry(); + // A later grant replacement, TTL expiry, plugin-out, or explicit clear is + // also an immediate retry opportunity; the timer remains a fallback only. + retryPendingCloses(); + const active = grants; + grants = []; + for (const record of active) retire(record); +} +function scheduleExpiry() { + cancelExpiry(); + if (!grants.length) return; + const epoch = timerEpoch; + const until = Math.min(...grants.map((record) => record.until)); + grantTimer = setTimeout(() => { + if (epoch !== timerEpoch) return; + clear(); + }, Math.max(0, until - Date.now())); + grantTimer.unref?.(); +} +function expireSession() { sessionEpoch += 1; clear(); } + +function identity(stat) { + return { size: stat.size, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs, dev: stat.dev, ino: stat.ino }; +} + +function sameIdentity(left, right) { + return left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs && left.dev === right.dev && left.ino === right.ino; +} + +function verifyRecord(record) { + const link = fs.lstatSync(record.real); + if (link.isSymbolicLink()) throw Error('HAR 文件在选择后发生变化'); + const pathIdentity = identity(fs.statSync(record.real)); + const handleIdentity = identity(fs.fstatSync(record.fd)); + if (!sameIdentity(record, pathIdentity) || !sameIdentity(record, handleIdentity) || !sameIdentity(pathIdentity, handleIdentity)) throw Error('HAR 文件在选择后发生变化'); + return handleIdentity; +} + +function meta(file) { + const real = fs.realpathSync(file); + const link = fs.lstatSync(file); + const fd = fs.openSync(real, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)); + try { + const stat = fs.fstatSync(fd); + if (link.isSymbolicLink() || !stat.isFile() || stat.size > MAX || !/\.har$/i.test(real)) throw Error('HAR 文件不符合要求'); + const record = { real, fd, ...identity(stat), until: Date.now() + grantTtl, closed: false }; + verifyRecord(record); + return record; + } catch (error) { + retire({ fd }); + throw error; + } +} + +function grant(files) { + clear(); + if (!Array.isArray(files) || files.length < 1 || files.length > 2) throw Error('请选择一到两个 HAR 文件'); + const selected = []; + try { + for (const file of files) selected.push(meta(file)); + grants = selected; + scheduleExpiry(); + return grants.map((item) => path.basename(item.real)); + } catch (error) { + for (const item of selected) retire(item); + throw error; + } +} + +function read(record) { + if (record.revoked) throw approvalRequired(); + if (Date.now() > record.until) { clear(); throw Error('HAR 文件选择授权已过期'); } + const before = verifyRecord(record); + const output = Buffer.alloc(before.size); + let offset = 0; + while (offset < output.length) { + const count = fs.readSync(record.fd, output, offset, output.length - offset, offset); + if (!count) throw Error('HAR 文件读取不完整'); + offset += count; + } + verifyRecord(record); + return output.toString('utf8'); +} + +function readGranted({ consume = true, human = false } = {}) { + if (!grants.length) throw approvalRequired(human ? '请先在 HAR 医生界面中选择一到两个 HAR 文件。' : undefined); + try { return grants.map(read); } + catch (error) { clear(); throw error; } + finally { if (consume) clear(); } +} + +async function choose(ztools) { + if (typeof ztools?.showOpenDialog !== 'function') throw Error('ZTools 文件选择对话框不可用'); + expireSession(); + const epoch = sessionEpoch; + const result = await ztools.showOpenDialog({ title: '选择一到两个 HAR 文件', properties: ['openFile', 'multiSelections'], filters: [{ name: 'HAR 文件', extensions: ['har'] }] }); + if (epoch !== sessionEpoch) throw sessionExpired(); + const files = Array.isArray(result) ? result : result?.filePaths; + if (!files?.length) { clear(); return []; } + return grant(files); +} + +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 detailLimit(value) { + if (value === undefined) return 20; + if (!Number.isInteger(value) || value < 1 || value > MAX_DETAIL_LIMIT) throw invalid('detailLimit 必须是 1 到 50 之间的整数。'); + return value; +} + +function validateInlineInput(input) { + validateObject(input, new Set(['har', 'compareHar', 'detailLimit']), '工具输入'); + if (typeof input.har !== 'string') throw invalid('har 必须是字符串。'); + if (input.compareHar !== undefined && typeof input.compareHar !== 'string') throw invalid('compareHar 必须是字符串。'); + const texts = [input.har, ...(input.compareHar === undefined ? [] : [input.compareHar])]; + const sizes = texts.map((text) => Buffer.byteLength(text, 'utf8')); + if (sizes.some((size) => size > INLINE_BYTES)) throw invalid('每份内联 HAR 最多为 512 KiB UTF-8。'); + if (sizes.reduce((sum, size) => sum + size, 0) > INLINE_TOTAL_BYTES) throw invalid('内联 HAR 文本总量超过 MCP 请求预算。'); + return { texts, detailLimit: detailLimit(input.detailLimit) }; +} + +function validateApprovedInput(input) { + validateObject(input, new Set(['detailLimit']), '工具输入'); + return { detailLimit: detailLimit(input.detailLimit) }; +} + +function loadCore() { + if (!corePromise) { + const packaged = path.join(__dirname, '..', 'core', 'analyze.js'); + const source = path.join(__dirname, '..', 'core', 'analyze.js').replace(`${path.sep}preload${path.sep}..${path.sep}core`, `${path.sep}core`); + corePromise = import(pathToFileURL(fs.existsSync(packaged) ? packaged : source).href); + } + return corePromise; +} + +function finite(value) { const number = Number(value); return Number.isFinite(number) ? number : 0; } +function clip(value, maximum = 1024) { + const text = String(value ?? ''); + if (Buffer.byteLength(text, 'utf8') <= maximum) return text; + const suffix = '…[truncated]'; + const budget = Math.max(0, maximum - Buffer.byteLength(suffix, 'utf8')); + const bytes = Buffer.from(text, 'utf8'); + let end = Math.min(budget, bytes.length); + while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1; + return `${bytes.subarray(0, end).toString('utf8')}${suffix}`; +} + +function boundValue(value, core, state = { nodes: 0 }, depth = 0) { + if (++state.nodes > 5000 || depth > 16) return '[truncated]'; + const redacted = core.redact(value); + if (typeof redacted === 'string') return clip(redacted); + if (redacted === null || typeof redacted !== 'object') return redacted; + if (Array.isArray(redacted)) return redacted.slice(0, 64).map((item) => boundValue(item, core, state, depth + 1)); + const output = Object.create(null); + for (const [key, item] of Object.entries(redacted).slice(0, 64)) { + Object.defineProperty(output, clip(key, 128), { value: boundValue(item, core, state, depth + 1), enumerable: true, configurable: true, writable: true }); + } + return output; +} + +function topCounts(values, limit, core) { + return Object.entries(values || {}) + .sort((left, right) => finite(right[1]) - finite(left[1]) || String(left[0]).localeCompare(String(right[0]))) + .slice(0, limit) + .map(([name, count]) => ({ name: clip(core.redact(name), 512), count: finite(count) })); +} + +function boundedReport(report, limit, core) { + const detailKeys = ['slow', 'errors', 'redirects', 'cache', 'cors', 'security']; + const details = Object.create(null); + const truncated = Object.create(null); + const totals = Object.create(null); + for (const key of detailKeys) { + const list = Array.isArray(report[key]) ? report[key] : []; + totals[key] = list.length; + truncated[key] = list.length > limit; + details[key] = list.slice(0, limit).map((item) => boundValue(item, core)); + } + const domainNames = Object.keys(report.domains || {}); + const typeNames = Object.keys(report.types || {}); + totals.domains = domainNames.length; + totals.types = typeNames.length; + return { + metrics: { entries: finite(report.entries), totalMs: finite(report.totalMs), transferredBytes: finite(report.transferredBytes), duplicateBytes: finite(report.duplicateBytes), invalidUrls: finite(report.invalidUrls) }, + totals, + domains: topCounts(report.domains, limit, core), + types: topCounts(report.types, limit, core), + details, + truncation: { detailLimit: limit, ...truncated, domains: domainNames.length > limit, types: typeNames.length > limit } + }; +} + +async function analyzeTexts(texts, limit) { + if (!Array.isArray(texts) || texts.length < 1 || texts.length > 2) throw invalid('需要一到两份 HAR 文档。'); + const core = await loadCore(); + const rawReports = texts.map((text) => core.analyze(core.parseHar(text))); + const diff = rawReports[1] ? core.diffReports(rawReports[0], rawReports[1]) : undefined; + const markdown = clip(core.redact(core.toMarkdown(rawReports[0], diff)), 8192); + let effectiveLimit = limit; + while (true) { + const response = { reports: rawReports.map((report) => boundedReport(report, effectiveLimit, core)), ...(diff ? { diff: boundValue(diff, core) } : {}), markdown, requestedDetailLimit: limit, detailLimit: effectiveLimit, responseTruncated: effectiveLimit < limit }; + if (Buffer.byteLength(JSON.stringify(response), 'utf8') <= MCP_RESPONSE_BYTES) return response; + if (effectiveLimit === 0) throw Object.assign(new Error('MCP response exceeds the safe output budget.'), { code: 'MCP_RESPONSE_TOO_LARGE' }); + effectiveLimit = Math.floor(effectiveLimit / 2); + } +} + +async function analyzeInlineForMcp(input) { + const value = validateInlineInput(input); + try { return await analyzeTexts(value.texts, value.detailLimit); } + catch (error) { throw machineFailure(error, 'HAR_ANALYSIS_FAILED', 'HAR analysis failed.'); } +} + +async function analyzeApprovedForMcp(input) { + const value = validateApprovedInput(input); + try { return await analyzeTexts(readGranted(), value.detailLimit); } + catch (error) { throw machineFailure(error, 'APPROVED_HAR_FAILED', 'Approved HAR analysis failed.'); } +} + +function registerTools(ztools) { + if (!ztools || typeof ztools.registerTool !== 'function' || registeredHosts.has(ztools)) return false; + let registered = false; + for (const [name, handler] of [[TOOL_NAMES.inline, analyzeInlineForMcp], [TOOL_NAMES.approved, analyzeApprovedForMcp]]) { + try { ztools.registerTool.call(ztools, name, handler); registered = true; } catch {} + } + registeredHosts.add(ztools); + return registered; +} + +function bridge(ztools) { + if (typeof ztools?.onPluginOut === 'function') ztools.onPluginOut(expireSession); + registerTools(ztools); + return Object.freeze({ choose: () => choose(ztools), previewGranted: () => readGranted({ consume: false, human: true }), copyText: (text) => ztools?.copyText?.(String(text)) }); +} + +function attachHarDoctor(target) { + if (!target || (typeof target !== 'object' && typeof target !== 'function')) throw new TypeError('需要一个类 window 目标。'); + target.harDoctor = bridge(target.ztools); + return target.harDoctor; +} + +if (typeof window !== 'undefined') attachHarDoctor(window); + +module.exports = { + TOOL_NAMES, + INLINE_BYTES, + MCP_RESPONSE_BYTES, + bridge, + attachHarDoctor, + registerTools, + validateInlineInput, + validateApprovedInput, + boundedReport, + analyzeTexts, + analyzeInlineForMcp, + analyzeApprovedForMcp, + __testGrant: grant, + __testClear: clear, + __testGrants: () => grants, + __testPendingCloses: () => pendingCloses, + __testRetryPendingCloses: retryPendingCloses, + __testSetCloseSync: (value) => { + if (typeof value !== 'function') throw new TypeError('测试 closeSync 必须是函数。'); + closeSync = value; + }, + __testResetCloseSync: () => { closeSync = fs.closeSync; }, + __testSetGrantTtl: (value) => { + if (!Number.isInteger(value) || value < 1) throw new TypeError('测试授权时长必须为正整数。'); + grantTtl = value; + }, + __testResetGrantTtl: () => { grantTtl = TTL; }, + __testHasGrantTimer: () => Boolean(grantTimer), + readGranted +}; diff --git a/plugins/har-doctor/test/analyze.test.mjs b/plugins/har-doctor/test/analyze.test.mjs new file mode 100644 index 00000000..55edebbe --- /dev/null +++ b/plugins/har-doctor/test/analyze.test.mjs @@ -0,0 +1,20 @@ +import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { parseHar,analyze,redact,redactString,redactUrl,diffReports,fileContract,humanizeRedactionMarkers } from '../src/core/analyze.js'; +import { pathRedactionHar } from './path-redaction-fixture.mjs'; +const require=createRequire(import.meta.url);const preload=require('../src/preload/index.cjs'); +const har={log:{entries:[{time:1200,request:{url:'https://a.test/x',method:'GET',headers:[{name:'Authorization',value:'x'}]},response:{status:500,bodySize:120,headers:[{name:'cache-control',value:'max-age=1'}],content:{mimeType:'application/json'}}}]}}; +function crossSegmentPemHar(){const afterToken=`github_pat_${'v'.repeat(24)}`,completeBegin=encodeURIComponent('-----BEGIN PRIVATE KEY-----\nQUJD'),completeEnd=encodeURIComponent(`-----END PRIVATE KEY-----visible-suffix-${afterToken}`),truncatedBegin=encodeURIComponent('-----BEGIN RSA PRIVATE KEY-----\nREVG'),completeUrl=`https://safe.test/safe%2Fboundary/${completeBegin}/COMPLETE_TAIL_A/COMPLETE_TAIL_B/${completeEnd}/normal?safe=one&safe=two`,truncatedUrl=`https://safe.test/safe%2Fboundary/${truncatedBegin}/TRUNC_TAIL_A/TRUNC_TAIL_B/AFTER_TAIL?safe=one&safe=two`,value={log:{entries:[completeUrl,truncatedUrl].map((url,index)=>({time:1600-index*100,request:{url,method:'GET',headers:[]},response:{status:500,bodySize:10,headers:[],content:{mimeType:'application/json'}}}))}};return{value,urls:[completeUrl,truncatedUrl],afterToken,secrets:['BEGIN PRIVATE KEY','BEGIN RSA PRIVATE KEY','END PRIVATE KEY','COMPLETE_TAIL_A','COMPLETE_TAIL_B','TRUNC_TAIL_A','TRUNC_TAIL_B','AFTER_TAIL',afterToken]};} +function anonymousQueryEntries(value){const query=String(value).split('?')[1]||'';const entries=[...new URLSearchParams(query).entries()];for(const [key,item] of entries){assert.match(key,/^(?:\[query-[a-f0-9]{32}\]|redacted)$/);assert.equal(item,'[redacted]')}return entries;} +test('analyzes and redacts',()=>{const r=analyze(parseHar(JSON.stringify(har)));assert.equal(r.errors.length,1);assert.equal(r.slow.length,1);assert.equal(redact({token:'x'}).token,'[redacted]');assert.equal(redact([{name:'Cookie',value:'x'}])[0].value,'[redacted]');const entries=anonymousQueryEntries(redactUrl('https://x.test/?token=x'));assert.equal(entries.length,1)});test('counts hostile domain and MIME keys safely',()=>{const r=analyze({log:{entries:[{time:1,request:{url:'https://__proto__/'},response:{status:200,content:{mimeType:'constructor'}}}]}});assert.equal(r.domains.__proto__,1);assert.equal(r.types.unknown,1);assert.equal(Object.getPrototypeOf(r.domains),null)});test('preload clears canceled, expired and failed multi-file selections',async()=>{const fixture=fileURLToPath(new URL('./fixtures/sample.har',import.meta.url));preload.__testGrant([fixture]);const consumed=preload.__testGrants()[0];preload.readGranted();assert.equal(preload.__testGrants().length,0);assert.throws(()=>fs.fstatSync(consumed.fd),{code:'EBADF'});preload.__testGrant([fixture]);assert.throws(()=>preload.__testGrant([fixture,'/does-not-exist.har']));assert.equal(preload.__testGrants().length,0);preload.__testGrant([fixture]);await preload.bridge({showOpenDialog:async()=>({filePaths:[]})}).choose();assert.equal(preload.__testGrants().length,0);preload.__testGrant([fixture]);preload.__testGrants()[0].until=0;assert.throws(()=>preload.readGranted(),/已过期/);assert.equal(preload.__testGrants().length,0)});test('rejects malformed',()=>assert.throws(()=>parseHar('{')));test('diffs reports',()=>assert.equal(diffReports({entries:1,errors:[]},{entries:2,errors:[{}]}).errors,1));test('renderer source avoids innerHTML',async()=>{const s=await (await import('node:fs/promises')).readFile(new URL('../src/main/app.js',import.meta.url),'utf8');assert.equal(s.includes('innerHTML'),false)});test('390px waterfall has bounded grid and wrapping',async()=>{const s=await (await import('node:fs/promises')).readFile(new URL('../src/main/style.css',import.meta.url),'utf8');assert.match(s,/@media\(max-width:390px\)/);assert.match(s,/overflow-wrap:anywhere/);assert.match(s,/--fraction/)});for(const p of ['win32','darwin','linux'])test(`platform ${p}`,()=>assert.equal(fileContract(p,p==='win32'?'C:\\x\\a.har':'/x/a.har').accepted,true)); +test('redacts credential-shaped strings at the final export boundary',()=>{assert.equal(redact('Bearer abcdefghijklmnop'),'[redacted]');assert.ok(!redact('token=top-secret-value').includes('top-secret-value'));}); +test('human surfaces localize redaction markers without changing machine values',()=>{const machine='https://x.test/%5Bredacted%5D?q=%5Bredacted%5D [redacted]';assert.equal(humanizeRedactionMarkers(machine),'https://x.test/[已脱敏]?q=[已脱敏] [已脱敏]');assert.equal(redact({token:'secret'}).token,'[redacted]');}); +test('redacts reliable prefixed credentials and private-key blocks in strings and URL paths',()=>{const values=[`github_pat_${'A'.repeat(24)}`,`ghp_${'B'.repeat(36)}`,`gho_${'C'.repeat(36)}`,`sk-proj-${'D'.repeat(24)}`,`sk-${'E'.repeat(24)}`,`AKIA${'F'.repeat(16)}`],pem=`-----BEGIN PRIVATE KEY-----\n${'G'.repeat(64)}\n-----END PRIVATE KEY-----`,source=[...values,pem].join(' / '),clean=redactString(source),url=redactUrl(`https://x.test/${values.map(encodeURIComponent).join('/')}/${encodeURIComponent(pem)}`);for(const value of values){assert.equal(clean.includes(value),false);assert.equal(url.includes(value),false)}assert.equal(clean.includes('BEGIN PRIVATE KEY'),false);assert.equal(url.includes('BEGIN%20PRIVATE%20KEY'),false);assert.match(clean,/\[redacted\]/);}); +test('redacts an unterminated private key from BEGIN through end of input',()=>{const material='T'.repeat(96),pem=`-----BEGIN PRIVATE KEY-----\n${material}`,clean=redactString(`prefix ${pem}`),url=redactUrl(`https://x.test/${encodeURIComponent(pem)}`);assert.equal(clean.includes('BEGIN PRIVATE KEY'),false);assert.equal(clean.includes(material),false);assert.equal(url.includes('BEGIN%20PRIVATE%20KEY'),false);assert.equal(url.includes(material),false);assert.match(clean,/\[redacted\]$/);}); +test('URL redaction preserves parseability, encoded-slash boundaries, and stable anonymous query groups',()=>{const token=`github_pat_${'q'.repeat(24)}`,raw=`https://${token}.example.test/a%2Fb/${encodeURIComponent(token)}?safe=one&safe=two&${encodeURIComponent(token)}=three#${token}`,clean=redactUrl(raw),parsed=new URL(clean),entries=anonymousQueryEntries(clean);assert.equal(clean.includes(token),false);assert.match(parsed.hostname,/^host-[a-f0-9]{32}\.invalid$/);assert.equal(parsed.pathname.split('/')[1],'a%2Fb');assert.equal(parsed.pathname.split('/').length,3);assert.equal(entries.length,3);assert.equal(entries[0][0],entries[1][0]);assert.notEqual(entries[0][0],entries[2][0]);assert.equal(parsed.hash,'');}); +test('cross-segment complete and truncated PEM paths remain statefully redacted for human reports',()=>{const fixture=crossSegmentPemHar(),report=analyze(fixture.value),json=JSON.stringify(redact([report]));assert.equal(report.entries,2);assert.equal(report.errors.length,2);for(const secret of fixture.secrets){assert.equal(json.includes(secret),false);for(const item of report.errors)assert.equal(item.url.includes(secret),false)}for(let index=0;index{const token=`github_pat_${'z'.repeat(24)}`,clean=redactUrl(`not a url?safe=one&safe=two&${encodeURIComponent(token)}=three#${token}`),entries=anonymousQueryEntries(clean);assert.equal(clean.includes(token),false);assert.equal(entries.length,3);assert.equal(entries[0][0],entries[1][0]);assert.notEqual(entries[0][0],entries[2][0]);}); +test('sensitive named records still redact credential strings in sibling fields',()=>{const token=`github_pat_${'H'.repeat(24)}`,clean=redact({name:'Authorization',value:'ignored',note:token});assert.equal(clean.value,'[redacted]');assert.equal(clean.note.includes(token),false);}); +test('human JSON export and waterfall fields redact hostname, method, PEM, domain, and MIME credentials',()=>{const github=`github_pat_${'a'.repeat(24)}`,ghp=`ghp_${'b'.repeat(36)}`,pemBody='P'.repeat(80),pem=`-----BEGIN PRIVATE KEY-----\n${pemBody}`,report=analyze({log:{entries:[github,ghp].map((token,index)=>({time:1500+index,request:{url:`https://${token}.example.test/${encodeURIComponent(pem)}`,method:`POST ${token} ${pem}`,headers:[]},response:{status:500,bodySize:10,headers:[],content:{mimeType:`application/${token}`}}}))}}),exported=redact([report]),json=JSON.stringify(exported);for(const secret of [github,ghp,pemBody])assert.equal(json.includes(secret),false);assert.equal(json.includes('BEGIN PRIVATE KEY'),false);for(const item of report.slow){assert.equal(item.url.includes(github),false);assert.equal(item.url.includes(ghp),false);assert.equal(item.url.includes('BEGIN PRIVATE KEY'),false);assert.equal(item.method,'OTHER');assert.match(item.url,/^https:\/\/host-[a-f0-9]{32}\.invalid\//)}assert.equal(Object.values(report.domains).reduce((sum,value)=>sum+value,0),2);assert.match(Object.keys(report.domains)[0],/^host-[a-f0-9]{32}\.invalid$/);assert.deepEqual(Object.entries(report.types),[['unknown',2]]);assert.equal(exported[0].entries,2);}); +test('redact uses poison-safe keys, sums numeric collisions, and suffixes nonnumeric collisions',()=>{const tokens=Array.from({length:4},(_,index)=>`github_pat_${String.fromCharCode(65+index).repeat(24)}`),source=Object.create(null);Object.defineProperty(source,'__proto__',{value:{polluted:true},enumerable:true});Object.defineProperty(source,'constructor',{value:1,enumerable:true});Object.defineProperty(source,'prototype',{value:2,enumerable:true});source[tokens[0]]=2;source[tokens[1]]=3;source[tokens[2]]='left';source[tokens[3]]={value:'right'};const clean=redact(source),keys=Object.keys(clean),serialized=JSON.stringify(clean);assert.equal(Object.getPrototypeOf(clean),null);assert.equal({}.polluted,undefined);assert.equal(clean['[redacted]'],5);assert.equal(clean['[redacted] [collision 2]'],'left');assert.deepEqual(clean['[redacted] [collision 3]'],Object.assign(Object.create(null),{value:'right'}));for(const reserved of ['__proto__','constructor','prototype'])assert.ok(keys.includes(`[reserved ${reserved}]`));for(const token of tokens)assert.equal(serialized.includes(token),false);}); +test('report count maps preserve finite totals while ordinary sensitive numeric values stay redacted',()=>{const github=`github_pat_${'J'.repeat(24)}`,ghp=`ghp_${'K'.repeat(36)}`,labeled=`token=${'L'.repeat(20)}`,domains=Object.create(null),types=Object.create(null);domains[github]=2;domains[ghp]=3;domains[labeled]=4;types[github]=5;types[ghp]=6;const clean=redact({password:123456,domains,types});assert.equal(clean.password,'[redacted]');assert.equal(clean.domains['[redacted]'],9);assert.equal(clean.types['[redacted]'],11);assert.equal(Object.values(clean.domains).reduce((sum,value)=>sum+value,0),9);assert.equal(Object.values(clean.types).reduce((sum,value)=>sum+value,0),11);assert.equal(JSON.stringify(clean).includes(github),false);assert.equal(JSON.stringify(clean).includes(ghp),false);}); +test('cache diagnostics never retain raw response header values',()=>{const r=analyze({log:{entries:[{time:1,request:{url:'https://x.test/'},response:{status:200,headers:[{name:'cache-control',value:'private, token=top-secret'}]}}]}});assert.equal(r.cache[0].cacheControl,true);assert.ok(!JSON.stringify(r).includes('top-secret'));}); +test('human reports fail closed for adversarial PEM paths and methods',()=>{const fixture=pathRedactionHar(),report=analyze(fixture.value),exported=JSON.stringify(redact([report])),surface=[...report.errors.map((item)=>decodeURIComponent(item.url)),...report.errors.map((item)=>item.method)].join('\n');assert.equal(report.entries,fixture.cases.length);assert.equal(report.errors.length,fixture.cases.length);assert.equal(report.slow.length,fixture.cases.length);assert.equal(report.invalidUrls,3);for(const secret of fixture.forbidden){assert.equal(exported.includes(secret),false,`export leaked ${secret}`);assert.equal(surface.includes(secret),false,`waterfall leaked ${secret}`)}fixture.cases.forEach((item,index)=>{const clean=report.errors[index].url;for(const visible of item.visible)assert.equal(decodeURIComponent(clean).includes(visible),true,`${item.name} lost ${visible}`);for(const visible of item.methodVisible||[])assert.equal(report.errors[index].method.includes(visible),true,`${item.name} method lost ${visible}`);const rawBase=item.url.replace(/#[\s\S]*$/,'').split('?')[0],cleanBase=clean.split('?')[0],entries=anonymousQueryEntries(clean),expectedQueryCount=(item.url.split('?')[1]||'').split('#')[0].split('&').filter(Boolean).length;assert.equal(cleanBase.split('/').length,rawBase.split('/').length,`${item.name} changed path topology`);if(item.name==='surrogate')assert.match(cleanBase,/\/%5Bredacted%5D\//);if(item.valid){const rawUrl=new URL(item.url),cleanUrl=new URL(clean);assert.equal(cleanUrl.pathname.split('/').length,rawUrl.pathname.split('/').length);assert.equal(cleanUrl.pathname.split('/')[1],'safe%2Fboundary')}assert.equal(entries.length,expectedQueryCount);assert.equal(entries[0][0],entries[1][0]);assert.notEqual(entries[0][0],entries[2][0]);assert.equal(entries.some(([key])=>key.includes(fixture.queryToken)),false)});}); diff --git a/plugins/har-doctor/test/dist-size.test.mjs b/plugins/har-doctor/test/dist-size.test.mjs new file mode 100644 index 00000000..06f331c3 --- /dev/null +++ b/plugins/har-doctor/test/dist-size.test.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import path from 'node:path'; +import { assertDistSize, directoryBytes, DIST_SIZE_LIMIT } from '../scripts/dist-size.mjs'; + +function fakeEntry(name, type) { + return { + name, + isDirectory: () => type === 'directory', + isFile: () => type === 'file', + isSymbolicLink: () => type === 'symlink' + }; +} + +function fakeTree() { + const root = path.resolve('/virtual/dist'); + const nested = path.join(root, 'nested'); + const entries = new Map([ + [root, [fakeEntry('main.js', 'file'), fakeEntry('nested', 'directory')]], + [nested, [fakeEntry('payload.bin', 'file')]] + ]); + const metadata = new Map([ + [path.join(root, 'main.js'), { isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false, size: 11 }], + [nested, { isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, size: 0 }], + [path.join(nested, 'payload.bin'), { isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false, size: 31 }] + ]); + return { + root, + readDirectory: async (directory) => entries.get(directory) ?? [], + inspectPath: async (entryPath) => metadata.get(entryPath) + }; +} + +test('size boundary accepts exactly 14,500,000 bytes', () => { + assert.equal(assertDistSize(DIST_SIZE_LIMIT), DIST_SIZE_LIMIT); +}); + +test('size boundary rejects 14,500,001 bytes', () => { + assert.throws(() => assertDistSize(DIST_SIZE_LIMIT + 1), /exceeds the 14\.5 MB safety limit/); +}); + +test('directoryBytes recursively counts regular files', async () => { + const tree = fakeTree(); + assert.equal(await directoryBytes(tree.root, tree), 42); +}); + +test('directoryBytes rejects a symbolic link without following it', async () => { + const root = path.resolve('/virtual/dist'); + let inspected = false; + await assert.rejects( + directoryBytes(root, { + root, + readDirectory: async () => [fakeEntry('escape', 'symlink')], + inspectPath: async () => { + inspected = true; + return { isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false, size: 1 }; + } + }), + /unsupported dist entry: escape/ + ); + assert.equal(inspected, false); +}); + +test('directoryBytes rejects a link reported by lstat after a stale file Dirent', async () => { + const root = path.resolve('/virtual/dist'); + await assert.rejects( + directoryBytes(root, { + root, + readDirectory: async () => [fakeEntry('changed', 'file')], + inspectPath: async () => ({ + isDirectory: () => false, + isFile: () => false, + isSymbolicLink: () => true, + size: 0 + }) + }), + /unsupported dist entry: changed/ + ); +}); diff --git a/plugins/har-doctor/test/fixtures/sample.har b/plugins/har-doctor/test/fixtures/sample.har new file mode 100644 index 00000000..22fdead9 --- /dev/null +++ b/plugins/har-doctor/test/fixtures/sample.har @@ -0,0 +1 @@ +{"log":{"entries":[]}} diff --git a/plugins/har-doctor/test/manifest.test.mjs b/plugins/har-doctor/test/manifest.test.mjs new file mode 100644 index 00000000..cab77436 --- /dev/null +++ b/plugins/har-doctor/test/manifest.test.mjs @@ -0,0 +1,35 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { access, readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +test('root manifest exposes directly loadable source entrypoints', async () => { + const manifest = JSON.parse(await readFile(path.join(root, 'plugin.json'), 'utf8')); + assert.equal(manifest.development, undefined); + assert.equal(manifest.main, 'src/main/index.html'); + assert.equal(manifest.preload, 'src/preload/index.cjs'); + assert.equal(manifest.logo, 'logo.svg'); + for (const entry of [manifest.main, manifest.preload, manifest.logo]) await access(path.join(root, entry)); + assert.match(pathToFileURL(path.join(root, manifest.main)).href, /^file:/); +}); + +test('human interface declares Simplified Chinese', async () => { + const html = await readFile(path.join(root, 'src', 'main', 'index.html'), 'utf8'); + assert.match(html, /lang="zh-CN"/); +}); + +test('repository packaging invariants keep the built dist directory eligible', async () => { + const manifest = JSON.parse(await readFile(path.join(root, 'plugin.json'), 'utf8')); + const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')); + const buildScript = await readFile(path.join(root, 'scripts', 'build.mjs'), 'utf8'); + assert.equal(typeof packageJson.scripts?.build, 'string'); + assert.match(packageJson.scripts.build, /scripts\/build\.mjs/); + assert.equal(existsSync(path.join(root, path.dirname(manifest.main), 'plugin.json')), false); + for (const contract of ["delete manifest.development", "manifest.main='main/index.html'", "manifest.preload='preload/index.cjs'", "manifest.logo='logo.svg'"]) { + assert.ok(buildScript.includes(contract), `build script must preserve ${contract}`); + } +}); diff --git a/plugins/har-doctor/test/mcp.test.mjs b/plugins/har-doctor/test/mcp.test.mjs new file mode 100644 index 00000000..39aa3ae3 --- /dev/null +++ b/plugins/har-doctor/test/mcp.test.mjs @@ -0,0 +1,403 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { pathRedactionHar } from './path-redaction-fixture.mjs'; + +const require = createRequire(import.meta.url); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const preload = require('../src/preload/index.cjs'); +const manifest = JSON.parse(await readFile(path.join(root, 'plugin.json'), 'utf8')); +const fixture = fileURLToPath(new URL('./fixtures/sample.har', import.meta.url)); + +function inlineHar(count = 3) { + return JSON.stringify({ log: { entries: Array.from({ length: count }, (_, index) => ({ + time: 1200 + index, + request: { url: `https://example.test/${'x'.repeat(2500)}?token=raw-secret-${index}`, method: 'GET', headers: [{ name: 'Authorization', value: 'Bearer raw-secret' }] }, + response: { status: 500, bodySize: 100, headers: [], content: { mimeType: `application/type-${index}` } } + })) } }); +} + +function responsePressureHar(marker) { + return JSON.stringify({ log: { entries: Array.from({ length: 50 }, (_, index) => ({ + time: 1200 + index, + request: { url: `https://pressure.test/${marker}/${'x'.repeat(7000)}/${index}`, method: index === 49 ? '密'.repeat(1000) : 'GET', headers: [{ name: 'Origin', value: 'https://client.test' }] }, + response: { status: index % 2 ? 500 : 302, bodySize: 100, headers: [{ name: 'cache-control', value: 'max-age=1' }], content: { mimeType: `application/type-${index}` } } + })) } }); +} + +function crossSegmentPemHar() { + const afterToken = `github_pat_${'v'.repeat(24)}`; + const completeBegin = encodeURIComponent('-----BEGIN PRIVATE KEY-----\nQUJD'); + const completeEnd = encodeURIComponent(`-----END PRIVATE KEY-----visible-suffix-${afterToken}`); + const truncatedBegin = encodeURIComponent('-----BEGIN RSA PRIVATE KEY-----\nREVG'); + const completeUrl = `https://safe.test/safe%2Fboundary/${completeBegin}/COMPLETE_TAIL_A/COMPLETE_TAIL_B/${completeEnd}/normal?safe=one&safe=two`; + const truncatedUrl = `https://safe.test/safe%2Fboundary/${truncatedBegin}/TRUNC_TAIL_A/TRUNC_TAIL_B/AFTER_TAIL?safe=one&safe=two`; + const value = { log: { entries: [completeUrl, truncatedUrl].map((url, index) => ({ time: 1600 - index * 100, request: { url, method: 'GET', headers: [] }, response: { status: 500, bodySize: 10, headers: [], content: { mimeType: 'application/json' } } })) } }; + return { value, urls: [completeUrl, truncatedUrl], secrets: ['BEGIN PRIVATE KEY', 'BEGIN RSA PRIVATE KEY', 'END PRIVATE KEY', 'COMPLETE_TAIL_A', 'COMPLETE_TAIL_B', 'TRUNC_TAIL_A', 'TRUNC_TAIL_B', 'AFTER_TAIL', afterToken] }; +} + +function anonymousQueryEntries(value) { + const query = String(value).split('?')[1] || ''; + const entries = [...new URLSearchParams(query).entries()]; + for (const [key, item] of entries) { + assert.match(key, /^(?:\[query-[a-f0-9]{32}\]|redacted)$/); + assert.equal(item, '[redacted]'); + } + return entries; +} + +function assertCrossSegmentMcpResult(result, fixture) { + const report = result.reports[0]; + const serialized = JSON.stringify(result); + assert.equal(report.metrics.entries, 2); + assert.equal(report.totals.errors, 2); + assert.equal(report.details.errors.length, 2); + for (const secret of fixture.secrets) assert.equal(serialized.includes(secret), false); + for (let index = 0; index < fixture.urls.length; index += 1) { + const raw = new URL(fixture.urls[index]); + const clean = new URL(report.details.errors[index].url); + assert.equal(clean.pathname.split('/').length, raw.pathname.split('/').length); + assert.equal(clean.pathname.split('/')[1], 'safe%2Fboundary'); + const entries = anonymousQueryEntries(clean); + assert.equal(entries.length, 2); + assert.equal(entries[0][0], entries[1][0]); + } + assert.match(decodeURIComponent(new URL(report.details.errors[0].url).pathname), /visible-suffix-/); + assert.doesNotMatch(decodeURIComponent(new URL(report.details.errors[1].url).pathname), /AFTER_TAIL/); +} + +function assertPathRedactionMcpResult(result, fixture) { + const report = result.reports[0]; + const serialized = JSON.stringify(result); + const surface = [...report.details.errors.map((item) => decodeURIComponent(item.url)), ...report.details.errors.map((item) => item.method)].join('\n'); + assert.equal(report.metrics.entries, fixture.cases.length); + assert.equal(report.metrics.invalidUrls, 3); + assert.equal(report.totals.errors, fixture.cases.length); + assert.equal(report.totals.slow, fixture.cases.length); + assert.equal(report.details.errors.length, fixture.cases.length); + for (const secret of fixture.forbidden) { + assert.equal(serialized.includes(secret), false, `MCP JSON leaked ${secret}`); + assert.equal(surface.includes(secret), false, `MCP waterfall leaked ${secret}`); + } + fixture.cases.forEach((item, index) => { + const clean = report.details.errors[index].url; + for (const visible of item.visible) assert.equal(decodeURIComponent(clean).includes(visible), true, `${item.name} lost ${visible}`); + for (const visible of item.methodVisible || []) assert.equal(report.details.errors[index].method.includes(visible), true, `${item.name} method lost ${visible}`); + const rawBase = item.url.replace(/#[\s\S]*$/, '').split('?')[0]; + const cleanBase = clean.split('?')[0]; + assert.equal(cleanBase.split('/').length, rawBase.split('/').length, `${item.name} changed path topology`); + if (item.name === 'surrogate') assert.match(cleanBase, /\/%5Bredacted%5D\//); + if (item.valid) { + const rawUrl = new URL(item.url); + const cleanUrl = new URL(clean); + assert.equal(cleanUrl.pathname.split('/').length, rawUrl.pathname.split('/').length); + assert.equal(cleanUrl.pathname.split('/')[1], 'safe%2Fboundary'); + const entries = anonymousQueryEntries(cleanUrl); + const expectedQueryCount = (item.url.split('?')[1] || '').split('#')[0].split('&').filter(Boolean).length; + assert.equal(entries.length, expectedQueryCount); + assert.equal(entries[0][0], entries[1][0]); + assert.notEqual(entries[0][0], entries[2][0]); + assert.equal([...cleanUrl.searchParams.keys()].some((key) => key.includes(fixture.queryToken)), false); + } else { + const entries = anonymousQueryEntries(clean); + const expectedQueryCount = (item.url.split('?')[1] || '').split('#')[0].split('&').filter(Boolean).length; + assert.equal(entries.length, expectedQueryCount); + assert.equal(entries[0][0], entries[1][0]); + assert.notEqual(entries[0][0], entries[2][0]); + } + }); +} + +function stableMachineError(code, message, ...forbidden) { + return (error) => { + assert.equal(error.code, code); + assert.equal(error.message, message); + for (const value of forbidden) assert.equal(error.message.includes(String(value)), false); + return true; + }; +} + +test('manifest declarations and native registrations stay one-to-one', () => { + const handlers = new Map(); + preload.bridge({ registerTool(name, handler) { handlers.set(name, handler); } }); + assert.deepEqual([...handlers.keys()].sort(), Object.keys(manifest.tools).sort()); + assert.equal(typeof handlers.get('analyze_inline'), 'function'); + assert.equal(typeof handlers.get('analyze_approved_files'), 'function'); +}); + +test('one failed registration does not block the UI or the other tool', () => { + const names = []; + const bridge = preload.bridge({ registerTool(name) { if (name === 'analyze_inline') throw Error('unsupported'); names.push(name); } }); + assert.equal(typeof bridge.choose, 'function'); + assert.deepEqual(names, ['analyze_approved_files']); +}); + +test('older hosts gracefully retain the human bridge', () => { + const bridge = preload.bridge({}); + assert.equal(typeof bridge.choose, 'function'); + assert.equal(typeof bridge.previewGranted, 'function'); +}); + +test('inline handler returns bounded redacted details and totals', async () => { + const result = await preload.analyzeInlineForMcp({ har: inlineHar(), detailLimit: 1 }); + const report = result.reports[0]; + assert.equal(report.totals.errors, 3); + assert.equal(report.details.errors.length, 1); + assert.equal(report.truncation.errors, true); + assert.ok(Buffer.byteLength(report.details.errors[0].url, 'utf8') <= 1024); + assert.doesNotMatch(JSON.stringify(result), /raw-secret|Authorization|Bearer/); + assert.doesNotMatch(JSON.stringify(result), /"log"\s*:/); +}); + +test('inline comparison uses the shared diff and Markdown core', async () => { + const result = await preload.analyzeInlineForMcp({ har: inlineHar(1), compareHar: inlineHar(2), detailLimit: 2 }); + assert.equal(result.diff.entries, 1); + assert.match(result.markdown, /HAR 诊断报告/); +}); + +test('approved handler consumes and closes the UI file grant exactly once', async () => { + preload.__testGrant([fixture]); + const record = preload.__testGrants()[0]; + const result = await preload.analyzeApprovedForMcp({ detailLimit: 5 }); + assert.equal(result.reports.length, 1); + assert.equal(preload.__testGrants().length, 0); + assert.throws(() => fs.fstatSync(record.fd), { code: 'EBADF' }); + await assert.rejects(preload.analyzeApprovedForMcp({}), { code: 'FILE_APPROVAL_REQUIRED' }); +}); + +test('real choose and UI preview preserve one grant for MCP before closing it exactly once', async () => { + const handlers = new Map(); + const bridge = preload.bridge({ + showOpenDialog: async () => ({ filePaths: [fixture] }), + registerTool(name, handler) { handlers.set(name, handler); } + }); + assert.deepEqual(await bridge.choose(), [path.basename(fixture)]); + const record = preload.__testGrants()[0]; + const preview = bridge.previewGranted(); + assert.equal(preview.length, 1); + assert.equal(preload.__testGrants().length, 1); + assert.doesNotThrow(() => fs.fstatSync(record.fd)); + const appSource = await readFile(path.join(root, 'src', 'main', 'app.js'), 'utf8'); + assert.match(appSource, /previewGranted/); + assert.doesNotMatch(appSource, /\.readGranted/); + const result = await handlers.get('analyze_approved_files')({ detailLimit: 5 }); + assert.equal(result.reports.length, 1); + assert.equal(preload.__testGrants().length, 0); + assert.throws(() => fs.fstatSync(record.fd), { code: 'EBADF' }); + await assert.rejects(handlers.get('analyze_approved_files')({}), stableMachineError('FILE_APPROVAL_REQUIRED', 'Select one or two HAR files in the HAR Doctor UI first.')); +}); + +test('plugin out expires a pending file chooser and cannot restore its UI or MCP grant', async () => { + const handlers = new Map(); + let resolveDialog; + let onPluginOut; + const bridge = preload.bridge({ + showOpenDialog: () => new Promise((resolve) => { resolveDialog = resolve; }), + onPluginOut(listener) { onPluginOut = listener; }, + registerTool(name, handler) { handlers.set(name, handler); } + }); + const choosing = bridge.choose(); + onPluginOut(); + resolveDialog({ filePaths: [fixture] }); + await assert.rejects(choosing, { code: 'SESSION_EXPIRED' }); + assert.equal(preload.__testGrants().length, 0); + await assert.rejects(handlers.get('analyze_approved_files')({}), stableMachineError('FILE_APPROVAL_REQUIRED', 'Select one or two HAR files in the HAR Doctor UI first.')); +}); + +test('approved handler rejects same-inode same-size rewrites even when mtime is restored', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'har-identity-rewrite-')); + const file = path.join(directory, 'identity.har'); + const beforeText = inlineHar(1); + const afterText = beforeText.replace('GET', 'PUT'); + assert.equal(Buffer.byteLength(beforeText), Buffer.byteLength(afterText)); + await writeFile(file, beforeText); + preload.__testGrant([file]); + const record = preload.__testGrants()[0]; + const original = fs.statSync(file); + fs.writeFileSync(file, afterText); + fs.utimesSync(file, original.atime, original.mtime); + const changed = fs.statSync(file); + assert.equal(changed.ino, original.ino); + assert.equal(changed.size, original.size); + record.mtimeMs = changed.mtimeMs; + assert.notEqual(changed.ctimeMs, record.ctimeMs); + await assert.rejects(preload.analyzeApprovedForMcp({}), stableMachineError('APPROVED_HAR_FAILED', 'Approved HAR analysis failed.', file)); + assert.equal(preload.__testGrants().length, 0); + assert.throws(() => fs.fstatSync(record.fd), { code: 'EBADF' }); +}); + +test('approved handler rejects a path replacement that diverges from the open handle', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'har-path-replacement-')); + const file = path.join(directory, 'selected.har'); + const moved = path.join(directory, 'moved.har'); + const text = inlineHar(1); + await writeFile(file, text); + preload.__testGrant([file]); + const record = preload.__testGrants()[0]; + fs.renameSync(file, moved); + fs.writeFileSync(file, text); + await assert.rejects(preload.analyzeApprovedForMcp({}), stableMachineError('APPROVED_HAR_FAILED', 'Approved HAR analysis failed.', file, moved)); + assert.equal(preload.__testGrants().length, 0); + assert.throws(() => fs.fstatSync(record.fd), { code: 'EBADF' }); +}); + +test('approved handler rejects a same-size rewrite during a bounded descriptor read', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'har-mid-read-rewrite-')); + const file = path.join(directory, 'mid-read.har'); + const beforeText = inlineHar(3); + const afterText = beforeText.replace('GET', 'PUT'); + assert.equal(Buffer.byteLength(beforeText), Buffer.byteLength(afterText)); + await writeFile(file, beforeText); + preload.__testGrant([file]); + const record = preload.__testGrants()[0]; + const original = fs.statSync(file); + const originalRead = fs.readSync; + let rewritten = false; + fs.readSync = function patchedRead(fd, buffer, offset, length, position) { + const count = originalRead.call(this, fd, buffer, offset, Math.min(length, 64), position); + if (!rewritten) { + rewritten = true; + fs.writeFileSync(file, afterText); + fs.utimesSync(file, original.atime, original.mtime); + } + return count; + }; + try { + await assert.rejects(preload.analyzeApprovedForMcp({}), stableMachineError('APPROVED_HAR_FAILED', 'Approved HAR analysis failed.', file)); + } finally { + fs.readSync = originalRead; + } + assert.equal(rewritten, true); + assert.equal(preload.__testGrants().length, 0); + assert.throws(() => fs.fstatSync(record.fd), { code: 'EBADF' }); +}); + +test('approved-file handler redacts prefixed tokens and PEM material from JSON and Markdown', async () => { + const handlers = new Map(); + preload.bridge({ registerTool(name, handler) { handlers.set(name, handler); } }); + const tokens = [`github_pat_${'A'.repeat(24)}`, `ghp_${'B'.repeat(36)}`, `sk-proj-${'C'.repeat(24)}`, `sk-${'D'.repeat(24)}`, `ASIA${'E'.repeat(16)}`]; + const pem = `-----BEGIN PRIVATE KEY-----\n${'F'.repeat(64)}\n-----END PRIVATE KEY-----`; + const truncatedMaterial = 'T'.repeat(96); + const truncatedPem = `-----BEGIN RSA PRIVATE KEY-----\n${truncatedMaterial}`; + const hostnameToken = `github_pat_${'h'.repeat(24)}`; + const har = { log: { entries: [{ time: 1500, request: { url: `https://${hostnameToken}.secret.test/${tokens.map(encodeURIComponent).join('/')}/${encodeURIComponent(pem)}`, method: `POST ${tokens[3]} ${truncatedPem}`, headers: [] }, response: { status: 500, bodySize: 10, headers: [], content: { mimeType: `application/${tokens[0]}` } } }] } }; + const directory = await mkdtemp(path.join(tmpdir(), 'har-mcp-secret-')); + const file = path.join(directory, 'secret.har'); + await writeFile(file, JSON.stringify(har)); + preload.__testGrant([file]); + const record = preload.__testGrants()[0]; + const result = await handlers.get('analyze_approved_files')({ detailLimit: 5 }); + const serialized = JSON.stringify(result); + for (const token of [...tokens, hostnameToken]) { assert.equal(serialized.includes(token), false); assert.equal(result.markdown.includes(token), false); } + assert.equal(serialized.includes('BEGIN PRIVATE KEY'), false); + assert.equal(serialized.includes('BEGIN RSA PRIVATE KEY'), false); + assert.equal(serialized.includes('F'.repeat(64)), false); + assert.equal(serialized.includes(truncatedMaterial), false); + assert.match(serialized, /redacted/i); + assert.equal(result.reports[0].metrics.entries, 1); + assert.equal(result.reports[0].totals.domains, 1); + assert.equal(result.reports[0].totals.types, 1); + assert.equal(preload.__testGrants().length, 0); + assert.throws(() => fs.fstatSync(record.fd), { code: 'EBADF' }); +}); + +test('registered inline and approved handlers redact PEM state across raw path segments', async () => { + const handlers = new Map(); + preload.bridge({ registerTool(name, handler) { handlers.set(name, handler); } }); + const fixtureValue = crossSegmentPemHar(); + const text = JSON.stringify(fixtureValue.value); + const inline = await handlers.get('analyze_inline')({ har: text, detailLimit: 5 }); + assertCrossSegmentMcpResult(inline, fixtureValue); + const directory = await mkdtemp(path.join(tmpdir(), 'har-mcp-pem-path-')); + const file = path.join(directory, 'cross-segment.har'); + await writeFile(file, text); + preload.__testGrant([file]); + const record = preload.__testGrants()[0]; + const approved = await handlers.get('analyze_approved_files')({ detailLimit: 5 }); + assertCrossSegmentMcpResult(approved, fixtureValue); + assert.equal(preload.__testGrants().length, 0); + assert.throws(() => fs.fstatSync(record.fd), { code: 'EBADF' }); + await assert.rejects(handlers.get('analyze_approved_files')({}), { code: 'FILE_APPROVAL_REQUIRED' }); +}); + +test('registered inline and approved handlers fail closed for adversarial PEM paths and methods', async () => { + const handlers = new Map(); + preload.bridge({ registerTool(name, handler) { handlers.set(name, handler); } }); + const fixtureValue = pathRedactionHar(); + const text = JSON.stringify(fixtureValue.value); + const inline = await handlers.get('analyze_inline')({ har: text, detailLimit: 20 }); + assertPathRedactionMcpResult(inline, fixtureValue); + const directory = await mkdtemp(path.join(tmpdir(), 'har-mcp-adversarial-path-')); + const file = path.join(directory, 'adversarial-path.har'); + await writeFile(file, text); + preload.__testGrant([file]); + const record = preload.__testGrants()[0]; + const approved = await handlers.get('analyze_approved_files')({ detailLimit: 20 }); + assertPathRedactionMcpResult(approved, fixtureValue); + assert.equal(preload.__testGrants().length, 0); + assert.throws(() => fs.fstatSync(record.fd), { code: 'EBADF' }); + await assert.rejects(handlers.get('analyze_approved_files')({}), { code: 'FILE_APPROVAL_REQUIRED' }); +}); + +test('two maximum-detail reports stay under 512 KiB and retain full totals', async () => { + const result = await preload.analyzeInlineForMcp({ har: responsePressureHar('left'), compareHar: responsePressureHar('right'), detailLimit: 50 }); + assert.ok(Buffer.byteLength(JSON.stringify(result), 'utf8') <= preload.MCP_RESPONSE_BYTES); + assert.equal(result.reports[0].totals.slow, 50); + assert.equal(result.reports[1].totals.security, 50); + assert.equal(result.requestedDetailLimit, 50); + assert.equal(result.responseTruncated, false); + assert.equal(result.detailLimit, result.requestedDetailLimit); + assert.ok(Buffer.byteLength(result.reports[0].details.slow[0].method, 'utf8') <= 1024); +}); + +test('MCP handlers expose stable path-free errors for malformed HAR documents', async () => { + await assert.rejects( + preload.analyzeInlineForMcp({ har: '{}' }), + stableMachineError('HAR_ANALYSIS_FAILED', 'HAR analysis failed.', 'log.entries', 'HAR 必须') + ); + const directory = await mkdtemp(path.join(tmpdir(), 'har-malformed-approved-')); + const file = path.join(directory, 'malformed.har'); + await writeFile(file, '{}'); + preload.__testGrant([file]); + const record = preload.__testGrants()[0]; + await assert.rejects( + preload.analyzeApprovedForMcp({}), + stableMachineError('APPROVED_HAR_FAILED', 'Approved HAR analysis failed.', directory, file, 'log.entries', 'HAR 必须') + ); + assert.equal(preload.__testGrants().length, 0); + assert.throws(() => fs.fstatSync(record.fd), { code: 'EBADF' }); +}); + +test('approved MCP handler maps descriptor read failures without leaking paths and revokes the grant', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'har-read-failure-')); + const file = path.join(directory, 'failure.har'); + await writeFile(file, inlineHar(1)); + preload.__testGrant([file]); + const record = preload.__testGrants()[0]; + const originalRead = fs.readSync; + fs.readSync = () => { throw new Error(`EIO while reading ${file}`); }; + try { + await assert.rejects( + preload.analyzeApprovedForMcp({}), + stableMachineError('APPROVED_HAR_FAILED', 'Approved HAR analysis failed.', directory, file, 'EIO') + ); + } finally { + fs.readSync = originalRead; + } + assert.equal(preload.__testGrants().length, 0); + assert.throws(() => fs.fstatSync(record.fd), { code: 'EBADF' }); + await assert.rejects(preload.analyzeApprovedForMcp({}), { code: 'FILE_APPROVAL_REQUIRED' }); +}); + +test('strict validation rejects paths, hostile prototypes and unsafe bounds', async () => { + await assert.rejects(preload.analyzeApprovedForMcp({ path: fixture }), { code: 'INVALID_TOOL_INPUT' }); + await assert.rejects(preload.analyzeInlineForMcp({ har: '{}', detailLimit: 51 }), { code: 'INVALID_TOOL_INPUT' }); + await assert.rejects(preload.analyzeInlineForMcp({ har: 'x'.repeat(512 * 1024 + 1) }), { code: 'INVALID_TOOL_INPUT' }); + await assert.rejects(preload.analyzeInlineForMcp(JSON.parse('{"har":"{}","__proto__":{}}')), { code: 'INVALID_TOOL_INPUT' }); + await assert.rejects(preload.analyzeInlineForMcp(Object.assign(Object.create({ har: inlineHar(1) }), { detailLimit: 1 })), { code: 'INVALID_TOOL_INPUT' }); +}); diff --git a/plugins/har-doctor/test/path-redaction-fixture.mjs b/plugins/har-doctor/test/path-redaction-fixture.mjs new file mode 100644 index 00000000..da7fa139 --- /dev/null +++ b/plugins/har-doctor/test/path-redaction-fixture.mjs @@ -0,0 +1,86 @@ +const pathEncode = encodeURIComponent; + +function query(token) { + return `?safe=one&safe=two&${token}%ZZ=ignored#fragment-${token}`; +} + +export function pathRedactionHar() { + const queryToken = `github_pat_${'Q'.repeat(24)}`; + const invalidAfterToken = `ghp_${'I'.repeat(36)}`; + const nestedMethod = 'METHOD_PREFIX-----BEGIN PRIVATE KEY-----OUTER_METHOD_BODY-----BEGIN PRIVATE KEY-----INNER_METHOD_BODY-----END PRIVATE KEY-----METHOD_OUTER_TAIL_SECRET-----END PRIVATE KEY-----VISIBLE_METHOD_NESTED'; + const mismatchedMethod = '-----BEGIN RSA PRIVATE KEY-----METHOD_RSA_BODY-----END PRIVATE KEY-----METHOD_MISMATCH_TAIL-----END RSA PRIVATE KEY-----VISIBLE_METHOD_MATCHED'; + const unicodeUnclosedMethod = `${'ß'.repeat(52)}-----END PRIVATE KEY-----METHOD_STRAY_VISIBLE-----BEGIN PRIVATE KEY-----METHOD_UNICODE_UNCLOSED_BODY`; + const unicodeCompleteMethod = '-----BEGIN PRIVATE KEY-----METHOD_UNICODE_ß_BODY-----END PRIVATE KEY-----VISIBLE_METHOD_UNICODE'; + const sequentialMethod = '-----BEGIN EC PRIVATE KEY-----METHOD_FIRST_BODY-----END EC PRIVATE KEY-----VISIBLE_METHOD_FIRST-----BEGIN DSA PRIVATE KEY-----METHOD_SECOND_BODY-----END DSA PRIVATE KEY-----VISIBLE_METHOD_SECOND'; + const invalidComplete = `not a url/safe%2Fboundary/${pathEncode('-----BEGIN PRIVATE KEY-----INVALID_COMPLETE_BODY')}/INVALID_COMPLETE_TAIL_A/INVALID_COMPLETE_TAIL_B/${pathEncode(`-----END PRIVATE KEY-----VISIBLE_INVALID_${invalidAfterToken}`)}${query(queryToken)}`; + const invalidTruncated = `not a url/safe%2Fboundary/${pathEncode('-----BEGIN RSA PRIVATE KEY-----INVALID_TRUNCATED_BODY')}/INVALID_TRUNCATED_TAIL_A/INVALID_TRUNCATED_TAIL_B${query(queryToken)}`; + const unicodeUnclosed = `https://safe.test/safe%2Fboundary/${pathEncode(`${'ß'.repeat(52)}-----END PRIVATE KEY-----STRAY_VISIBLE-----BEGIN PRIVATE KEY-----UNICODE_UNCLOSED_BODY`)}/UNICODE_UNCLOSED_TAIL${query(queryToken)}`; + const unicodeComplete = `https://safe.test/safe%2Fboundary/${pathEncode('-----BEGIN PRIVATE KEY-----UNICODE_ß_BODY')}/${pathEncode('-----END PRIVATE KEY-----VISIBLE_UNICODE')}${query(queryToken)}`; + const malformedSame = `https://safe.test/safe%2Fboundary/${pathEncode('-----BEGIN PRIVATE KEY-----')}%ZZMALFORMED_SAME_SECRET/MALFORMED_SAME_AFTER${query(queryToken)}`; + const malformedCross = `https://safe.test/safe%2Fboundary/${pathEncode('-----BEGIN PRIVATE KEY-----VALID_PREFIX')}/%ZZMALFORMED_CROSS_SECRET/MALFORMED_CROSS_AFTER${query(queryToken)}`; + const mismatchedEnd = `https://safe.test/safe%2Fboundary/${pathEncode('-----BEGIN RSA PRIVATE KEY-----MISMATCH_BODY_ß')}/${pathEncode('-----END PRIVATE KEY-----MISMATCH_AFTER_WRONG')}/MISMATCH_STILL_PRIVATE/${pathEncode('-----END RSA PRIVATE KEY-----VISIBLE_MATCHED')}${query(queryToken)}`; + const nestedSameLabel = `https://safe.test/safe%2Fboundary/${pathEncode('-----BEGIN PRIVATE KEY-----OUTER_BODY')}/${pathEncode('-----BEGIN PRIVATE KEY-----INNER_BODY')}/${pathEncode('-----END PRIVATE KEY-----NESTED_OUTER_TAIL')}/OUTER_STILL_PRIVATE/${pathEncode('-----END PRIVATE KEY-----VISIBLE_NESTED')}${query(queryToken)}`; + const sequential = `https://safe.test/safe%2Fboundary/${pathEncode('-----BEGIN EC PRIVATE KEY-----FIRST_BODY')}/${pathEncode('-----END EC PRIVATE KEY-----VISIBLE_FIRST')}/${pathEncode('-----BEGIN DSA PRIVATE KEY-----SECOND_BODY')}/${pathEncode('-----END DSA PRIVATE KEY-----VISIBLE_SECOND')}${query(queryToken)}`; + const surrogate = `not a url/safe%2Fboundary/\uD800/SURROGATE_VISIBLE?safe=one&safe=two&${queryToken}%ZZ=ignored&\uD800=ignored#fragment-${queryToken}`; + const cases = [ + { name: 'invalid-complete', url: invalidComplete, method: nestedMethod, valid: false, visible: [] }, + { name: 'invalid-truncated', url: invalidTruncated, method: mismatchedMethod, valid: false, visible: [] }, + { name: 'unicode-unclosed', url: unicodeUnclosed, method: unicodeUnclosedMethod, valid: true, visible: ['STRAY_VISIBLE'] }, + { name: 'unicode-complete', url: unicodeComplete, method: unicodeCompleteMethod, valid: true, visible: ['VISIBLE_UNICODE'] }, + { name: 'malformed-same', url: malformedSame, valid: true, visible: [] }, + { name: 'malformed-cross', url: malformedCross, valid: true, visible: [] }, + { name: 'mismatched-end', url: mismatchedEnd, valid: true, visible: ['VISIBLE_MATCHED'] }, + { name: 'nested-same-label', url: nestedSameLabel, valid: true, visible: ['VISIBLE_NESTED'] }, + { name: 'sequential', url: sequential, method: sequentialMethod, valid: true, visible: ['VISIBLE_FIRST', 'VISIBLE_SECOND'] }, + { name: 'surrogate', url: surrogate, valid: false, visible: [] } + ]; + const forbidden = [ + queryToken, + invalidAfterToken, + '-----BEGIN', + '-----END', + 'INVALID_COMPLETE_BODY', + 'INVALID_COMPLETE_TAIL_A', + 'INVALID_COMPLETE_TAIL_B', + 'INVALID_TRUNCATED_BODY', + 'INVALID_TRUNCATED_TAIL_A', + 'INVALID_TRUNCATED_TAIL_B', + 'UNICODE_UNCLOSED_BODY', + 'UNICODE_UNCLOSED_TAIL', + 'UNICODE_ß_BODY', + 'MALFORMED_SAME_SECRET', + 'MALFORMED_SAME_AFTER', + 'MALFORMED_CROSS_SECRET', + 'MALFORMED_CROSS_AFTER', + 'MISMATCH_BODY_ß', + 'MISMATCH_AFTER_WRONG', + 'MISMATCH_STILL_PRIVATE', + 'OUTER_BODY', + 'INNER_BODY', + 'NESTED_OUTER_TAIL', + 'OUTER_STILL_PRIVATE', + 'FIRST_BODY', + 'SECOND_BODY', + 'OUTER_METHOD_BODY', + 'INNER_METHOD_BODY', + 'METHOD_OUTER_TAIL_SECRET', + 'METHOD_RSA_BODY', + 'METHOD_MISMATCH_TAIL', + 'METHOD_UNICODE_UNCLOSED_BODY', + 'METHOD_UNICODE_ß_BODY', + 'METHOD_FIRST_BODY', + 'METHOD_SECOND_BODY', + '\uD800', + '\\ud800' + ]; + const value = { + log: { + entries: cases.map(({ url, method }, index) => ({ + time: 1800 + index, + request: { url, method: method || 'GET', headers: [] }, + response: { status: 500, bodySize: 10, headers: [], content: { mimeType: 'application/json' } } + })) + } + }; + return { value, cases, forbidden, queryToken }; +} diff --git a/plugins/har-doctor/test/privacy-lifecycle.test.mjs b/plugins/har-doctor/test/privacy-lifecycle.test.mjs new file mode 100644 index 00000000..55601fcd --- /dev/null +++ b/plugins/har-doctor/test/privacy-lifecycle.test.mjs @@ -0,0 +1,165 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { analyze, classifyPii, redact, redactUrl } from '../src/core/analyze.js'; + +const require = createRequire(import.meta.url); +const preload = require('../src/preload/index.cjs'); +const fixture = fileURLToPath(new URL('./fixtures/sample.har', import.meta.url)); +const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +test('privacy projection stably anonymizes hostnames and path identities', () => { + const values = [ + 'internal.example.com', + 'alice', + 'acme-corp', + '18812345678', + '11010519491231002X', + '192.168.10.24', + 'alice@example.com', + '1234567890abcdef1234567890abcdef', + 'tenant-omega' + ]; + const raw = `https://internal.example.com/api/v1/users/alice/tenants/acme-corp/phones/18812345678/ids/11010519491231002X/ips/192.168.10.24/emails/alice@example.com/orders/1234567890abcdef1234567890abcdef/tenant-omega?access_token=do-not-export`; + const clean = redactUrl(raw); + const repeated = redactUrl(raw); + const changedHost = redactUrl(raw.replace('internal.example.com', 'staging.internal.example.com')); + const parsed = new URL(clean); + assert.equal(clean, repeated); + assert.notEqual(parsed.hostname, new URL(changedHost).hostname); + assert.match(parsed.hostname, /^host-[a-f0-9]{32}\.invalid$/); + assert.match(decodeURIComponent(parsed.pathname), /^\/api\/v1\/users\/\[user-/); + assert.match(decodeURIComponent(parsed.pathname), /\/tenants\/\[tenant-/); + assert.match(decodeURIComponent(parsed.pathname), /\/phones\/\[phone-/); + assert.match(decodeURIComponent(parsed.pathname), /\/ids\/\[identity-/); + assert.match(decodeURIComponent(parsed.pathname), /\/ips\/\[ip-/); + assert.match(decodeURIComponent(parsed.pathname), /\/emails\/\[email-/); + assert.match(decodeURIComponent(parsed.pathname), /\/orders\/\[id-/); + assert.match(decodeURIComponent(parsed.pathname), /\/\[tenant-/); + for (const value of values) assert.equal(clean.includes(value), false, `URL leaked ${value}`); + const report = analyze({ log: { entries: [{ time: 1500, request: { url: raw, method: 'GET' }, response: { status: 500 } }] } }); + const exported = JSON.stringify(redact(report)); + for (const value of values) assert.equal(exported.includes(value), false, `report leaked ${value}`); + assert.equal(exported.includes('do-not-export'), false); +}); + +test('privacy classifier covers query names, short aliases, tenants, compressed IPv6 and invalid bases', () => { + const secrets = ['alice', 'bob', 'acme', '2001:db8::7', 'internal.example.com', 'alice@example.com', '18812345678', '11010519491231002X']; + const raw = 'https://internal.example.com/u/alice/@bob/t/acme/ips/2001:db8::7?alice@example.com=value&2001%3Adb8%3A%3A7=value&safe=value&internal.example.com=value&alice=value&bob=value&acme=value'; + const clean = redactUrl(raw); + const parsed = new URL(clean); + for (const secret of secrets) assert.equal(clean.includes(secret), false, `URL leaked ${secret}`); + assert.match(decodeURIComponent(parsed.pathname), /\/u\/\[user-/); + assert.match(decodeURIComponent(parsed.pathname), /\/\[user-/); + assert.match(decodeURIComponent(parsed.pathname), /\/t\/\[tenant-/); + assert.match(decodeURIComponent(parsed.pathname), /\/ips\/\[ip-/); + assert.ok([...parsed.searchParams.keys()].every((key) => /^\[query-[a-f0-9]{32}\]$/.test(key))); + assert.equal(classifyPii('2001:db8::7'), 'ip'); + assert.equal(classifyPii('@bob'), 'user'); + assert.equal(classifyPii('acme', 't'), 'tenant'); + + const invalid = redactUrl('internal.example.com/u/alice/2001:db8::7?alice@example.com=value'); + assert.equal(invalid.includes('internal.example.com'), false); + assert.equal(invalid.includes('alice'), false); + assert.equal(invalid.includes('2001:db8'), false); + assert.equal(invalid.split('?')[0].split('/').length, 'internal.example.com/u/alice/2001:db8::7'.split('/').length); +}); + +test('method and MIME projections cannot echo hostile HAR fields, and ids remain distinct', () => { + const left = redactUrl('https://private.example/u/alice'); + const right = redactUrl('https://private.example/u/bob'); + assert.notEqual(left, right); + const report = analyze({ log: { entries: [{ + time: 1, + request: { url: 'https://internal.example.com/u/alice', method: 'GET alice@example.com 2001:db8::7' }, + response: { status: 500, content: { mimeType: 'application/alice@example.com; token=do-not-export' } } + }] } }); + const surface = JSON.stringify(redact(report)); + for (const value of ['internal.example.com', 'alice', 'alice@example.com', '2001:db8::7', 'do-not-export']) assert.equal(surface.includes(value), false, `report leaked ${value}`); + assert.equal(report.errors[0].method, 'OTHER'); + assert.deepEqual(Object.keys(report.types), ['unknown']); +}); + +test('file grants close proactively once, reset on replacement, and cancel for all session exits', async () => { + preload.__testClear(); + preload.__testSetGrantTtl(100); + try { + preload.__testGrant([fixture]); + const first = preload.__testGrants()[0]; + assert.equal(preload.__testHasGrantTimer(), true); + await wait(60); + preload.__testGrant([fixture]); + const replacement = preload.__testGrants()[0]; + assert.equal(first.closed, true); + await wait(60); + assert.doesNotThrow(() => fs.fstatSync(replacement.fd)); + await wait(70); + assert.equal(preload.__testGrants().length, 0); + assert.equal(preload.__testHasGrantTimer(), false); + assert.throws(() => fs.fstatSync(replacement.fd), { code: 'EBADF' }); + + preload.__testSetGrantTtl(1000); + preload.__testGrant([fixture]); + const consumed = preload.__testGrants()[0]; + preload.readGranted(); + assert.equal(preload.__testHasGrantTimer(), false); + assert.throws(() => fs.fstatSync(consumed.fd), { code: 'EBADF' }); + + let onPluginOut; + const bridge = preload.bridge({ onPluginOut(listener) { onPluginOut = listener; } }); + preload.__testGrant([fixture]); + const exited = preload.__testGrants()[0]; + onPluginOut(); + assert.equal(preload.__testGrants().length, 0); + assert.equal(preload.__testHasGrantTimer(), false); + assert.throws(() => fs.fstatSync(exited.fd), { code: 'EBADF' }); + + let resolveDialog; + const choosing = preload.bridge({ + onPluginOut(listener) { onPluginOut = listener; }, + showOpenDialog: () => new Promise((resolve) => { resolveDialog = resolve; }) + }).choose(); + onPluginOut(); + resolveDialog({ filePaths: [fixture] }); + await assert.rejects(choosing, { code: 'SESSION_EXPIRED' }); + assert.equal(preload.__testGrants().length, 0); + assert.equal(preload.__testHasGrantTimer(), false); + } finally { + preload.__testClear(); + preload.__testResetGrantTtl(); + } +}); + +test('failed descriptor close revokes authorization and retries without exposing the file path', () => { + preload.__testClear(); + let failOnce = true; + preload.__testSetCloseSync((fd) => { + if (failOnce) { + failOnce = false; + const error = new Error('simulated close failure'); + error.code = 'EIO'; + throw error; + } + return fs.closeSync(fd); + }); + try { + preload.__testGrant([fixture]); + const record = preload.__testGrants()[0]; + preload.__testClear(); + assert.equal(preload.__testGrants().length, 0); + assert.equal(preload.__testPendingCloses().length, 1); + assert.deepEqual(Object.keys(preload.__testPendingCloses()[0]).sort(), ['closeFailed', 'closed', 'fd']); + assert.equal(Object.hasOwn(preload.__testPendingCloses()[0], 'real'), false); + assert.doesNotThrow(() => fs.fstatSync(record.fd)); + assert.throws(() => preload.readGranted(), { code: 'FILE_APPROVAL_REQUIRED' }); + preload.__testClear(); + assert.equal(preload.__testPendingCloses().length, 0); + assert.throws(() => fs.fstatSync(record.fd), { code: 'EBADF' }); + } finally { + preload.__testResetCloseSync(); + preload.__testRetryPendingCloses(); + preload.__testClear(); + } +});