diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fab691c --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# Optional override; only placeholders belong in tracked files. +PRIVHUNTER_API_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b785b97 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +/config.json +/.env +/.env.* +!/.env.example +/data/ +/exports/ +/logs/ +/bin/ +*.db +*.db-* +*.sqlite +*.sqlite3 +*.sqlite3-* +*.pem +*.key +*.log +.DS_Store +/frontend/node_modules/ +/frontend/dist/ +/frontend/*.tsbuildinfo + +/privhunter + +/frontend/test-results/ +/frontend/playwright-report/ diff --git a/AIAPIS/aiapis.go b/AIAPIS/aiapis.go deleted file mode 100644 index 564f6ac..0000000 --- a/AIAPIS/aiapis.go +++ /dev/null @@ -1,125 +0,0 @@ -package aiapis - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "time" - "yuequanScan/config" -) - -const ( - apiTimeout = 30 * time.Second -) - -type ChatCompletionRequest struct { - Model string `json:"model"` - Messages []Message `json:"messages"` - Temperature float64 `json:"temperature,omitempty"` - MaxTokens int `json:"max_tokens,omitempty"` -} - -type Message struct { - Role string `json:"role"` - Content string `json:"content"` -} - -type ChatCompletionResponse struct { - ID string `json:"id"` - Choices []Choice `json:"choices"` - Error struct { - Message string `json:"message"` - } `json:"error"` -} - -type Choice struct { - Message Message `json:"message"` - FinishReason string `json:"finish_reason"` -} - -// CreateChatCompletion 发送请求到 API -func CreateChatCompletion(request ChatCompletionRequest, aiurl string, aiapikey string) (*ChatCompletionResponse, error) { - client := &http.Client{Timeout: apiTimeout} - - requestBody, err := json.Marshal(request) - if err != nil { - return nil, fmt.Errorf("marshal request failed: %v", err) - } - - req, err := http.NewRequest("POST", aiurl, bytes.NewBuffer(requestBody)) - if err != nil { - return nil, fmt.Errorf("create request failed: %v", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+aiapikey) - - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("API request failed: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, body) - } - - var response ChatCompletionResponse - if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { - return nil, fmt.Errorf("decode response failed: %v", err) - } - - if response.Error.Message != "" { - return nil, fmt.Errorf("API error: %s", response.Error.Message) - } - - return &response, nil -} - -func AIScan(model, aiurl, apikey, reqA, respA, respB, statusB string) (string, error) { - input := map[string]string{ - "reqA": reqA, - "responseA": respA, - "responseB": respB, - "statusB": statusB, - } - - inputBytes, err := json.MarshalIndent(input, "", " ") - if err != nil { - fmt.Printf("Error marshaling input: %v\n", err) - return "", err - } - fmt.Println(string(inputBytes)) - request := ChatCompletionRequest{ - Model: model, // 根据实际模型名称修改 - Messages: []Message{ - { - Role: "system", - Content: config.Prompt, - }, - { - Role: "user", - Content: string(inputBytes), - }, - }, - Temperature: 0.2, - MaxTokens: 2500, - } - - response, err := CreateChatCompletion(request, aiurl, apikey) - if err != nil { - fmt.Printf("Error: %v\n", err) - return "", err - } - - if len(response.Choices) > 0 { - return response.Choices[0].Message.Content, nil - } else { - fmt.Println("No response received") - return "", errors.New("no response received") - } -} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6af6a3f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +## Unreleased + +### Added +- React/TypeScript 工作台:概览、结果证据与人工复核、扫描控制、系统设置、成员管理及审计。 +- SQLite 持久化、管理员初始化、登录会话、CSRF 校验、角色权限与密码修改。 +- 分页筛选、批量复核、脱敏导出、按需详情、配置草稿保护及浏览器历史导航。 + +### Changed +- 后端迁移到 Gin,按入口、领域、认证、存储、HTTP、扫描和脱敏职责拆分。 +- 登录及管理页重新布局,样式按模块维护;移除旧 AIAPIS 和单页实现。 +- 被动扫描加入范围控制、有界队列、去重、超时、有限重试及响应上限。 +- 升级网络/文本依赖,修复三项可触达漏洞;最低 Go 版本为 1.25。 + +### Security +- 本地配置、数据库、环境文件、证书、日志和导出文件排除于版本控制;旧 config.json 从索引移除。 +- 凭证不回显,证据按字段脱敏;密码和会话令牌以哈希存储。 + +旧配置须显式通过 `-import-config` 本地迁移;新的启动、构建和验证方法见 README。真实模型供应商和系统 CA 信任的验证边界见 `docs/verification.md`。 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1586537 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +.PHONY: build test dev clean +build: + cd frontend && npm ci && npm run build + go build -o bin/privhunter ./cmd/privhunter +test: + go test -race ./... + go vet ./... + cd frontend && npm run build +dev: + go run ./cmd/privhunter +clean: + rm -rf bin frontend/dist diff --git a/README.md b/README.md index a36b6fd..e79b8dd 100644 --- a/README.md +++ b/README.md @@ -1,169 +1,88 @@ -# PrivHunterAI -一款通过被动代理方式,利用主流 AI(如 Kimi、DeepSeek、GPT 等)检测越权漏洞的工具。其核心检测功能依托相关 AI 引擎的开放 API 构建,支持 HTTPS 协议的数据传输与交互。 +# PrivHunterAI -## 工作流程 - +基于 Go / Gin、React / TypeScript 和 SQLite 的本地权限检测工作台。通过被动代理采集 HTTP(S) 流量,替换账号 B 凭证进行对比,结合兼容 Chat Completions 的模型分析与人工复核形成证据记录。 -## Prompt -```json -{ - "role": "你是一个专注于HTTP语义分析的越权漏洞检测专家,负责通过对比HTTP数据包,精准检测潜在的越权行为,并给出基于请求性质、身份字段、响应差异的系统性分析结论。", - "input_params": { - "reqA": "原始请求对象(包括URL、方法和参数)", - "responseA": "账号A发起请求的响应数据", - "responseB": "将账号A凭证替换为账号B凭证后的响应数据", - "statusB": "账号B请求的HTTP状态码(优先级排序:403 > 500 > 200)" - }, - "analysis_flow": { - "preprocessing": [ - "STEP 0. **请求类型识别(读/写)**:通过请求方法(GET/POST/PUT/DELETE)、URL特征、参数内容、请求体结构等判断请求是否为写操作,若包含典型身份字段(如 user_id/account_id),优先判断为写操作。", - "STEP 1. **接口属性判断**:识别接口是否为公共接口(如验证码获取、公告类资源),结合路径命名、是否要求认证等进行判断。", - "STEP 2. **动态字段过滤**:自动忽略影响判断的动态字段(如 timestamp、request_id、trace_id、nonce、session_id 等),支持后续通过配置扩展字段。", - "STEP 3. **身份字段提取**:分析请求参数及Body中是否存在账号身份字段(如 user_id、account_id、email 等),用于辅助判断操作行为目标。" - ], - "core_logic": { - "快速判定通道(优先级从高到低)": [ - "1. **越权行为(Result返回True)**:若为写操作,且 responseA 与 responseB 核心字段一致,均表示写入/修改成功(如 writeStatus = success),视为写操作型越权(true)。", - "2. **越权行为(Result返回True)**:若 responseB 与 responseA 关键字段(如 data.id、user_id、account_number)完全一致(不包含动态字段),判断为读操作型越权(true)。", - "3. **越权行为(Result返回True)**:若 responseB 与 responseA 完全一致,判断为越权行为(true)。", - "4. **越权行为(Result返回True)**:若 responseB 中包含 responseA 的敏感字段(如 user_id、email、balance),但无账号B相关数据,判断为越权行为(true)。", - "5. **非越权行为(Result返回false)**:若 responseB.status_code 为403或401,判断为无越权行为(false)。", - "6. **非越权行为(Result返回false)**:若 responseB 为空(如 null、[]、{}),且 responseA 有数据,判断为非越权行为(false)。", - "7. **非越权行为(Result返回false)**:若 responseB 与 responseA 在关键业务字段值或结构上显著不一致,判断为非越权行为(false)。", - "8. **无法判断(Result返回Unknown)**:若不满足明确的越权或非越权标准,且字段相似度处于模糊区间,返回 unknown。", - "9. **无法判断(Result返回Unknown)**:若 responseB 为500、乱码或格式异常时,返回 unknown。" - ], - "深度分析模式(快速通道未触发时执行)": { - "字段值对比": [ - "a. **结构层级分析**:采用JSON Path对比字段层级结构和字段覆盖率,评估字段匹配相似度。", - "b. **关键字段匹配**:识别如 user_id、order_id、account_number 等字段,分析命名、路径、值的一致性。" - ], - "语义分析": [ - "i. **数值型字段检查**:判断是否存在账户余额、积分、金额等关键字段泄露。", - "ii. **格式与模式分析**:分析如手机号、邮箱、身份证等字段格式是否对应账号A。", - "iii. **敏感字段泄露检测**:自动识别 password、token、email、phone 等字段,判定是否为账号A的数据。" - ] - } - } - }, - "decision_tree": { - "true": [ - "1. 若写操作响应中,账号B执行后获得与账号A一致的 success 响应,判定为越权(res: true)。", - "2. 若为读操作且 responseB 返回账号A的敏感数据,判定为越权(res: true)。", - "3. 若 responseB 与 responseA 字段完全一致,未包含账号B自身信息,判定为越权(res: true)。", - "4. 若关键字段(如 order_id、user_id、phone)结构和值完全一致,判定为越权(res: true)。" - ], - "false": [ - "1. responseB.status_code 为 403/401 → 非越权(res: false)。", - "2. responseB 数据为空但 responseA 有内容 → 非越权(res: false)。", - "3. responseB 与 responseA 在关键字段值或结构上差异显著 → 非越权(res: false)。", - "4. 若接口为公共资源接口,无需鉴权 → 非越权(res: false)。" - ], - "unknown": [ - "1. 相似度处于中间地带(50%-80%),字段结构部分匹配 → 无法判断(res: unknown)。", - "2. 响应为乱码、加密格式、异常格式 → 无法判断(res: unknown)。", - "3. 无法判断操作目标是账号A还是账号B(如无身份字段) → 无法判断(res: unknown)。" - ] - }, - "output_spec": { - "json": { - "res": "结果为 true、false 或 unknown。", - "reason": "提供详细的分析过程和判断依据。", - "confidence": "结果的可信度(百分比,string类型,需要加百分号)。" - } - }, - "notes": [ - "1. 判断为越权时,res 返回 true;非越权时,返回 false;无法判断时,返回 unknown。", - "2. 保持输出为 JSON 格式,不添加任何额外文本。", - "3. 确保 JSON 格式正确,便于后续处理。", - "4. 保持客观,仅基于请求及响应内容进行判断。", - "5. 支持用户提供动态字段或解密方式,以提高分析准确性。", - "6. 若请求方法无法识别为明确的写/读操作,默认保守处理为 unknown。" - ], - "advanced_config": { - "similarity_threshold": { - "structure": 0.8, - "content": 0.7 - }, - "sensitive_fields": [ - "password", - "token", - "phone", - "email", - "user_id", - "account_id", - "id_card" - ], - "dynamic_fields_default": [ - "timestamp", - "request_id", - "trace_id", - "nonce", - "session_id" - ], - "auto_retry": { - "when": "检测到加密数据、乱码、或格式异常时", - "action": "建议用户提供解密方式后重新检测" - } - } -} +原项目:[Ed1s0nZ/PrivHunterAI](https://github.com/Ed1s0nZ/PrivHunterAI)。旧文档保留在 [docs/legacy-readme.md](docs/legacy-readme.md),其中旧启动方式不再适用。 + +## 快速启动 + +依赖:Go 1.25 或以上、Node.js 22.12 或以上、C 编译器(SQLite 驱动使用 CGO)。 + +```sh +cd frontend +npm ci +npm run build +cd .. +go build -o bin/privhunter ./cmd/privhunter +./bin/privhunter ``` -## 使用方法 -1. 下载源代码 或 Releases; -2. 编辑根目录下的`config.json`文件,配置`AI`和对应的`apiKeys`(只需要配置一个即可);(AI的值可配置qianwen、kimi、hunyuan、gpt、glm 或 deepseek) ; -3. 配置`headers2`(请求B对应的headers);可按需配置`suffixes`、`allowedRespHeaders`(接口后缀白名单,如.js); -4. 执行`go build`编译项目,并运行二进制文件(如果下载的是Releases可直接运行二进制文件); -5. 首次启动程序后需安装证书以解析 HTTPS 流量,证书会在首次启动程序后自动生成,路径为 ~/.mitmproxy/mitmproxy-ca-cert.pem(Windows 路径为%USERPROFILE%\\.mitmproxy\mitmproxy-ca-cert.pem)。安装步骤可参考 Python mitmproxy 文档:[About Certificates](https://docs.mitmproxy.org/stable/concepts-certificates/)。 -6. BurpSuite 挂下级代理 `127.0.0.1:9080`(端口可在`mitmproxy.go` 的`Addr:":9080",` 中配置)即可开始扫描; -7. 终端和web界面均可查看扫描结果,前端查看结果请访问`127.0.0.1:8222` 。 +访问 `http://127.0.0.1:8222`,首次设置管理员;密码至少 12 字节。系统无公开注册,管理员在用户管理中添加只读成员或其他管理员。 -### 配置文件介绍(config.json) -| 字段 | 用途 | 内容举例 | -|------------------|----------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------| -| `AI` | 指定所使用的 AI 模型 | `qianwen`、`kimi`、`hunyuan` 、`gpt`、`glm` 或 `deepseek` | -| `apiKeys` | 存储不同 AI 服务对应的 API 密钥 (填一个即可,与AI对应) | - `"kimi": "sk-xxxxxxx"`
- `"deepseek": "sk-yyyyyyy"`
- `"qianwen": "sk-zzzzzzz"`
- `"hunyuan": "sk-aaaaaaa"` | -| `headers2` | 自定义请求B的 HTTP 请求头信息 | - `"Cookie": "Cookie2"`
- `"User-Agent": "PrivHunterAI"`
- `"Custom-Header": "CustomValue"` | -| `suffixes` | 需要过滤的文件后缀名列表 | `.js`、`.ico`、`.png`、`.jpg`、 `.jpeg` | -| `allowedRespHeaders` | 需要过滤的 HTTP 响应头中的内容类型(`Content-Type`) | `image/png`、`text/html`、`application/pdf`、`text/css`、`audio/mpeg`、`audio/wav`、`video/mp4`、`application/grpc`| -| `respBodyBWhiteList` | 鉴权关键字(如暂无查询权限、权限不足),用于初筛未越权的接口 | - `参数错误`
- `数据页数不正确`
- `文件不存在`
- `系统繁忙,请稍后再试`
- `请求参数格式不正确`
- `权限不足`
- `Token不可为空`
- `内部错误`| +在扫描控制中填写精确目标域名 / IP 和账号 B 请求头,再启用扫描。上游代理连接 `127.0.0.1:9080`。HTTPS 采集需要安装本机生成的代理 CA,默认位于 `~/.mitmproxy/mitmproxy-ca-cert.pem`。只在授权测试目标与测试账户上采集。 -## 输出效果 -持续优化中,目前输出效果如下: +模型端点使用完整 Chat Completions URL。可通过 `PRIVHUNTER_API_KEY` 环境变量覆盖本地模型密钥,避免写入数据库。未配置模型时保留账号 A/B 证据供人工复核;HTTP 401/403 标记为拒绝,关键词命中与普通相似响应不会直接当作确定结论。模型看到的是脱敏证据,身份信息缺失可能导致 unknown。 -1. 终端输出: - +## 功能 -2. 前端输出(访问127.0.0.1:8222): - +- 首次初始化、登录/退出、密码哈希、哈希会话、CSRF 与管理员/只读角色。 +- SQLite 持久化用户、设置、结果、复核备注与操作审计。 +- 概览、分页搜索、判定/复核筛选、账号 A/B 请求和响应对比。 +- 人工确认、误报、解决状态,记录删除,CSV/JSON 筛选导出。 +- 扫描暂停与目标范围、静态后缀过滤、凭证替换、5 分钟重复抑制。 +- 64 条有界队列、请求超时、模型有限重试、1 MiB 响应及解压上限。 +- 证据中的认证头、敏感 JSON 字段和查询参数脱敏;CSV 公式注入防护。 -## 后续计划 -1. 添加敏感信息的扫描,例如通过正则匹配+AI辅助识别的方式扫描js文件中泄露的秘钥; -2. 优化越权漏洞/未授权漏洞扫描流程,实现消耗更少的token、更准确的扫描。 +## 目录 -## 更新时间线 -- 2025.02.18 - 1. ⭐️新增扫描失败重试机制,避免出现漏扫; - 2. ⭐️新增响应Content-Type白名单,静态文件不扫描; - 3. ⭐️新增限制每次扫描向AI请求的最大字节,避免因请求包过大导致扫描失败。 -- 2025.02.25 - 02.27 - 1. ⭐️新增对URL的分析(初步判断是否可能是无需数据鉴权的公共接口); - 2. ⭐️新增前端结果展示功能。 - 3. ⭐️新增针对请求B添加其他headers的功能(适配有些鉴权不在cookie中做的场景)。 -- 2025.03.01 - 1. 优化Prompt,降低误报率; - 2. 优化重试机制,重试会提示类似:`AI分析异常,重试中,异常原因: API returned 401: {"code":"InvalidApiKey","message":"Invalid API-key provided.","request_id":"xxxxx"}`,每10秒重试一次,重试5次失败后放弃重试(避免无限重试)。 -- 2025.03.03 - 1. 💰成本优化:在调用 AI 判断越权前,新增鉴权关键字(如 “暂无查询权限”“权限不足” 等)过滤环节,若匹配到关键字则直接输出未越权结果,节省 AI tokens 花销,提升资源利用效率。 -- 2025.03.21 - 1. ⭐️新增终端输出请求包记录。 -- 2025.04.10 - 1. ⭐️新增结果可信度(confidence)输出,并优化Prompt。 -- 2025.04.22 - 04.23 - 1. 优化前端样式,并引入分页查询功能,避免一次性加载全部数据,从而减轻浏览器的渲染压力,提升页面响应速度和用户体验。 -- 2025.06.07 - 1. ⭐️新增“导出结果”功能; - 2. 解决响应体乱码问题。 +```text +cmd/privhunter/ 启动与服务生命周期 +internal/ + auth/ 密码、会话与用户权限约束 + config/ 旧配置的显式本地迁移 + domain/ 领域数据结构 + httpapi/ Gin 路由、中间件与接口处理 + scanner/ 被动代理、队列、重放和模型适配 + security/ 统一脱敏规则 + store/ SQLite schema 与数据访问 +frontend/src/ + api/ API 契约与请求客户端 + components/ 布局与共享展示组件 + hooks/ 数据加载状态 + pages/ React 业务页面 + styles/ 基础、组件、页面与响应式样式 +docs/ 重构契约与历史说明 +``` -# 注意 -声明:仅用于技术交流,请勿用于非法用途。 +## 运行配置与迁移 + +```sh +./bin/privhunter -listen 127.0.0.1:8222 -proxy 127.0.0.1:9080 -database data/privhunter.db -frontend frontend/dist +# 仅显式执行时覆盖本地扫描设置,保留暂停状态 +./bin/privhunter -import-config ./config.json +``` + +默认仅监听本机。HTTPS 反向代理部署时设置 `-secure-cookie`,保持同源访问,禁止把 9080 代理直接暴露到公网。初始化应在本机完成后再开放远程访问。 + +数据目录、SQLite、旧配置、环境文件、日志和导出均被 Git 排除。SQLite 设置中保存的密钥是本地明文,依赖文件权限保护;不会从设置 API 返回。需要清除密钥时可通过 API 传入空字符串;空白 UI 输入表示保留当前值。数据库备份必须作为敏感文件保管。 + +## 开发与验证 + +```sh +# 在项目根目录验证后端 +go test ./... +go test -race ./... +go vet ./... +# 启动后端 +go run ./cmd/privhunter +# 另一终端,Vite 将 /api 转发到 8222 +cd frontend +npm run dev +# 在 frontend 目录构建与浏览器验收(独立测试数据库) +npm run build +npm run test:e2e +``` + +工作台页面使用 `#/findings` 等地址,刷新保留当前页面,支持浏览器返回和前进。前端开发依赖和生产依赖可在 `frontend` 目录执行 `npm audit` 检查;后端在项目根目录执行 `go run golang.org/x/vuln/cmd/govulncheck@latest ./...` 检查可触达漏洞。 + +前端通过 `npm run build` 执行严格 TypeScript 检查。服务未启用流量正文日志。敏感数据脱敏依赖字段名,业务特有格式应扩展 `internal/security` 并添加样例测试。 diff --git a/cmd/privhunter/main.go b/cmd/privhunter/main.go new file mode 100644 index 0000000..1034b85 --- /dev/null +++ b/cmd/privhunter/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "context" + "errors" + "flag" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gin-gonic/gin" + "github.com/lqqyt2423/go-mitmproxy/proxy" + "yuequanScan/internal/config" + "yuequanScan/internal/httpapi" + "yuequanScan/internal/scanner" + "yuequanScan/internal/store" +) + +func main() { + addr := flag.String("listen", "127.0.0.1:8222", "控制台监听地址") + dbPath := flag.String("database", "data/privhunter.db", "SQLite 数据库文件") + frontend := flag.String("frontend", "frontend/dist", "React 构建目录") + secure := flag.Bool("secure-cookie", false, "HTTPS 部署使用 Secure Cookie") + proxyAddr := flag.String("proxy", "127.0.0.1:9080", "被动代理监听地址") + importPath := flag.String("import-config", "", "显式导入旧配置到本地 SQLite(保持暂停)") + flag.Parse() + gin.SetMode(gin.ReleaseMode) + db, e := store.Open(*dbPath) + if e != nil { + log.Fatal("数据库初始化失败") + } + defer db.Close() + if *importPath != "" { + settings, err := config.ImportLegacy(*importPath) + if err != nil { + log.Fatal(err) + } + if err = db.SaveSettings(settings); err != nil { + log.Fatal("旧配置保存失败") + } + log.Print("旧配置已本地导入,扫描保持暂停") + } + engine := scanner.New(db) + apiServer := httpapi.New(db, *secure) + apiServer.ScannerStatus = engine.Status + router := apiServer.Router() + router.Static("/assets", *frontend+"/assets") + router.GET("/", func(c *gin.Context) { c.File(*frontend + "/index.html") }) + server := &http.Server{Addr: *addr, Handler: router, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second} + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + engine.Run(ctx) + passive, err := proxy.NewProxy(&proxy.Options{Addr: *proxyAddr, StreamLargeBodies: 1 << 20}) + if err != nil { + log.Fatal("代理初始化失败") + } + passive.AddAddon(engine) + go func() { + if err := passive.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Print("代理已停止") + stop() + } + }() + go func() { + log.Printf("PrivHunter 控制台监听 %s", *addr) + if e := server.ListenAndServe(); e != nil && !errors.Is(e, http.ErrServerClosed) { + log.Print("HTTP 服务启动失败") + stop() + } + }() + <-ctx.Done() + shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + passive.Close() + engine.Wait() + if server.Shutdown(shutdown) != nil { + log.Print("HTTP 服务关闭超时") + } +} diff --git a/config.json b/config.json deleted file mode 100644 index cbfefbc..0000000 --- a/config.json +++ /dev/null @@ -1,126 +0,0 @@ -{ - "AI": "qianwen", - "apiKeys": { - "kimi": "sk-yyyyyyy", - "deepseek": "sk-yyyyyyy", - "qianwen": "sk-yyyyyyy", - "hunyuan": "sk-yyyyyyy", - "glm":"sk-yyyyyyy", - "gpt":"sk-yyyyyyy" - }, - "headers2": { - "Cookie": "Cookie2", - "User-Agent": "PrivHunterAI", - "Custom-Header": "CustomValue" - }, - "suffixes": [ - ".js", - ".ico", - ".png", - ".jpg", - ".jpeg", - ".gif", - ".bmp", - ".svg", - ".ttf", - ".woff", - ".woff2", - ".eot", - ".otf", - ".mp3", - ".wav", - ".ogg", - ".mp4", - ".webm", - ".avi", - ".css", - ".scss", - ".less", - ".tmp", - ".temp", - ".cache", - ".swp", - ".log", - ".err", - ".env", - ".json", - ".yml", - ".yaml", - ".xml", - ".ini", - ".class", - ".dll", - ".so", - ".zip", - ".tar", - ".gz", - ".txt", - ".md", - ".doc", - ".docx", - ".pdf", - ".csv", - ".xlsx", - ".xls", - ".sh", - ".bat", - ".gitignore", - ".lock", - ".bak" - ], - "allowedRespHeaders": [ - "image/png", - "text/html", - "application/pdf", - "text/css", - "audio/mpeg", - "audio/wav", - "video/mp4", - "application/grpc" - ], - "respBodyBWhiteList": [ - "登录失败", - "登录过期,请重新登录", - "Token已过期", - "token已失效,请重新获取", - "日期格式错误", - "授权已过期", - "缺少请求参数", - "参数不能为空", - "汇总方式不合法", - "抱歉,您无法访问此文件", - "账号不存在", - "签名错误", - "账号被禁用", - "图片验证码错误,请刷新后重试", - "链接过期", - "链接已失效", - "参数错误", - "数据页数不正确", - "文件不存在", - "系统繁忙,请稍后再试", - "请求参数格式不正确", - "权限不足", - "Token不可为空", - "内部错误", - "404 page not found", - "请重新刷新页面", - "账号信息不合法", - "过期无法访问", - "不支持的应用", - "登录过期", - "暂无查询权限", - "信息校验错误", - "操作无权限", - "系统繁忙请稍后重试", - "登录信息错误或已过期", - "session获取用户名失败", - "登录已过期,请重新登录", - "sso获取token失败", - "请求参数不可为空", - "Token已失效,请重新登录", - "未登录", - "账户不存在", - "token 不合法" - ] -} diff --git a/docs/completion-audit.md b/docs/completion-audit.md new file mode 100644 index 0000000..63721fd --- /dev/null +++ b/docs/completion-audit.md @@ -0,0 +1,33 @@ +# 最佳实践优化完成核对 + +2026-10-07。按用户的 React/Gin 重构、维护性、功能深化、管理页反馈与敏感信息本地保留要求,以及既有 `rebuild-plan.md` 的八项交付契约核对。未把「最佳实践」解释成所有未来功能或所有部署场景。 + +| 要求 | 当前状态证据 | 结论 | +| --- | --- | --- | +| 1. 初始化、登录、退出、改密、用户权限及会话失效 | auth/service.go、httpapi/server.go;认证测试及完整浏览器初始化/改密/只读流程;最后管理员测试 | 已完成 | +| 2. SQLite 持久化、会话哈希、迁移与受限权限 | schema.sql、migrations.go、store.go、auth/service.go;关闭重开测试、旧版本升级/新版本拒绝测试;本地目录 0700、库 0600 | 已完成 | +| 3. React 六页侧栏与 Gin 同源服务 | App/Layout、各 pages、package.json、cmd 入口和 Router;浏览器逐页导航、桌面和手机截图 | 已完成 | +| 4. 概览、结果搜索分页筛选、A/B 证据、复核备注、删除和导出 | dashboard/findings/detail/export/bulk API 与存储、Findings/EvidenceComparison;本地代理至 UI、复核刷新、指标跳转、批量复核、导出回归;摘要/证据/导出存储测试 | 已完成 | +| 5. 保留被动扫描,范围、暂停、去重、过滤、有界队列、超时、有限重试及失败记录 | scanner/engine.go、body.go、model.go;代理、凭证替换、头副本、去重、拒绝响应、模型 mock、解压上限测试 | 已完成 | +| 6. 通用模型适配、端点/名称配置、环境密钥、敏感字段不回显 | model.go、settings.go、Settings 页面;本地模型协议测试、API 密钥/请求头不返回测试 | 已完成 | +| 7. 设置校验及加载/空白/错误/权限/手机状态 | Settings/Status/useQuery、API 输入验证;多行输入、保存、拒绝离开草稿、页面隔离及权限浏览器流程;revision 并发冲突测试 | 已完成 | +| 8. 敏感凭证、JSON 和查询字段脱敏;正文与凭证不进日志 | security/redact.go、scanner 持久化与模型输入路径、通用错误文案;脱敏测试读取完整详情正文验证;本地配置凭证与可提交文件比对零命中 | 已完成 | +| 可维护目录、移除 AIAPIS、文档和运行入口 | cmd/internal 分职责、frontend api/components/hooks/pages/styles、Makefile/README;旧目录与 config 已从索引删除,本地 config 留存且忽略 | 已完成 | +| 用户反馈的登录割裂、管理页粗糙、页面简单 | 登录统一画布;配置分组/帮助侧栏/保存栏;成员摘要/弹窗;概览真实趋势与复核队列;桌面/手机实际截图和 E2E | 已完成 | +| 刷新/浏览器历史、详情性能、可维护样式 | 导航 hook 与权限回退;摘要和详情独立接口/类型;九个样式文件严格保留顺序,构建 CSS 拆分前后字节一致 | 已完成 | +| 不上传敏感信息 | config/env/data/db/cert/log/export 忽略,config 索引删除;没有执行 commit/push/发布,服务仅本地 | 已完成 | + +## 本次最终检查 + +- 全仓库 `go test -race ./...`、`go vet ./...`、Go 二进制构建通过。 +- React 严格 TypeScript/Vite 构建通过;样式拆分前后 CSS 哈希一致。 +- 两项浏览器完整流程通过,覆盖配置与导航、用户/审计/导出/密码/权限,以及真实本地 HTTP 代理至证据复核。 +- `git diff --check` 通过,运行页面 HTTP 200。 +- `npm audit`(包含开发依赖)零漏洞。 +- govulncheck 发现的三项可触达漏洞已通过 x/net v0.55.0、x/text v0.39.0 等升级消除;最低 Go 版本相应为 1.25,README 同步。复扫可触达漏洞为零。报告仍列出 1 项导入包、19 项模块级未触达公告,不能将结果表述为所有依赖零公告。 + +## 验证边界与后续建议 + +真实外部模型供应商没有调用;系统级代理 CA 安装和信任没有自动化验证。目前验证的是本地 HTTP 代理链路、模型兼容协议以及脱敏输入。字段脱敏规则不能推断业务自定义的任意敏感内容;相关说明见 verification.md。 + +experience-expansion.md 中明确列为后续建议的项目隔离、多身份、扫描任务历史、报告中心及模型用量统计,并非本轮确认的交付要求,仍未实现。原文的日期/方法过滤和连接测试也保持建议状态,不计作已交付功能。该审计不宣称未来功能全部完成。 diff --git a/docs/experience-expansion.md b/docs/experience-expansion.md new file mode 100644 index 0000000..004d114 --- /dev/null +++ b/docs/experience-expansion.md @@ -0,0 +1,40 @@ +# 工作台体验深化 + +## Workflow Gate Report +- User request: 功能和页面过简,深化使用体验、交互与场景功能。 +- Phase: P10;已有功能但缺少任务导向信息和操作闭环。 +- Required/found artifacts: 当前页面、实际 API、用户截图、已有重构契约。 +- Implementation allowed: yes,依据实际数据逐步扩展,不用虚构演示指标。 +- Execution scope: 先完成工作台总览、配置就绪提示与可点击的复核队列;后续完善结果处理、配置体验与成员操作。 +- Acceptance: 统计口径明确;空数据可指引下一步;只读角色看不到配置操作;指标跳转筛选;图表可访问;异常可重试。 +- Risks: 不把模型推断当作确证,不自动发起额外攻击性测试;敏感字段不进入新的汇总接口。 + +## 页面职责和待扩展方向 + +| 页面 | 核心任务 | 体验与功能 | +| --- | --- | --- | +| 概览 | 判断优先处理什么、系统能否工作 | 真正的待复核/已确认/已解决指标、七日趋势、结果分布、目标热点、最近检测、就绪检查和快捷入口 | +| 结果 | 快速定位证据并形成结论 | 从概览带入筛选、复核状态、请求/响应对比;后续增加日期与方法筛选、批量复核、详情标签页与键盘操作 | +| 扫描控制 | 安全且清楚地启动采集 | 目标范围和账号 B 配置、暂停、队列状态;后续拆分范围/身份/过滤步骤,操作前显示缺失条件、配置保存反馈与帮助 | +| 系统设置 | 维护模型与运行配置 | 密钥保密、保留/清除区别、模型配置;后续增加连接测试(不发送流量)、重试状态与配置草稿提示 | +| 用户管理 | 最小权限协作 | 创建/停用账号;后续完善角色调整确认、自身操作提示、最后管理员保护说明 | +| 审计 | 知道谁在什么时间改变了什么 | 记录时间、操作者、对象;后续增加可读操作文案与搜索 | + +以上方向以完整用户流程优先排序。历史记录为空时不填充假数据;不凭空展示成本、令牌数或模型准确率。此轮先完成可用概览和跨页定位,其他深化需要继续实施与独立验收。 + +## 此轮已实施与验证 + +- 概览:真实待复核/已确认/失败口径、北京时间七日趋势、判定分布、完成复核比例、目标热点、最近记录、采集就绪状态;点击带入结果筛选;后台页面可见时每 30 秒刷新。 +- 结果:请求/响应标签切换、最多 500 个 JSON 差异字段、复制脱敏证据、当前页多选与事务批量复核、清除筛选、Esc 关闭。 +- 成员:用户名检索、角色调整确认、停用确认、禁止在界面停用自身;后端最后管理员保护继续有效。 +- 验证:TypeScript/Vite 构建、Go 测试与竞态检查通过;2 项 Playwright 流程通过,含新增指标跳转筛选与批量复核。服务已更新到 8222。 + +## 后续功能建议(尚未实施) + +1. 项目/目标工作区:每个项目独立范围、身份和检测记录;切换项目不会串用凭证。 +2. 身份配置管理:命名账号、角色标签、凭证替换与到期提醒,不返回明文。 +3. 扫描任务历史:采集开始/结束、配置快照、失败原因、重试审批与关联结果。 +4. 报告中心:基于人工确认结果生成可分享的脱敏报告,保留导出范围和版本。 +5. 模型运行记录:实际记录耗时、token 与失败分类,再展示成本与效果;不推算假指标。 + +项目隔离和多身份会改变数据模型与扫描归属,需要单独的数据迁移及权限验收。 diff --git a/docs/interaction-quality.md b/docs/interaction-quality.md new file mode 100644 index 0000000..518cf11 --- /dev/null +++ b/docs/interaction-quality.md @@ -0,0 +1,30 @@ +# 交互与状态质量优化 + +Workflow Gate Report:P10,当前代码与实际流程具备输入/API 契约,允许优化。范围:请求取消、搜索防抖、配置编辑草稿保护、页面间状态隔离;验收:构建、浏览器回归以及多行输入/离开草稿验证。 + +Maintainability Gate Report:扫描 useQuery、Settings、App 与 Findings;风险 medium(跨页面状态,但模块规模适中);允许 narrow_fix 与共享 hook 提取。不扩大现有模块职责,使用可复用 hook;不更改扫描行为。 + +发现:列表 textarea 每次输入都会移除空行,导致用户无法自然连续输入多行;扫描/系统页共用组件状态;加载请求未取消;刷新与页面切换缺少草稿保护。修复以上可复现交互问题,并保留敏感输入仅存在页面内存。 + +## 弹窗与审计切片 +Workflow Gate:P10;现有 API 与页面齐全,允许窄范围扩展。统一使用原生 dialog(焦点限制、背景不可交互、Esc、关闭后恢复焦点),审计增加服务端搜索与操作类型过滤、准确总数及中文动作名称。验收包含键盘弹窗流程和审计过滤测试。 + +## 配置并发与数据库迁移 +Workflow Gate:后端安全与维护迭代,配置契约新增 revision,前后端同时更新;保存需要读取页面版本,过期版本返回 409,保留草稿并给出显式重新加载入口。Maintainability Gate:store.go 混合 schema、配置与查询;先抽出 migrations.go/settings.go,采用 feature_after_refactor。验收:旧 schema 升级、较新 schema 拒绝、两份配置并发冲突、UI 回归。 + +## 管理页视觉重构 +用户截图指出长表单、大片空白与缺少层级。实施范围:扫描/系统设置的分组卡片、侧边说明、固定保存区;成员统计与列表、新增成员弹窗。依赖已有设置/用户 API,保留字段标签、敏感输入规则、草稿保护与权限逻辑。验收须检查桌面及手机实际渲染截图,不以构建代替视觉验证。 + +## 证据按需加载 +Workflow Gate:结果/概览数据量优化;明确新增 GET /api/findings/:id,保持会话鉴权。列表从 SQLite 投影摘要,不读取请求/响应正文;详情单独读取完整脱敏证据,导出保留完整内容。共享查询 hook 支持关闭查询并按路径隔离结果。验收:摘要不含正文、详情可读取、分页与复核浏览器回归。 + +## 工作台导航持久化 +Workflow Gate Report:P10;用户授权最佳实践优化,现有六页与权限契约齐全;当前内存导航导致刷新丢失页面、返回按钮无法工作。允许实现同源 hash 路由(#/overview 等),无需后端重写规则;未知页面回概览,只读用户不能呈现管理员页面。未保存草稿的侧栏、历史导航和退出均保留确认。 +Maintainability Gate Report:App 混合认证与导航编排,约 130 行、medium;先把导航机制抽出共享 hook,再接入 App,允许 adapter_extraction。验收:刷新保留页面、浏览器返回/前进、拒绝离开保留草稿、只读直接地址回概览、正常登录与原流程回归。结果筛选仍为页内状态,此切片不扩大 API。 + +## 样式维护边界 +Workflow Gate:P9/P10,已实现 UI 的维护整理,视觉/API 契约不变,允许实施。 +Maintainability Gate Report:style.css 1590 行,混合全局、登录、布局、组件、各页与迭代覆写;high。需要先拆分再继续添加样式;允许 zero_behavior_refactor。按连续完整规则区段抽取,入口用 @import 严格保留原先顺序(含响应式覆盖),不重排选择器或改变值。验收:构建前后输出 CSS 字节一致、TypeScript/Vite 构建和完整浏览器流程。后续页面样式放对应文件,避免入口继续增长。 + +## 可触达依赖漏洞修复 +Workflow Gate:P9,最终依赖扫描发现 x/text v0.25.0 的 GO-2026-5970、x/net v0.40.0 的 GO-2026-5026 与 GO-2026-4918,扫描报告有模型 HTTP 客户端调用链。依赖更新属于用户最佳实践优化范围,允许实施。Maintainability Gate:仅 go.mod/go.sum 依赖调整,low,允许 narrow_fix;先升级到官方修复版本 x/text v0.39.0、x/net v0.55.0 并按要求调整 Go 工具链,再验证竞态测试、构建、漏洞扫描和浏览器全流程。若有兼容问题,以真实结果继续修复。 diff --git a/config/config.go b/docs/legacy-readme.md similarity index 52% rename from config/config.go rename to docs/legacy-readme.md index 080bf0f..91a4d70 100644 --- a/config/config.go +++ b/docs/legacy-readme.md @@ -1,32 +1,11 @@ -package config +# PrivHunterAI +一款通过被动代理方式,利用主流 AI(如 Kimi、DeepSeek、GPT 等)检测越权漏洞的工具。其核心检测功能依托相关 AI 引擎的开放 API 构建,支持 HTTPS 协议的数据传输与交互。 -import ( - "encoding/json" - "fmt" - "os" -) +## 工作流程 + -// 配置结构 -type Config struct { - AI string `json:"AI"` - Headers2 map[string]string `json:"headers2"` - Suffixes []string `json:"suffixes"` - AllowedRespHeaders []string `json:"allowedRespHeaders"` - APIKeys struct { - Kimi string `json:"kimi"` - DeepSeek string `json:"deepseek"` - Qianwen string `json:"qianwen"` - HunYuan string `json:"hunyuan"` - Gpt string `json:"gpt"` - Glm string `json:"glm"` - } `json:"apiKeys"` - RespBodyBWhiteList []string `json:"respBodyBWhiteList"` -} - -// 全局配置变量 -var conf Config - -var Prompt = ` +## Prompt +```json { "role": "你是一个专注于HTTP语义分析的越权漏洞检测专家,负责通过对比HTTP数据包,精准检测潜在的越权行为,并给出基于请求性质、身份字段、响应差异的系统性分析结论。", "input_params": { @@ -128,33 +107,63 @@ var Prompt = ` } } } -` +``` -// 加载配置文件 -func loadConfig(filePath string) error { - data, err := os.ReadFile(filePath) - if err != nil { - return err - } +## 使用方法 +1. 下载源代码 或 Releases; +2. 编辑根目录下的`config.json`文件,配置`AI`和对应的`apiKeys`(只需要配置一个即可);(AI的值可配置qianwen、kimi、hunyuan、gpt、glm 或 deepseek) ; +3. 配置`headers2`(请求B对应的headers);可按需配置`suffixes`、`allowedRespHeaders`(接口后缀白名单,如.js); +4. 执行`go build`编译项目,并运行二进制文件(如果下载的是Releases可直接运行二进制文件); +5. 首次启动程序后需安装证书以解析 HTTPS 流量,证书会在首次启动程序后自动生成,路径为 ~/.mitmproxy/mitmproxy-ca-cert.pem(Windows 路径为%USERPROFILE%\\.mitmproxy\mitmproxy-ca-cert.pem)。安装步骤可参考 Python mitmproxy 文档:[About Certificates](https://docs.mitmproxy.org/stable/concepts-certificates/)。 +6. BurpSuite 挂下级代理 `127.0.0.1:9080`(端口可在`mitmproxy.go` 的`Addr:":9080",` 中配置)即可开始扫描; +7. 终端和web界面均可查看扫描结果,前端查看结果请访问`127.0.0.1:8222` 。 - if err := json.Unmarshal(data, &conf); err != nil { - return err - } +### 配置文件介绍(config.json) +| 字段 | 用途 | 内容举例 | +|------------------|----------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------| +| `AI` | 指定所使用的 AI 模型 | `qianwen`、`kimi`、`hunyuan` 、`gpt`、`glm` 或 `deepseek` | +| `apiKeys` | 存储不同 AI 服务对应的 API 密钥 (填一个即可,与AI对应) | - `"kimi": "sk-xxxxxxx"`
- `"deepseek": "sk-yyyyyyy"`
- `"qianwen": "sk-zzzzzzz"`
- `"hunyuan": "sk-aaaaaaa"` | +| `headers2` | 自定义请求B的 HTTP 请求头信息 | - `"Cookie": "Cookie2"`
- `"User-Agent": "PrivHunterAI"`
- `"Custom-Header": "CustomValue"` | +| `suffixes` | 需要过滤的文件后缀名列表 | `.js`、`.ico`、`.png`、`.jpg`、 `.jpeg` | +| `allowedRespHeaders` | 需要过滤的 HTTP 响应头中的内容类型(`Content-Type`) | `image/png`、`text/html`、`application/pdf`、`text/css`、`audio/mpeg`、`audio/wav`、`video/mp4`、`application/grpc`| +| `respBodyBWhiteList` | 鉴权关键字(如暂无查询权限、权限不足),用于初筛未越权的接口 | - `参数错误`
- `数据页数不正确`
- `文件不存在`
- `系统繁忙,请稍后再试`
- `请求参数格式不正确`
- `权限不足`
- `Token不可为空`
- `内部错误`| - return nil -} +## 输出效果 +持续优化中,目前输出效果如下: -// 获取配置 -func GetConfig() Config { - return conf -} +1. 终端输出: + -// 初始化配置 -func init() { - configPath := "./config.json" // 配置文件路径 +2. 前端输出(访问127.0.0.1:8222): + - if err := loadConfig(configPath); err != nil { - fmt.Printf("Error loading config file: %v\n", err) - os.Exit(1) - } -} +## 后续计划 +1. 添加敏感信息的扫描,例如通过正则匹配+AI辅助识别的方式扫描js文件中泄露的秘钥; +2. 优化越权漏洞/未授权漏洞扫描流程,实现消耗更少的token、更准确的扫描。 + +## 更新时间线 +- 2025.02.18 + 1. ⭐️新增扫描失败重试机制,避免出现漏扫; + 2. ⭐️新增响应Content-Type白名单,静态文件不扫描; + 3. ⭐️新增限制每次扫描向AI请求的最大字节,避免因请求包过大导致扫描失败。 +- 2025.02.25 - 02.27 + 1. ⭐️新增对URL的分析(初步判断是否可能是无需数据鉴权的公共接口); + 2. ⭐️新增前端结果展示功能。 + 3. ⭐️新增针对请求B添加其他headers的功能(适配有些鉴权不在cookie中做的场景)。 +- 2025.03.01 + 1. 优化Prompt,降低误报率; + 2. 优化重试机制,重试会提示类似:`AI分析异常,重试中,异常原因: API returned 401: {"code":"InvalidApiKey","message":"Invalid API-key provided.","request_id":"xxxxx"}`,每10秒重试一次,重试5次失败后放弃重试(避免无限重试)。 +- 2025.03.03 + 1. 💰成本优化:在调用 AI 判断越权前,新增鉴权关键字(如 “暂无查询权限”“权限不足” 等)过滤环节,若匹配到关键字则直接输出未越权结果,节省 AI tokens 花销,提升资源利用效率。 +- 2025.03.21 + 1. ⭐️新增终端输出请求包记录。 +- 2025.04.10 + 1. ⭐️新增结果可信度(confidence)输出,并优化Prompt。 +- 2025.04.22 - 04.23 + 1. 优化前端样式,并引入分页查询功能,避免一次性加载全部数据,从而减轻浏览器的渲染压力,提升页面响应速度和用户体验。 +- 2025.06.07 + 1. ⭐️新增“导出结果”功能; + 2. 解决响应体乱码问题。 + +# 注意 +声明:仅用于技术交流,请勿用于非法用途。 diff --git a/docs/login-layout-update.md b/docs/login-layout-update.md new file mode 100644 index 0000000..7ada032 --- /dev/null +++ b/docs/login-layout-update.md @@ -0,0 +1,3 @@ +# 登录页面视觉调整 + +Workflow Gate Report:P10 视觉迭代;已有登录页面与截图,依赖齐全,允许实施。范围为 Login 与登录专属样式;统一浅色画布、同一双栏容器和垂直对齐,移动端保持单栏。验收为前端构建和桌面/移动截图检查,登录契约保持现状。风险:宽屏留白与窄屏密度,使用最大宽度和响应式间距控制。 diff --git a/docs/main-delivery.md b/docs/main-delivery.md new file mode 100644 index 0000000..8c2e468 --- /dev/null +++ b/docs/main-delivery.md @@ -0,0 +1,13 @@ +# main 交付记录 + +日期:2026-10-07。 + +Feature Lifecycle Report:最佳实践重构交付,当前阶段 F6(发布记录与推送准备);用户明确要求推送 main,按用户指定分支直接提交,不再新建功能分支。远端 origin 为 Ed1s0nZ/PrivHunterAI,开始时本地与 origin/main 均为 2c842cb、无分叉。 + +实现、设计与验证已完成,其文档载体为 rebuild-plan.md、interaction-quality.md、verification.md 和 completion-audit.md。本阶段把已完成的代码与交付文档合并为一个原子提交;此前用户要求本地保留且未授权推送,所以不追补伪造的阶段提交。此文件和 CHANGELOG.md 为 F6 交付载体。不创建版本标签或 GitHub Release。 + +提交范围包括 React/Gin/SQLite 重构、体验深化、维护性与依赖安全修复、测试和文档,以及旧实现删除。提交前检查暂存树,排除真实配置、运行数据库、证书、日志、导出、构建输出和截图;敏感值比对仅输出命中位置与数量。 + +已完成验证见 completion-audit.md:全仓库竞态测试、Go 静态检查与构建、前端构建、两项完整浏览器流程;npm audit 零漏洞,govulncheck 零项可触达漏洞。此阶段没有生产代码变更,只更新交付文档。 + +此前文档的「未提交/未推送」描述记录的是当时的本地验收状态;本次用户授权将提交并推送 main。最终推送结果和提交标识以 Git 远端引用及本次聊天回执为准。 diff --git a/docs/rebuild-plan.md b/docs/rebuild-plan.md new file mode 100644 index 0000000..4d962eb --- /dev/null +++ b/docs/rebuild-plan.md @@ -0,0 +1,58 @@ +# PrivHunterAI 重构交付契约 + +## Workflow Gate Report +- User request: 重构目录与前端,增加侧边栏、登录鉴权、SQLite,完善该场景功能,禁止上传敏感信息。 +- Detected phase: P0/P1,现有实现无产品/API/安全设计文档。 +- Task type: 已授权全栈重构和功能扩展。 +- Required upstream artifacts: 功能范围、页面状态、数据模型、API、安全与迁移约定。 +- Found artifacts: README、Go 代理与扫描实现、单页 HTML;截图仅展示旧仓库结构。 +- Missing/weak artifacts: 自动化测试、持久化、鉴权、生命周期与模块边界。 +- Implementation allowed now: yes,先形成本文最小契约,再按模块逐步实现。 +- Prework required: 基线检查、敏感配置本地隔离。 +- Execution scope: 下述功能全部纳入此次交付。 +- Acceptance criteria: 构建、单元/接口/竞态测试通过;浏览器完成初始化、登录、导航、结果和设置流程;重启数据保留。 +- Risks/assumptions: 默认本机部署;无公开注册;首次管理员初始化;AI 输出为辅助判断,保留人工复核。 + +## Maintainability Gate Report +- Requested change: 跨前端、传输、存储与扫描的重构。 +- Files/modules inspected: main.go, index.go, scan.go, tools.go, mitmproxy.go, config, AIAPIS, index.html。 +- Trigger: 多职责全局状态,HTML 814 行,跨层耦合。 +- Current risk level: high。 +- Responsibility count: HTTP、UI、配置、模型调用、扫描、导出、状态存储混合。 +- Size/complexity signals: O(n*m) 相似度矩阵;无响应上限与请求超时。 +- Coupling signals: 全局 Resp/logs;配置在 init 读取;通过包级变量通信。 +- Tests covering the area: 无。 +- Refactor required first: yes。 +- Allowed change type: feature_after_refactor;用户明确授权整体重构与扩展。 +- Proposed slice: 领域与存储、鉴权、HTTP/UI、扫描集成、端到端验证。 +- Acceptance criteria: 不保留 AIAPIS 专用目录;无全局可变结果切片;服务与扫描共享明确存储接口。 +- Validation commands: go test ./..., go test -race ./..., go vet ./..., go build ./cmd/privhunter, git diff --check。 +- Risks and assumptions: 旧结果没有磁盘数据可迁移;旧配置只在本地迁移,不写入示例或文档。 + +## 功能与页面 +1. 首次初始化管理员,登录、退出、修改密码,管理员/只读用户管理,禁用用户使会话失效。 +2. SQLite 保存用户、哈希会话、结果、复核状态、运行设置与审计;事务迁移、外键、WAL、权限受限的本地数据目录。 +3. 侧边栏:概览、检测结果、扫描控制、系统设置、用户管理、审计日志。 +4. 概览统计与运行状态;结果分页、搜索、判定筛选、详情、请求/响应对比、人工复核、备注、删除、脱敏 CSV/JSON 导出。 +5. 扫描暂停/恢复、目标域名范围、重复请求抑制、静态内容过滤、有界队列、超时、有限重试与错误记录;保留被动代理与凭证替换检测。 +6. 通用兼容模型客户端,端点/模型可配置,密钥从环境或本地配置读取;不在 UI 返回明文密钥。 +7. 设置保存与校验;空白、加载、失败、权限和窄屏状态完整;静态资源本地提供。 +8. 默认脱敏凭证、敏感 JSON 字段与查询参数;日志不包含流量正文、密钥、Cookie 或模型完整错误响应。 + +## 结构与接口约定 +- cmd/privhunter: 入口与信号退出。 +- internal/config: 配置装载与校验。 +- internal/domain: 公共领域类型。 +- internal/store: SQLite、迁移与查询。 +- internal/auth: 密码和会话。 +- internal/httpapi: 路由、权限、请求校验、导出。 +- internal/scanner: 被动代理、有界工作队列、比较与模型适配。 +- internal/security: 脱敏。 +- frontend/: React + TypeScript + Vite,按 pages/components/api/hooks 分层。 +- frontend/dist/: Vite 构建产物,由 Gin 同源提供;启动参数允许单独替换前端构建目录。 +- docs: 架构、运行和验证记录。 + +API 使用 /api 前缀,JSON 错误采用 {"error":"可读信息"}。会话 HttpOnly、SameSite=Strict,写入需要同源 CSRF token;非登录接口必须鉴权,配置/用户/扫描控制需要管理员。结果列表与导出共享筛选逻辑。浏览器通过文本节点显示流量,不插入未经转义的 HTML。 + +## 敏感数据约束 +本地 config.json、.env、data/、SQLite 文件、证书、导出和运行日志不提交。已跟踪的旧 config.json 先本地隔离再从索引移除。示例仅包含占位符。此次不推送、不发布;提交前检查暂存区,扫描仅输出命中位置、不输出值。 diff --git a/docs/verification.md b/docs/verification.md new file mode 100644 index 0000000..9f70877 --- /dev/null +++ b/docs/verification.md @@ -0,0 +1,51 @@ +# 重构验收记录 + +日期:2026-10-07。测试均使用虚构账户和临时 SQLite,不使用真实模型密钥。 + +## 需求与证据 + +| 需求 | 当前实现 | 验证 | +| --- | --- | --- | +| 可维护目录,移除 AIAPIS | cmd、internal 分职责模块、frontend pages/components/api/hooks | 旧源码移除,go build 和 TypeScript 构建 | +| React 前端与侧边栏 | React 19 / TypeScript / Vite;桌面与窄屏布局 | Playwright 初始化、导航;1280 与 390px 截图检查 | +| Gin 后端 | httpapi Router,中间件与处理器分离 | Gin 接口测试与浏览器同源调用 | +| 登录鉴权 | bcrypt,哈希会话,HttpOnly/Strict Cookie,同源与 CSRF,角色控制 | 登录/退出/密码变更测试、只读接口拒绝 | +| SQLite3 | WAL、外键、事务初始化、用户/会话/结果/设置/审计 | store 关闭重开持久化测试 | +| 扫描保留与优化 | 有界队列、范围检查、头副本、超时、重试、解压上限、去重 | HTTP 代理完整链路与竞态测试 | +| 新增场景功能 | 概览、搜索筛选、证据对比、复核备注、用户管理、审计、暂停设置 | 2 个连续浏览器流程;代理→UI→复核→刷新 | +| 导出 | 同一 SQLite 查询快照,10000 条/32 MiB 上限,CSV/JSON | 浏览器 CSV 下载;公式防护单元测试 | +| 敏感信息不上传 | config/env/data/db/证书/日志/导出排除;请求证据脱敏;设置不返回凭证 | 敏感格式位置扫描无命中;config/data check-ignore;未 push | +| 旧配置迁移 | -import-config 显式本地导入,保持暂停 | 虚构旧配置导入测试 | + +## 执行检查 + +- `go test ./...`:通过。 +- `go test -race ./...`:通过。 +- `go vet ./...`:通过。 +- `go build -o bin/privhunter ./cmd/privhunter`:通过。 +- `npm run build`:通过(严格 TypeScript 检查与 Vite 构建)。 +- `npm run test:e2e`:2 项通过;初始化、登录、导航、设置、用户创建、审计、导出、密码变更、只读权限、退出,及真实 HTTP 代理采集/脱敏/复核/刷新。 +- `git diff --check`:通过。 + +## 边界 + +真实外部模型供应商未调用,避免消耗密钥和传输真实数据。模型适配协议已用本地测试服务验证;模型判断仍需人工复核。HTTPS 证书安装依赖操作系统信任设置,自动化目前验证 HTTP 代理路径。关键词脱敏不是任意敏感内容检测器,业务特殊字段应扩展规则。 + +不宣称本次覆盖所有可能新增功能;已实现 rebuild-plan 中列出的场景范围。未执行提交或远程推送,所有变更留在本地供审阅。 + +## 管理页反馈迭代(2026-10-07) + +扫描控制与系统设置采用分组卡片、说明侧栏、固定保存栏;用户管理采用统计、成员身份列表与新增成员弹窗。检查了桌面和手机截图。保留草稿离开提示、敏感字段不回显及管理员权限保护。 + +结果列表只返回摘要,详情通过鉴权接口按需读取完整脱敏证据,导出仍保留证据。新增摘要/详情/导出一致性测试、详情鉴权与缺失记录测试;扫描脱敏测试从详情读取正文进行验证。 + +本轮 `go test ./...`、React 构建与 2 项端到端测试通过;补充测试后 scanner/httpapi/store 测试、`go vet ./...`、Go 构建和 diff 检查通过。本地 8222 服务已重启到新版本。未调用真实模型服务,未上传配置、凭证或数据库。 + +## 导航与样式维护迭代 + +- 页面地址使用 `#/overview`、`#/findings` 等路径。浏览器测试覆盖刷新后保留系统设置、返回/前进、拒绝历史导航后保留配置草稿、只读用户直接访问管理员页面回概览,以及直接打开结果页。 +- 新导航 hook 独立于认证编排;侧栏使用 `aria-current`,文档标题显示当前页面。退出登录复用草稿保护。 +- 1590 行样式按基础、登录、布局、组件、概览、证据、配置、成员、响应式覆盖拆分;入口保持原顺序。拆分前后构建 CSS SHA-256 一致(字节内容完全相同)。 +- API 类型区分 `FindingSummary` 和完整 `Finding`,列表调用不能假定已加载正文。 +- 最终运行 `go test -race ./...`、`go vet ./...`、TypeScript/Vite 构建、2 项完整浏览器流程及 diff 检查均通过。当前本地服务返回 HTTP 200。 +- 待提交/未忽略源码与本地旧配置的凭证值比对无命中;运行目录 0700、数据库 0600;生产前端依赖 `npm audit --omit=dev` 返回 0 项漏洞。 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d03de5a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + PrivHunter · 安全工作台 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..01b702e --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1946 @@ +{ + "name": "privhunter-console", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "privhunter-console", + "version": "1.0.0", + "dependencies": { + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@playwright/test": "^1.63.0", + "@types/react": "^19.1.13", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.3", + "prettier": "^3.9.9", + "typescript": "^5.9.2", + "vite": "^7.1.7" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.1.tgz", + "integrity": "sha512-QRU7UpoS9hd8Ij5I6fCVBxZ0UpdfAbZ5y7BWG5STFGMoM9jGzUP464yYAa3CzZxvG+jBwq1kI8L07xx0ReAdGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.1.tgz", + "integrity": "sha512-ttQF6Rp4MrNy9/2AOPmHmMXbNE8kIlNPYoGC3ksid9tonleMQALfrhTFJiQxobBvXYKG5Wcc8VGcHWTHR0ik8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.1.tgz", + "integrity": "sha512-MvzZ+qbBi5jrafURHsASUF1qW/58tOJFt/gFVJo2znQWrKX4CSf/Lt/qGONtoA4yHsNL5QzgdXMuITZxAs62Vg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.1.tgz", + "integrity": "sha512-9uj1VBuiPK5ij/+g0mCK17X+V/4SbErvRQMXIQjZAtbl4VcUhv63RhPZsFSWHrCRkTd3FKDtaVTrtT0+8bIhhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.1.tgz", + "integrity": "sha512-FCoXm836IqcXWhWPRkqlLx3Hjnv9S7c8tg5MFwsgHB3aFCz1JjUy6b1ssrxEjEbqaHpnvRmNjUUHxsXuU85NYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.1.tgz", + "integrity": "sha512-Xa6Oxo86Ilx1hq1vNb69541NOuUyhGEH3Sc0g7eGkfsfRblWh4hAa4Lj2GWwZyfUVYL+Z4xK3TxhwIuZ3fNPLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.1.tgz", + "integrity": "sha512-Nkw7cVMu1yrczpzr42Mh1BZ5Ai5A4Fsz2Je/frbwHmazpQTJzP+qo7pNjdxHrxVj56ivKTJihNoN90OD1hGxBA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.1.tgz", + "integrity": "sha512-JONaLM9mZPXbSetxMzYG3496qoJuWvG2ERCTrrVptfw/Fwe4UzrQoEx/mYEeZCRv/Xz0RldOLFeqDfqmH75/Zg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.1.tgz", + "integrity": "sha512-OfimHma+prmdoobHI3tfYud2enQ2LXYaMu+iMUQH7IjJkykJ6qUmAs1M/scdQaEKRWysiKwTctsDa2LXvaO0xw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.1.tgz", + "integrity": "sha512-X5fwh2HM8EHbgfFwpf4m2x2pG/AHdQ2aPDPk+JCDwV4DPeM8yxYmKG1vvm123JSc9gBSu8tu9NECA99wHPztsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.1.tgz", + "integrity": "sha512-8dp2TxKdjiX/4f7Da9gF7BR29hygxOQlHxKNa8qxJHoqZ13ytrogFTpMPhtVEFrgFu9MSOabAn006PeoygavjA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.1.tgz", + "integrity": "sha512-YuYDpRSZnU9qLf6sqyhiBdMRCw5ImECGLNqRjM2hZrJIEwAckameWkRJ3SUnn0oxSewZIib/4iLwyEXj40QqQQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.1.tgz", + "integrity": "sha512-gGj3v7YYkysy7IbfclMI4PDtHaa6YX4nU1D5NR1fyvXAGqM4TDk28ZWvrpOkkK92eoQRryAludWz6jNjsm77Qg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.1.tgz", + "integrity": "sha512-DTZVwvS94FUYpP0CjCfz2iT0XTmBfQGc/arx9IdwUbDImpe9h8dWuMT/oPUvcGiwLKMQoNmuFBx3AVajm+FNjA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.1.tgz", + "integrity": "sha512-NWnyiq2moc8iEHGyTjIFadJTy8VCvCU/rUBfor+3l30JOQKAifpmKdxX4ZxRRPSPE/rLhFpLADdzNjS3Ldctdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.1.tgz", + "integrity": "sha512-yIz+7L37g7bVOGLaUQmGqiYADH262krL4dtf5zU6QGldPgOairLEgW1FTs5pP0UFkHq5uf2qxDttOFsVAX6kxg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.1.tgz", + "integrity": "sha512-sRK+GWovJZV6ijyV/k1Ft3FL5+4DscF5OYsJolTbhVTTLFxKOPVjS0wxUUHakvL0vRw/cbFxZNeZLfM/EzuQhA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.1.tgz", + "integrity": "sha512-T2C0NXqqD063yJdm8UGdcoudBATDODdphEXWS7rEoh0jOITfoEBv7LHfRBYVsy+pI7wwxgScoZpkFiQOf40ryQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.1.tgz", + "integrity": "sha512-05wl8uRZMkcobB6YZrzskr2SiM0VOdzlFw3bAxOjKKTt3M6o3O0X/hE/8xk15u8oQY17xUCdTKcNMMg45ZAFog==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.1.tgz", + "integrity": "sha512-NoRZK1VclOgn9x3b9vPJ3sTxwvQ/F+Hf93xurRKSFJKiwNZyQZnLxVpqlCejhDvesMLMF8VNN+Y4jHTFddV/Xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.1.tgz", + "integrity": "sha512-SDkX4OkZBePUPf0/XsXVJLwS+O61CmkDKuIyk5CeWddK3vkE8AZjmWK+p1ILRez4wnbS5rTK+U9YmcbRyx2dHw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.1.tgz", + "integrity": "sha512-JQ7el/Plr3juYFxIVu9YXDPNl57XR2L8WIc87JX9wj0L3goV8fXQPQXb3DaHVXq61X/WCGWsTCQsQHo6/Mz5UQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.1.tgz", + "integrity": "sha512-oZSlQ2H3ZDOz3ce1jEyuOZKuD0tPY/D2cKGMcMl0jhHQauBvkHHKAGR1OeRiMluvu3itzLRU2xCTUZptLayObA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.1.tgz", + "integrity": "sha512-Do2m3jUeVAvUDpee/yVZYRqhO8Ecx4x5sX3EVGalqLx+OxecLDGx3sIwPEm1Nq+MFK2FV3pgdpwWP4624b85Og==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.1.tgz", + "integrity": "sha512-KpxCwRn1Qh3tmBHAqIAF4HtUCVKqZd0w57TsItWblx3QVeJLtXaSgeBFAj1WAvBSpBo+Mh9iZCjj6YW/HaBVMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", + "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001814", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", + "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.449", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.449.tgz", + "integrity": "sha512-dKeYk/3SFQz+nXi5whp3OuJzeWABEG4zCtkWJ9+Vby1E1iFM2y0gElRtQvNXRPJTsj8WAF4HMP7hvP790QtJ9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.64.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.1.tgz", + "integrity": "sha512-7s4V+3J8ux75WNj9qwSJnrWmlCmZ2YHr2fojtHy5RiIMvhJv6DGe7HKKz7L6G1sq1Eg1fLC8SjYtAtDis1F4CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.1", + "@rollup/rollup-android-arm64": "4.64.1", + "@rollup/rollup-darwin-arm64": "4.64.1", + "@rollup/rollup-darwin-x64": "4.64.1", + "@rollup/rollup-freebsd-arm64": "4.64.1", + "@rollup/rollup-freebsd-x64": "4.64.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.1", + "@rollup/rollup-linux-arm-musleabihf": "4.64.1", + "@rollup/rollup-linux-arm64-gnu": "4.64.1", + "@rollup/rollup-linux-arm64-musl": "4.64.1", + "@rollup/rollup-linux-loong64-gnu": "4.64.1", + "@rollup/rollup-linux-loong64-musl": "4.64.1", + "@rollup/rollup-linux-ppc64-gnu": "4.64.1", + "@rollup/rollup-linux-ppc64-musl": "4.64.1", + "@rollup/rollup-linux-riscv64-gnu": "4.64.1", + "@rollup/rollup-linux-riscv64-musl": "4.64.1", + "@rollup/rollup-linux-s390x-gnu": "4.64.1", + "@rollup/rollup-linux-x64-gnu": "4.64.1", + "@rollup/rollup-linux-x64-musl": "4.64.1", + "@rollup/rollup-openbsd-x64": "4.64.1", + "@rollup/rollup-openharmony-arm64": "4.64.1", + "@rollup/rollup-win32-arm64-msvc": "4.64.1", + "@rollup/rollup-win32-ia32-msvc": "4.64.1", + "@rollup/rollup-win32-x64-gnu": "4.64.1", + "@rollup/rollup-win32-x64-msvc": "4.64.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f42b909 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "privhunter-console", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "tsc -b && vite build", + "format": "prettier --write src tests *.ts *.json index.html", + "test:e2e": "playwright test" + }, + "dependencies": { + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@playwright/test": "^1.63.0", + "@types/react": "^19.1.13", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.3", + "prettier": "^3.9.9", + "typescript": "^5.9.2", + "vite": "^7.1.7" + } +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..dea7e6f --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "@playwright/test"; +export default defineConfig({ + testDir: "tests", + workers: 1, + use: { baseURL: "http://127.0.0.1:18222" }, + webServer: { + command: `go run ../cmd/privhunter -listen 127.0.0.1:18222 -proxy 127.0.0.1:19080 -database ../data/e2e-${Date.now()}.db -frontend dist`, + url: "http://127.0.0.1:18222/api/auth/status", + reuseExistingServer: false, + timeout: 120000, + }, +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..0edf2eb --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,116 @@ +import PasswordDialog from "./components/PasswordDialog"; +import Overview, { type ResultSelection } from "./pages/Overview"; +import Findings from "./pages/Findings"; +import Users from "./pages/Users"; +import Audit from "./pages/Audit"; +import Settings from "./pages/Settings"; +import { useEffect, useState } from "react"; +import { api, send, setCSRF, type Session } from "./api/client"; +import Login from "./pages/Login"; +import Layout, { type Page } from "./components/Layout"; +import useWorkspaceNavigation, { + canLeaveWorkspace, +} from "./hooks/useWorkspaceNavigation"; +export default function App() { + const [session, setSession] = useState(null); + const [setup, setSetup] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const { + page, + navigate: navigatePage, + reset, + } = useWorkspaceNavigation(session?.user.role); + const [selection, setSelection] = useState({}); + const navigate = (next: Page, filter: ResultSelection = {}) => { + if (!navigatePage(next)) return; + setSelection(filter); + }; + const [passwordOpen, setPasswordOpen] = useState(false); + const accept = (s: Session, restore = false) => { + setCSRF(s.csrf); + setSession(s); + if (!restore) reset(); + }; + useEffect(() => { + const expire = () => { + setSession(null); + setCSRF(""); + }; + window.addEventListener("session-expired", expire); + (async () => { + try { + const v = await api<{ setupRequired: boolean }>("/auth/status"); + setSetup(v.setupRequired); + if (!v.setupRequired) { + try { + accept(await api("/auth/me"), true); + } catch {} + } + } catch (e) { + setError((e as Error).message); + } finally { + setLoading(false); + } + })(); + return () => window.removeEventListener("session-expired", expire); + }, []); + if (loading) return
正在连接工作台…
; + if (error) + return ( +
+

{error}

+ +
+ ); + if (!session) + return ( + setSetup(false)} onLogin={accept} /> + ); + return ( + setPasswordOpen(true)} + logout={async () => { + if (!canLeaveWorkspace()) return; + try { + await api("/auth/logout", send("POST", {})); + setSession(null); + setCSRF(""); + } catch (e) { + setError((e as Error).message); + } + }} + > +
+ {page === "overview" ? ( + + ) : page === "findings" ? ( + + ) : page === "users" ? ( + + ) : page === "audit" ? ( + + ) : ( + + )} +
+ {passwordOpen && ( + setPasswordOpen(false)} + changed={() => { + setPasswordOpen(false); + setSession(null); + setCSRF(""); + }} + /> + )} +
+ ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..24ed3f4 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,55 @@ +export type User = { + id: number; + username: string; + role: "admin" | "viewer"; + disabled: boolean; +}; +export type Session = { user: User; csrf: string }; +export type Finding = { + id: number; + method: string; + url: string; + requestA: string; + requestB: string; + responseA: string; + responseB: string; + result: string; + reason: string; + confidence: string; + review: string; + note: string; + createdAt: string; +}; +export type FindingSummary = Omit< + Finding, + "requestA" | "requestB" | "responseA" | "responseB" +>; +let csrf = ""; +export function setCSRF(value: string) { + csrf = value; +} +export async function api( + path: string, + options: RequestInit = {}, +): Promise { + const response = await fetch("/api" + path, { + ...options, + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrf, + ...options.headers, + }, + }); + const body = await response.json(); + if (!response.ok) { + if (response.status === 401) + window.dispatchEvent(new Event("session-expired")); + throw new Error(body.error || "请求失败"); + } + return body as T; +} +export const send = (method: string, value: unknown): RequestInit => ({ + method, + body: JSON.stringify(value), +}); diff --git a/frontend/src/components/EvidenceComparison.tsx b/frontend/src/components/EvidenceComparison.tsx new file mode 100644 index 0000000..6382a23 --- /dev/null +++ b/frontend/src/components/EvidenceComparison.tsx @@ -0,0 +1,120 @@ +import { useMemo, useState } from "react"; +import type { Finding } from "../api/client"; +function fields(value: unknown, prefix = "", out: Record = {}) { + if (Object.keys(out).length >= 500) return out; + if (value && typeof value === "object") { + for (const [key, v] of Object.entries(value)) { + fields(v, prefix ? prefix + "." + key : key, out); + } + } else out[prefix || "$"] = JSON.stringify(value) ?? "null"; + return out; +} +export default function EvidenceComparison({ finding }: { finding: Finding }) { + const [tab, setTab] = useState<"response" | "request" | "diff">("response"); + const [message, setMessage] = useState(""); + const diff = useMemo(() => { + try { + const a = fields(JSON.parse(finding.responseA)); + const b = fields(JSON.parse(finding.responseB)); + return [...new Set([...Object.keys(a), ...Object.keys(b)])] + .filter((k) => a[k] !== b[k]) + .map((k) => ({ + path: k, + a: a[k] ?? "(不存在)", + b: b[k] ?? "(不存在)", + })) + .slice(0, 500); + } catch { + return null; + } + }, [finding]); + const values = + tab === "request" + ? [finding.requestA || "", finding.requestB || ""] + : [finding.responseA || "", finding.responseB || ""]; + return ( + <> +
+ {[ + ["response", "响应对比"], + ["request", "请求对比"], + ["diff", "JSON 字段差异"], + ].map(([key, label]) => ( + + ))} +
+ {tab === "diff" ? ( +
+ {diff === null ? ( +

响应不是可解析的 JSON,请使用响应对比查看原文。

+ ) : diff.length === 0 ? ( +

+ 未发现可见字段差异。脱敏后的内容一致不代表权限判断已得到证实。 +

+ ) : ( +
+ + + + + + + + + + {diff.map((d) => ( + + + + + + ))} + +
字段路径账号 A账号 B
{d.path} + {d.a} + + {d.b} +
+
+ )} + 最多展示 500 个差异字段;脱敏字段不会恢复原值。 +
+ ) : ( +
+ {values.map((body, i) => ( +
+
+

+ 账号 {i === 0 ? "A" : "B"}{" "} + {tab === "request" ? "请求" : "响应"} +

+ +
+
{body || "无内容"}
+
+ ))} +
+ )} + {message && {message}} + + ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..a4df0fd --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,85 @@ +import type { ReactNode } from "react"; +import type { User } from "../api/client"; +export const navigation = [ + ["overview", "概览", "◈"], + ["findings", "检测结果", "⌕"], + ["scanner", "扫描控制", "◎"], + ["settings", "系统设置", "⚙"], + ["users", "用户管理", "♙"], + ["audit", "审计日志", "≡"], +] as const; +export type Page = (typeof navigation)[number][0]; +export default function Layout({ + user, + page, + setPage, + logout, + password, + children, +}: { + user: User; + page: Page; + setPage: (p: Page) => void; + logout: () => void; + password: () => void; + children: ReactNode; +}) { + return ( +
+ +
+
+ 工作台 / {navigation.find(([key]) => key === page)?.[1]} + ● LOCAL WORKSPACE +
+ {children} +
+
+ ); +} diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx new file mode 100644 index 0000000..9357bc4 --- /dev/null +++ b/frontend/src/components/Modal.tsx @@ -0,0 +1,42 @@ +import { useEffect, useRef, type ReactNode } from "react"; +export default function Modal({ + title, + onClose, + busy = false, + children, +}: { + title: string; + onClose: () => void; + busy?: boolean; + children: ReactNode; +}) { + const dialog = useRef(null); + const close = useRef(onClose); + close.current = onClose; + useEffect(() => { + const element = dialog.current; + if (!element) return; + const previous = document.activeElement as HTMLElement | null; + element.showModal(); + const overflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + element.close(); + document.body.style.overflow = overflow; + if (previous?.isConnected) previous.focus(); + }; + }, []); + return ( + { + e.preventDefault(); + if (!busy) close.current(); + }} + > + {children} + + ); +} diff --git a/frontend/src/components/PasswordDialog.tsx b/frontend/src/components/PasswordDialog.tsx new file mode 100644 index 0000000..1e4e992 --- /dev/null +++ b/frontend/src/components/PasswordDialog.tsx @@ -0,0 +1,73 @@ +import Modal from "./Modal"; +import { useState } from "react"; +import { api, send } from "../api/client"; +export default function PasswordDialog({ + close, + changed, +}: { + close: () => void; + changed: () => void; +}) { + const [current, setCurrent] = useState(""); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + return ( + +
{ + e.preventDefault(); + setBusy(true); + setError(""); + try { + await api("/auth/password", send("POST", { current, password })); + changed(); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + }} + > +
+

修改密码

+ +
+ + + 修改后所有现有会话失效,请使用新密码登录。 + {error && ( +

+ {error} +

+ )} + +
+
+ ); +} diff --git a/frontend/src/components/Status.tsx b/frontend/src/components/Status.tsx new file mode 100644 index 0000000..bf01a42 --- /dev/null +++ b/frontend/src/components/Status.tsx @@ -0,0 +1,45 @@ +export const labels: Record = { + true: "疑似越权", + false: "未发现越权", + unknown: "待分析", + error: "检测失败", + pending: "待复核", + confirmed: "已确认", + false_positive: "误报", + resolved: "已解决", +}; +export function Badge({ value }: { value: string }) { + return ( + {labels[value] || value} + ); +} +export function QueryState({ + loading, + error, + empty = false, + retry, +}: { + loading: boolean; + error: string; + empty?: boolean; + retry: () => void; +}) { + if (loading) return
正在加载…
; + if (error) + return ( +
+

+ {error} +

+ +
+ ); + if (empty) + return ( +
+ 暂无记录 +

符合条件的数据将在这里显示。

+
+ ); + return null; +} diff --git a/frontend/src/hooks/useDebouncedValue.ts b/frontend/src/hooks/useDebouncedValue.ts new file mode 100644 index 0000000..21414c1 --- /dev/null +++ b/frontend/src/hooks/useDebouncedValue.ts @@ -0,0 +1,9 @@ +import { useEffect, useState } from "react"; +export function useDebouncedValue(value: T, delay = 250) { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + return debounced; +} diff --git a/frontend/src/hooks/useQuery.ts b/frontend/src/hooks/useQuery.ts new file mode 100644 index 0000000..ace5adc --- /dev/null +++ b/frontend/src/hooks/useQuery.ts @@ -0,0 +1,54 @@ +import { useCallback, useEffect, useState } from "react"; +import { api } from "../api/client"; +type State = { + path: string | null; + data: T | null; + error: string; + loading: boolean; +}; +export function useQuery(path: string | null) { + const [state, setState] = useState>({ + path: null, + data: null, + error: "", + loading: false, + }); + const [version, setVersion] = useState(0); + useEffect(() => { + if (path === null) { + setState({ path: null, data: null, error: "", loading: false }); + return; + } + let active = true; + const controller = new AbortController(); + setState((prev) => ({ + path, + data: prev.path === path ? prev.data : null, + error: "", + loading: true, + })); + api(path, { signal: controller.signal }) + .then((data) => { + if (active) setState({ path, data, error: "", loading: false }); + }) + .catch((e) => { + if (active && e.name !== "AbortError") + setState((prev) => ({ ...prev, error: e.message, loading: false })); + }); + return () => { + active = false; + controller.abort(); + }; + }, [path, version]); + const reload = useCallback(() => setVersion((v) => v + 1), []); + const current = + state.path === path + ? state + : { path, data: null, error: "", loading: path !== null }; + return { + data: current.data, + error: current.error, + loading: current.loading, + reload, + }; +} diff --git a/frontend/src/hooks/useUnsavedChanges.ts b/frontend/src/hooks/useUnsavedChanges.ts new file mode 100644 index 0000000..7683ba4 --- /dev/null +++ b/frontend/src/hooks/useUnsavedChanges.ts @@ -0,0 +1,19 @@ +import { useEffect } from "react"; +export function useUnsavedChanges(dirty: boolean) { + useEffect(() => { + if (!dirty) return; + const unload = (e: BeforeUnloadEvent) => { + e.preventDefault(); + }; + const navigate = (e: Event) => { + if (!window.confirm("有未保存的修改,确定放弃并离开?")) + e.preventDefault(); + }; + window.addEventListener("beforeunload", unload); + window.addEventListener("workspace-before-navigate", navigate); + return () => { + window.removeEventListener("beforeunload", unload); + window.removeEventListener("workspace-before-navigate", navigate); + }; + }, [dirty]); +} diff --git a/frontend/src/hooks/useWorkspaceNavigation.ts b/frontend/src/hooks/useWorkspaceNavigation.ts new file mode 100644 index 0000000..5fe6774 --- /dev/null +++ b/frontend/src/hooks/useWorkspaceNavigation.ts @@ -0,0 +1,63 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { navigation, type Page } from "../components/Layout"; + +export function canLeaveWorkspace() { + return window.dispatchEvent( + new Event("workspace-before-navigate", { cancelable: true }), + ); +} + +function readPage(): Page { + const key = window.location.hash.replace(/^#\/?/, ""); + return navigation.find(([page]) => page === key)?.[0] ?? "overview"; +} + +function writePage(page: Page, replace = false) { + const url = `${window.location.pathname}${window.location.search}#/${page}`; + window.history[replace ? "replaceState" : "pushState"](null, "", url); +} + +export default function useWorkspaceNavigation(role?: string) { + const [page, setPage] = useState(readPage); + const current = useRef(page); + const allowed = useCallback( + (next: Page): Page => + role === "viewer" && next !== "overview" && next !== "findings" + ? "overview" + : next, + [role], + ); + const move = useCallback((next: Page, replace = false) => { + current.current = next; + writePage(next, replace); + setPage(next); + }, []); + const navigate = useCallback( + (next: Page) => { + if (next === current.current) return true; + if (!canLeaveWorkspace()) return false; + move(allowed(next)); + return true; + }, + [allowed, move], + ); + useEffect(() => { + move(allowed(current.current), true); + const onHistory = () => { + const next = allowed(readPage()); + if (next !== current.current && !canLeaveWorkspace()) { + writePage(current.current, true); + return; + } + move(next, true); + }; + window.addEventListener("hashchange", onHistory); + return () => { + window.removeEventListener("hashchange", onHistory); + }; + }, [allowed, move]); + useEffect(() => { + document.title = `${navigation.find(([key]) => key === page)?.[1]} · PrivHunter`; + }, [page]); + return { page: allowed(page), navigate, reset: () => move("overview", true) }; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..38e4380 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,9 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "./style.css"; +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/frontend/src/pages/Audit.tsx b/frontend/src/pages/Audit.tsx new file mode 100644 index 0000000..e2b0792 --- /dev/null +++ b/frontend/src/pages/Audit.tsx @@ -0,0 +1,124 @@ +import { useDebouncedValue } from "../hooks/useDebouncedValue"; +import { useState } from "react"; +import { useQuery } from "../hooks/useQuery"; +import { QueryState } from "../components/Status"; +type Entry = { + id: number; + actor: string; + action: string; + target: string; + createdAt: string; +}; +const actions: Record = { + setup: "初始化工作台", + login: "登录", + password_changed: "修改密码", + password_reset: "重置密码", + create_user: "创建成员", + update_user: "调整成员", + review: "复核记录", + bulk_review: "批量复核", + delete_finding: "删除记录", + update_settings: "保存配置", + export: "导出报告", +}; +export default function Audit() { + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const [action, setAction] = useState(""); + const debounced = useDebouncedValue(search); + const q = useQuery<{ data: Entry[]; total: number }>( + "/audit?" + + new URLSearchParams({ page: String(page), search: debounced, action }), + ); + return ( + <> +
+
+ ACTIVITY LOG +

审计日志

+

追踪登录、配置修改与证据复核操作。

+
+ +
+
+
+ { + setSearch(e.target.value); + setPage(1); + }} + /> + + {(search || action) && ( + + )} +
+ +
+ + + + + + + + + + + {q.data?.data.map((v) => ( + + + + + + + ))} + +
时间操作者操作对象
{new Date(v.createdAt).toLocaleString()}{v.actor} + {actions[v.action] || v.action} + {v.action} + {v.target}
+
+
+ 共 {q.data?.total || 0} 条 + + {page} + +
+
+ + ); +} diff --git a/frontend/src/pages/Findings.tsx b/frontend/src/pages/Findings.tsx new file mode 100644 index 0000000..811d769 --- /dev/null +++ b/frontend/src/pages/Findings.tsx @@ -0,0 +1,355 @@ +import Modal from "../components/Modal"; +import { useDebouncedValue } from "../hooks/useDebouncedValue"; +import EvidenceComparison from "../components/EvidenceComparison"; +import { useEffect, useState } from "react"; +import { api, send, type Finding, type FindingSummary } from "../api/client"; +import { useQuery } from "../hooks/useQuery"; +import { Badge, QueryState, labels } from "../components/Status"; +export default function Findings({ + admin, + initial = {}, +}: { + admin: boolean; + initial?: { search?: string; result?: string; review?: string }; +}) { + const [search, setSearch] = useState(initial.search || ""); + const [result, setResult] = useState(initial.result || ""); + const [review, setReview] = useState(initial.review || ""); + const [checked, setChecked] = useState([]); + const [bulkStatus, setBulkStatus] = useState("confirmed"); + const [notice, setNotice] = useState(""); + const [page, setPage] = useState(1); + const [selected, setSelected] = useState(null); + const detailQuery = useQuery( + selected ? "/findings/" + selected.id : null, + ); + const [note, setNote] = useState(""); + const [status, setStatus] = useState("pending"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const debouncedSearch = useDebouncedValue(search); + const q = useQuery<{ data: FindingSummary[]; total: number }>( + "/findings?" + + new URLSearchParams({ + search: debouncedSearch, + result, + review, + page: String(page), + size: "20", + }), + ); + useEffect(() => { + setChecked([]); + }, [search, result, review, page]); + const bulk = async () => { + setBusy(true); + setError(""); + try { + await api( + "/findings/bulk-review", + send("POST", { ids: checked, review: bulkStatus }), + ); + setNotice(`已更新 ${checked.length} 条记录`); + setChecked([]); + q.reload(); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + }; + const mutate = async (method: string, body?: unknown) => { + if (!selected) return; + setBusy(true); + setError(""); + try { + await api("/findings/" + selected.id, send(method, body || {})); + setSelected(null); + q.reload(); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + }; + return ( + <> +
+
+ EVIDENCE & REVIEW +

检测结果

+

检索权限风险,对比证据并记录结论。

+
+ +
+
+
+ { + setSearch(e.target.value); + setPage(1); + }} + /> + + +
+ {notice && ( +

+ {notice} +

+ )} + {error && !selected && ( +

+ {error} +

+ )} + {(search || result || review) && ( + + )} + {admin && checked.length > 0 && ( +
+ 已选 {checked.length} 条(当前页) + + + +
+ )} + + {!q.loading && !q.error && !!q.data?.data.length && ( +
+ + + + + + + + + + + + {q.data.data.map((v) => ( + { + setSelected(v); + setNote(v.note); + setStatus(v.review); + setError(""); + }} + > + + + + + + + ))} + +
+ {admin && ( + + setChecked( + e.target.checked + ? q.data!.data.map((v) => v.id) + : [], + ) + } + /> + )}{" "} + 请求 + 判定复核可信度检测时间
+ {admin && ( + e.stopPropagation()} + onChange={(e) => + setChecked((prev) => + e.target.checked + ? [...prev, v.id] + : prev.filter((id) => id !== v.id), + ) + } + /> + )} + + + + + + {v.confidence || "—"}{new Date(v.createdAt).toLocaleString()}
+
+ )} +
+ 共 {q.data?.total || 0} 条 + + {page} + +
+
+ {selected && ( + setSelected(null)}> +
+

检测证据 #{selected.id}

+ +
+

+ {selected.method} {selected.url} +

+ +

{selected.reason}

+ + {detailQuery.data && ( + + )} + {admin && ( +
+ +