Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions plugins/har-doctor/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist/
node_modules/
16 changes: 16 additions & 0 deletions plugins/har-doctor/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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]` 协议值。
25 changes: 25 additions & 0 deletions plugins/har-doctor/README.md
Original file line number Diff line number Diff line change
@@ -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、插件退出和新授权都会立即重试,后台计时器仅作为兜底。
1 change: 1 addition & 0 deletions plugins/har-doctor/logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions plugins/har-doctor/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions plugins/har-doctor/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
73 changes: 73 additions & 0 deletions plugins/har-doctor/plugin.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
}
}
2 changes: 2 additions & 0 deletions plugins/har-doctor/scripts/build.mjs
Original file line number Diff line number Diff line change
@@ -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));
42 changes: 42 additions & 0 deletions plugins/har-doctor/scripts/dist-size.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
18 changes: 18 additions & 0 deletions plugins/har-doctor/scripts/verify-dist.mjs
Original file line number Diff line number Diff line change
@@ -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)`);
Loading
Loading