mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 01:03:50 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9adbd54627 | ||
|
|
aca6e9a8fb | ||
|
|
012030b41d | ||
|
|
ada09f6c1a | ||
|
|
d36812b7e1 | ||
|
|
d68594e81a | ||
|
|
7db7b71cdb | ||
|
|
71ec0f0efd | ||
|
|
e77c3ae9a3 | ||
|
|
444cf6e50a | ||
|
|
24d4726aee | ||
|
|
ead58f0983 | ||
|
|
04c8c54ff8 | ||
|
|
979e1fe4eb | ||
|
|
735dd61564 |
No files matched your search
@@ -144,13 +144,21 @@ jobs:
|
||||
done
|
||||
sha256sum *.zip > SHA256SUMS
|
||||
sha256sum --check SHA256SUMS
|
||||
- name: Refuse to overwrite a published release
|
||||
- name: Replace release only when the tag moved to a new commit
|
||||
env:
|
||||
RELEASE_COMMIT: ${{ needs.prepare.outputs.commit }}
|
||||
run: |
|
||||
if gh release view "$RELEASE_TAG" --json isDraft > /tmp/artex-release.json 2>/tmp/artex-release.err; then
|
||||
test "$(jq -r .isDraft /tmp/artex-release.json)" = true || {
|
||||
echo "该版本已发布,请使用新的版本标签" >&2
|
||||
exit 1
|
||||
}
|
||||
if gh release view "$RELEASE_TAG" --json isDraft,targetCommitish > /tmp/artex-release.json 2>/tmp/artex-release.err; then
|
||||
draft=$(jq -r .isDraft /tmp/artex-release.json)
|
||||
target=$(jq -r '.targetCommitish // empty' /tmp/artex-release.json)
|
||||
if [[ "$draft" != "true" ]]; then
|
||||
if [[ "$target" == "$RELEASE_COMMIT" ]]; then
|
||||
echo "该版本已发布且已指向当前提交,拒绝重复发布" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "标签已移动到新提交,删除旧 Release 后重新创建 $RELEASE_TAG"
|
||||
gh release delete "$RELEASE_TAG" --yes
|
||||
fi
|
||||
elif ! grep -qi 'release not found\|HTTP 404' /tmp/artex-release.err; then
|
||||
cat /tmp/artex-release.err >&2
|
||||
exit 1
|
||||
|
||||
+16
-3
@@ -4,7 +4,22 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 部署与发布维护(保持程序版本 0.3.15)
|
||||
## [0.3.16] - 2026-10-09
|
||||
|
||||
### 访问控制
|
||||
|
||||
- 新增可选的 HTTP Basic Auth 访问控制,可在系统设置中配置,并修复配置持久化占用请求锁的问题。
|
||||
|
||||
### 同一漏洞的重复上报归并
|
||||
|
||||
- Reporter 在生成独立报告后按同任务候选证据判断同一实际缺陷,明确重复时归并,证据不足时提交人工确认建议。
|
||||
- 新增可展开漏洞文件夹、统一报告、原始等级分布、独立漏洞/上报记录统计及归并历史;每条原报告、证据和流量继续保留。
|
||||
- 支持手动整理所选历史记录、移出成员与防止自动再次归并,继承任务保持只读。
|
||||
- 统一报告采用证据版本校验;导出默认统一报告,可包含原始子报告,未生成或过期时明确提示并保留原材料。
|
||||
|
||||
## [0.3.15] - 2026-10-07
|
||||
|
||||
### 部署与发布维护
|
||||
|
||||
- 迁移至独立的 `RuoJi6/ARTEX` 仓库,保留原提交历史与分支。
|
||||
- 页面在线更新源为 `RuoJi6/ARTEX`;GitHub Actions 构建五个平台程序包和 SHA256 校验文件。
|
||||
@@ -12,8 +27,6 @@
|
||||
- 两种架构验证通过后,发布 `v0.3.15`、`latest` 镜像及对应程序包。
|
||||
- Compose 和部署说明使用本仓库的镜像,补充已有部署保留数据的迁移方式。
|
||||
|
||||
## [0.3.15] - 2026-10-07
|
||||
|
||||
### 拦截
|
||||
|
||||
#### 新增的功能
|
||||
|
||||
@@ -9,7 +9,7 @@ AI 自主渗透测试系统(Go 后端 + Next.js 前端)
|
||||
|
||||
</div>
|
||||
|
||||
本仓库为独立维护的 [RuoJi6/ARTEX](https://github.com/RuoJi6/ARTEX),保留 ARTEX 的原始提交历史与 AGPL-3.0 许可证。程序发布包从本仓库 Releases 下载,Docker 镜像 [ruoji6/artex](https://hub.docker.com/r/ruoji6/artex) 由本仓库 GitHub Actions 构建并发布。
|
||||
本仓库为独立维护的 [RuoJi6/ARTEX](https://github.com/RuoJi6/ARTEX),保留 https://github.com/Autumn-27/ARTEX 的原始提交历史与 AGPL-3.0 许可证。程序发布包从本仓库 Releases 下载,Docker 镜像 [ruoji6/artex](https://hub.docker.com/r/ruoji6/artex) 由本仓库 GitHub Actions 构建并发布。
|
||||
|
||||
---
|
||||
|
||||
@@ -95,7 +95,7 @@ docker compose up -d # 拉取 ruoji6/artex 镜像 + postgres
|
||||
|
||||
镜像已含常用工具(ripgrep/curl/vim/npm/nmap…);`./skills` 与 `./data` 以绑定挂载持久化。
|
||||
|
||||
`.env` 中默认 `ARTEX_IMAGE=ruoji6/artex`,`ARTEX_TAG=latest` 使用最新镜像,也可固定为 `v0.3.15`(完整镜像地址 `ruoji6/artex:v0.3.15`)。镜像支持 `linux/amd64` 和 `linux/arm64`,Docker 会自动选择与宿主机匹配的架构,ARM64 Kali 无需自行编译。初次部署请修改 `POSTGRES_PASSWORD`;运行服务无需配置 Docker Hub 发布 Token。
|
||||
`.env` 中默认 `ARTEX_IMAGE=ruoji6/artex`,`ARTEX_TAG=latest` 使用最新镜像,也可固定为 `v0.3.16`(完整镜像地址 `ruoji6/artex:v0.3.16`)。镜像支持 `linux/amd64` 和 `linux/arm64`,Docker 会自动选择与宿主机匹配的架构,ARM64 Kali 无需自行编译。初次部署请修改 `POSTGRES_PASSWORD`;运行服务无需配置 Docker Hub 发布 Token。
|
||||
|
||||
远程 MCP 可在系统设置中选择 `http`(Streamable HTTP)或 `sse`(旧版 SSE)。
|
||||
旧版 SSE 服务通常使用 `GET /sse` 建立事件流,再通过服务返回的
|
||||
@@ -165,12 +165,12 @@ ARTEX_TARGETS=linux/amd64,windows/amd64 ./build.sh --release
|
||||
|
||||
### 维护者:通过 GitHub Actions 发布在线更新
|
||||
|
||||
发布工作流为 `.github/workflows/release.yml`。推送正式标签会触发构建,也可在 Actions → release → Run workflow 中输入已存在的标签重新执行失败的发布。标签必须是 `v主版本.次版本.修订号`,例如下例的 `v0.3.15`;后续版本必须高于用户正在运行的版本。
|
||||
发布工作流为 `.github/workflows/release.yml`。推送正式标签会触发构建,也可在 Actions → release → Run workflow 中输入已存在的标签重新执行失败的发布。标签必须是 `v主版本.次版本.修订号`,例如下例的 `v0.3.16`;后续版本必须高于用户正在运行的版本。
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
git tag v0.3.15
|
||||
git push origin v0.3.15
|
||||
git tag v0.3.16
|
||||
git push origin v0.3.16
|
||||
```
|
||||
|
||||
工作流固定标签对应的提交,用 Node.js 22 导出前端,按照 `go.mod` 指定的 Go 版本编译内嵌前端的五个平台程序,并生成 ZIP 与 `SHA256SUMS`。AMD64 和 ARM64 镜像分别在 GitHub 原生 runner 上构建,连接临时 PostgreSQL 验证程序启动、版本和内嵌前端;两种架构都通过后发布多架构镜像的版本标签与 `latest`。镜像发布成功后上传完整 ZIP 和校验文件,最后公开 Release。已公开的同版本不允许覆盖,应发布新标签。
|
||||
@@ -204,10 +204,10 @@ docker compose logs --tail=50 artex
|
||||
```bash
|
||||
# 在下载目录运行;先按 SHA256SUMS 核对下载的 ZIP,再解压。
|
||||
sha256sum --ignore-missing --check SHA256SUMS
|
||||
unzip artex-0.3.15-linux-arm64.zip
|
||||
unzip artex-0.3.16-linux-arm64.zip
|
||||
sudo docker stop artex-artex-1
|
||||
sudo docker cp artex-artex-1:/app/artex ./artex-before-migration
|
||||
sudo docker cp ./artex-0.3.15-linux-arm64/artex artex-artex-1:/app/artex
|
||||
sudo docker cp ./artex-0.3.16-linux-arm64/artex artex-artex-1:/app/artex
|
||||
sudo docker start artex-artex-1
|
||||
curl --fail http://127.0.0.1:8787/api/health
|
||||
```
|
||||
@@ -231,7 +231,7 @@ cd ARTEX
|
||||
```bash
|
||||
cd ARTEX
|
||||
git pull # 更新 compose / 脚本(可选)
|
||||
# 指定版本:在 .env 设 ARTEX_TAG=v0.3.15;不设则用 latest
|
||||
# 指定版本:在 .env 设 ARTEX_TAG=v0.3.16;不设则用 latest
|
||||
docker compose pull artex
|
||||
docker compose up -d artex # 换新镜像重启 → 自动迁移 schema
|
||||
docker image prune -f # 清理旧镜像(可选)
|
||||
@@ -279,6 +279,25 @@ CGO_ENABLED=0 go build -tags embedui -o artex ./cmd/artex
|
||||
|
||||
**常用参数**:`./start.sh -addr :8787 -proxy :8788`(`-addr` 前端+API,`-proxy` 流量录制代理)。启动脚本会把参数原样透传给 `artex`。
|
||||
|
||||
### 可选的 HTTP Basic Auth 访问验证
|
||||
|
||||
在 **系统配置 → HTTP Basic Auth** 中填写独立的验证用户名和密码,打开「启用访问验证」并点击「保存访问验证」。默认关闭,保存后立即生效,无需重启;密码至少 8 位、最多 72 字节,用户名不能包含冒号。
|
||||
|
||||
开启后,访客先完成浏览器原生账号密码验证,再进入原有 ARTEX 登录页。网页、静态资源和 API 均受保护,`/api/health` 健康检查保留公开访问;通过访问验证仍需登录 ARTEX 才能使用业务 API。公网部署请通过 HTTPS 访问。
|
||||
|
||||
配置持久化到数据库,密码仅保存 bcrypt 哈希,界面不会回显。保存时密码留空表示不修改;关闭后保留凭据,方便再次开启。独立的 HttpOnly 验证 Cookie 有效期为 12 小时,兼容现有 JWT 和 SSE;保存配置会使其他浏览器的旧验证 Cookie 失效,当前管理员浏览器保持访问。浏览器可能缓存 Basic Auth 凭据,可用新的无痕窗口检查验证弹窗。
|
||||
|
||||
Docker 和内嵌前端二进制直接支持此功能。`next dev` 的认证检查仅向本机回环后端转发凭据(`localhost`、`127.0.0.1` 或 `::1`);前端与后端应使用同一主机名。远程开发后端未开启验证时仍可使用;开启后请通过内嵌前端访问远程服务,避免跨域验证 Cookie 问题。
|
||||
|
||||
若遗忘独立验证密码,可在服务器连接应用数据库后执行以下语句并重启 ARTEX,关闭这层访问验证,再用原有 ARTEX 账号登录后重新配置:
|
||||
|
||||
```sql
|
||||
UPDATE settings
|
||||
SET value = jsonb_set(value::jsonb, '{enabled}', 'false'::jsonb)::text,
|
||||
updated_at = now()
|
||||
WHERE key = 'auth.http_basic';
|
||||
```
|
||||
|
||||
### 反向代理部署(HTTPS / 只开放 443)
|
||||
|
||||
前端和 API/SSE 都由同一个后端端口(默认 `:8787`)提供,实时活动流默认走**同源**地址,因此**无需配置 `NEXT_PUBLIC_SSE_BASE`**,公网只开放 443、把 8787 留在内网即可。
|
||||
@@ -314,6 +333,16 @@ server {
|
||||
|
||||
## 开发
|
||||
|
||||
### 同一漏洞归并与统一报告
|
||||
|
||||
发现页默认「按漏洞」。原始上报、按任务及按资产保留原来的表格、状态和操作按钮,仅已归并记录改为可展开的漏洞文件夹。同一任务内,Reporter 依据目标、接口参数、权限边界、根因及验证证据确认同一实际缺陷后形成文件夹;仅类型或名称相同不会作为自动归并依据。严重程度、接口、后端或读写影响不同仍可能是同一漏洞;反之,共用登录入口或令牌不能单独证明同一根因。有关联但共同根因无法确认时提交人工建议。文件夹的 `严/高/中/低` 数量按原始记录统计,例如 `0/1/1/1`;发现页的独立漏洞数按文件夹去重,等级数量按原始上报统计,列表及文件夹详情不显示统一评级标签。原始上报内容、评级、状态、报告及证据不被覆盖。
|
||||
|
||||
点击箭头展开原始记录,点击文件夹标题查看统一报告、原始记录及归并历史。证据不足的建议需要点击「确认归并」;否决或移出后,这些关系不会被 Agent 自动再次归并。移出成员不删除原漏洞,不足两条时解散文件夹并保留历史。继承任务的漏洞组保持只读。
|
||||
|
||||
新上报由现有 Reporter 生成独立报告并检查重复。升级首次补充 Reporter 的归并工具和运行指引,保留自定义提示词及触发设置;已删除或停用的 Reporter 不会重建、启用。历史记录不会自动扫描:展开并选择原始记录,点击「整理所选」,按任务分别排队执行,可查看执行记录及失败信息。同一任务已有排队或运行中的整理时拒绝重复提交。历史整理以只读方式复用原报告,禁止覆盖原报告或补绑证据;已有报告为空或过期时如实说明,单独通过报告撰写流程处理。结束前需提交每条所选记录的根因判断,检查读取范围、建议、统一报告完整性及版本,通过验收才显示完成。文件夹详情可重新生成统一报告;需要启用 Reporter 并配置可用模型。判断和写作会消耗模型 token,候选检索先分页返回摘要,详情证据按需读取,不重新探测目标。
|
||||
|
||||
成员、报告或证据变化后统一报告标记待更新,写入时校验实际读取版本。导出默认每组一份统一报告及未归并记录,可勾选「包含原始子报告」;统一报告未生成或过期时附明确说明及全部原始材料。原始上报显示所属任务、资产及时间;按任务先展开任务,再展开漏洞文件夹;按资产保留资产树,命中任一成员即展示整个文件夹,展开时标明未命中筛选的成员。折叠状态只读取轻量摘要,不加载成员报告。升级沿用启动时幂等数据库迁移,无需执行独立 SQL。
|
||||
|
||||
### 手动漏洞复测
|
||||
|
||||
任务详情的「复测」页签可分页选择本任务的漏洞、查看历次结论和证据,并手动发起复测。启动后保留当前页签,显示转圈图标和「复测中」;确认修复后同步更新漏洞状态。
|
||||
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
// UnlockSkill unlocks a named skill's MCPs — hosts call it to rebuild the unlock set
|
||||
// from history on a resumed session (design doc C2).
|
||||
type DeferredInfo struct {
|
||||
CaseGuidance string // independent of optional traffic binding
|
||||
|
||||
FindingGuidance string // derived from the final permitted tools, including DB overrides
|
||||
Deferred []string // all MCP tool names (schema withheld)
|
||||
GlobalNames []string // MCP names to list in the system-prompt block
|
||||
@@ -60,7 +62,15 @@ func AugmentTools(ctx context.Context, agentKey string, base []actool.CoreTool)
|
||||
if ToolResolve != nil {
|
||||
out = ToolResolve(ctx, agentKey, out)
|
||||
}
|
||||
if agentKey == "reporter" && isFindingCaseReview(ctx) {
|
||||
out = historicalFindingCaseTools(out)
|
||||
}
|
||||
out, def.FindingGuidance = findingWorkflowTools(agentKey, out)
|
||||
out, def.CaseGuidance = findingCaseWorkflow(agentKey, out)
|
||||
if agentKey == "reporter" && isFindingCaseReview(ctx) {
|
||||
def.FindingGuidance = ""
|
||||
def.CaseGuidance += historicalFindingReviewGuidance
|
||||
}
|
||||
for i, t := range out {
|
||||
out[i] = guardPanic(t)
|
||||
}
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ import (
|
||||
// skill loads. Returns a single plain segment + boundary 0 when there is no global
|
||||
// block to add.
|
||||
func deferredSystem(sysText string, def DeferredInfo) (system []string, boundary int) {
|
||||
sysText += def.FindingGuidance
|
||||
sysText += def.FindingGuidance + def.CaseGuidance
|
||||
block := actool.RenderDeferredToolsBlock(def.GlobalNames)
|
||||
if block == "" {
|
||||
return []string{sysText}, 0
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
type findingReviewKey struct{}
|
||||
|
||||
// Historical consolidation has a narrower capability set than new reporting.
|
||||
func WithFindingCaseReview(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, findingReviewKey{}, true)
|
||||
}
|
||||
func isFindingCaseReview(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(findingReviewKey{}).(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
func historicalFindingCaseTools(tools []actool.CoreTool) []actool.CoreTool {
|
||||
allowed := map[string]bool{}
|
||||
for _, name := range []string{"search_finding_duplicates", "get_finding_record", "merge_finding_records", "suggest_finding_merge", "get_finding_case", "update_finding_case_report", "complete_finding_case_review", "get_finding_traffic", "traffic_search", "traffic_get", "traffic_blob", "get_task_node_detail", "list_task_findings", "get_task_graph", "list_task_worker_traces", "get_task_worker_trace", "Read"} {
|
||||
allowed[name] = true
|
||||
}
|
||||
out := make([]actool.CoreTool, 0, len(tools))
|
||||
for _, tool := range tools {
|
||||
if allowed[tool.Name()] {
|
||||
out = append(out, tool)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// This guidance is appended to the runtime prompt, including editable old prompts.
|
||||
// Never reintroduce unbound tools or recreate a deleted Reporter.
|
||||
func findingCaseWorkflow(key string, tools []actool.CoreTool) ([]actool.CoreTool, string) {
|
||||
if key != "reporter" {
|
||||
return tools, ""
|
||||
}
|
||||
has := map[string]bool{}
|
||||
for _, t := range tools {
|
||||
has[t.Name()] = true
|
||||
}
|
||||
if !has["search_finding_duplicates"] || !has["merge_finding_records"] || !has["get_finding_case"] || !has["update_finding_case_report"] {
|
||||
return tools, ""
|
||||
}
|
||||
return tools, `
|
||||
|
||||
【同一漏洞的多次上报整理】保留本次独立报告,不覆盖其他原报告。默认报告完成即停止的旧指引在此扩展:先保存本次报告,然后 search_finding_duplicates(finding_id=独立记录ID) 分页读取同任务候选摘要,按需 get_finding_record 查看证据。仅凭漏洞类型/标题相似或高中低等级不能归并;须核对目标、接口、参数、权限边界、根因及实际验证证据。
|
||||
【判断准则】接口、路径、后端、读/写影响、评级不同,都不能单独作为不重复的理由;同一登录入口、同一令牌或同一漏洞类型也不能单独证明同一根因。比较实际缺失的鉴权检查、对象权限边界、会话门或共同组件,并引用每条记录中的具体证据。读和写可能是同一权限检查缺失的不同影响;也可能是独立 handler 的独立缺陷。只有证据证明共同实际根因才自动归并,证据证明不同根因才判独立;存在关联但无法确认时必须 suggest_finding_merge,说明已知共同点、缺失证据和待确认问题,不用“路径不同/影响不同”代替根因判断。明确同一缺陷时 merge_finding_records(task_id,finding_ids,title,reason),不确定时 suggest_finding_merge 留待人工确认,不能强行归并。不重复上报、不删除记录、不重新探测目标。人工选择整理时只能判断所选记录,不能加入未选记录(既有组成员可作为报告上下文)。
|
||||
归并成功或已有case_id时,get_finding_case 记录实际version,分页读取全部成员,按需get_finding_record、get_finding_traffic读取原报告、PoC及已保存流量。update_finding_case_report保存完整统一报告(包含有内容的概述、影响、前置条件、复现步骤、证据、PoC、根因分析、修复建议章节,不得半篇截断)和基于已验证影响的severity及severity_reason;不能直接取成员最高等级。集中给出前置条件、完整执行顺序、输入输出如何衔接、可运行的完整脚本及命令,不把必要步骤分散到原报告;缺失条件、未验证步骤如实标明。版本冲突重新读取材料并生成,不能仅换version重试。各成员仍有独立报告、评级和证据。没有明确重复则保留独立记录。工具被关闭时不绕过配置。`
|
||||
}
|
||||
|
||||
const historicalFindingReviewGuidance = `
|
||||
|
||||
【历史整理模式,优先于旧报告撰写及自动绑流量指引】本次只整理已经存在的上报。原始报告即使为空或待更新,也只记录缺失情况,绝不调用 update_finding_report、report_finding、bind_finding_traffic,不修改原始标题、评级、状态、证据或报告,不执行 Bash、写文件或重新探测目标。
|
||||
先逐条 get_finding_record 读取所选记录,已有原报告直接作为判断材料复用,不重复撰写。只在所选范围内检查同一实际缺陷,可按需读取已存在的证据。按上述根因准则形成明确归并/独立/疑似建议;无法确认时保存建议供人工确认。
|
||||
已有或新建文件夹必须读取全部成员并写入完整、最新的统一报告;统一报告是单独的材料,不覆盖原报告。最后必须调用 complete_finding_case_review,为每条所选 finding_id 提交 verdict(independent/merged/suggested)、reason 和 root_cause_evidence,并提供总结。根因证据应说明具体相同/不同的检查及证据来源;疑似建议明确待补证据。服务端验收通过后才可宣布完成。工具不可用或验收失败时如实报告,不能只用自然语言声称完成。`
|
||||
@@ -0,0 +1,61 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
func TestFindingCaseGuidanceRespectsToolsAndTrafficSwitch(t *testing.T) {
|
||||
tools := []actool.CoreTool{}
|
||||
for _, name := range []string{"search_finding_duplicates", "merge_finding_records", "get_finding_case", "update_finding_case_report"} {
|
||||
tools = append(tools, writeTool(name, name, obj(nil), func(context.Context, json.RawMessage) (actool.Result, error) { return actool.Text("ok"), nil }))
|
||||
}
|
||||
_, guide := findingCaseWorkflow("reporter", tools)
|
||||
if !strings.Contains(guide, "不能直接取成员最高等级") || !strings.Contains(guide, "独立报告") {
|
||||
t.Fatal("missing preservation rules")
|
||||
}
|
||||
if _, guide := findingCaseWorkflow("worker", tools); guide != "" {
|
||||
t.Fatal("worker changed")
|
||||
}
|
||||
if _, guide := findingCaseWorkflow("reporter", tools[:2]); guide != "" {
|
||||
t.Fatal("unbound tools reintroduced")
|
||||
}
|
||||
system, _ := deferredSystem("CUSTOM PROMPT", DeferredInfo{CaseGuidance: guide})
|
||||
if !strings.HasPrefix(system[0], "CUSTOM PROMPT") || !strings.Contains(system[0], guide) {
|
||||
t.Fatal("custom prompt lost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoricalFindingReviewFiltersMutationAndProbeTools(t *testing.T) {
|
||||
oldAugment, oldResolve := ToolAugment, ToolResolve
|
||||
ToolAugment, ToolResolve = nil, nil
|
||||
t.Cleanup(func() { ToolAugment, ToolResolve = oldAugment, oldResolve })
|
||||
tools := []actool.CoreTool{}
|
||||
for _, name := range []string{"update_finding_report", "bind_finding_traffic", "report_finding", "Bash", "Write", "Edit", "mcp__custom__write", "get_finding_record", "merge_finding_records", "search_finding_duplicates", "get_finding_case", "update_finding_case_report", "complete_finding_case_review"} {
|
||||
tools = append(tools, writeTool(name, name, obj(nil), func(context.Context, json.RawMessage) (actool.Result, error) { return actool.Text("ok"), nil }))
|
||||
}
|
||||
out, def, cleanup := AugmentTools(WithFindingCaseReview(t.Context()), "reporter", tools)
|
||||
defer cleanup()
|
||||
for _, tool := range out {
|
||||
switch tool.Name() {
|
||||
case "update_finding_report", "bind_finding_traffic", "report_finding", "Bash", "Write", "Edit", "mcp__custom__write":
|
||||
t.Fatal("historical mutation capability", tool.Name())
|
||||
}
|
||||
}
|
||||
if len(out) != 6 || def.FindingGuidance != "" || !strings.Contains(def.CaseGuidance, "历史整理模式") || !strings.Contains(def.CaseGuidance, "接口、路径、后端、读/写影响、评级不同") {
|
||||
t.Fatal("missing historical isolation", def)
|
||||
}
|
||||
normal, _, cl := AugmentTools(t.Context(), "reporter", tools)
|
||||
defer cl()
|
||||
found := map[string]bool{}
|
||||
for _, tool := range normal {
|
||||
found[tool.Name()] = true
|
||||
}
|
||||
if !found["update_finding_report"] || !found["Bash"] || !found["report_finding"] {
|
||||
t.Fatal("normal reporting was restricted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
var ErrFindingCaseReviewBusy = errors.New("该任务已有排队或运行中的漏洞整理,请等待完成或停止后重试")
|
||||
|
||||
type FindingCaseReviewReservation struct {
|
||||
TaskID int64
|
||||
ConversationID int64
|
||||
FindingIDs []int64
|
||||
}
|
||||
|
||||
// Reserve all source tasks atomically, so a concurrent click cannot start a
|
||||
// second review and a mixed-task request cannot partially enqueue its work.
|
||||
func (d *DB) CreateFindingCaseReviews(ctx context.Context, batches map[int64][]int64) ([]FindingCaseReviewReservation, error) {
|
||||
tasks := make([]int64, 0, len(batches))
|
||||
for task := range batches {
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
sort.Slice(tasks, func(i, j int) bool { return tasks[i] < tasks[j] })
|
||||
out := []FindingCaseReviewReservation{}
|
||||
err := d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
|
||||
for _, task := range tasks {
|
||||
if err := lockCaseTask(tx, task); err != nil {
|
||||
return err
|
||||
}
|
||||
var busy bool
|
||||
if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_review_runs WHERE task_id=$1 AND state IN ('queued','running'))`, task).Scan(&busy); err != nil {
|
||||
return err
|
||||
}
|
||||
if busy {
|
||||
return fmt.Errorf("%w(任务 #%d)", ErrFindingCaseReviewBusy, task)
|
||||
}
|
||||
}
|
||||
for _, task := range tasks {
|
||||
ids := []int64{}
|
||||
seen := map[int64]bool{}
|
||||
for _, id := range batches[task] {
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
var source int64
|
||||
if err := tx.QueryRow(`SELECT task_id FROM findings WHERE id=$1`, id).Scan(&source); err != nil {
|
||||
return err
|
||||
}
|
||||
if source != task {
|
||||
return errors.New("所选漏洞不属于来源任务")
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return errors.New("请选择原始上报")
|
||||
}
|
||||
raw, err := json.Marshal(ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var conv int64
|
||||
if err := tx.QueryRow(`INSERT INTO conversations(agent_key,title) VALUES('reporter',$1) RETURNING id`, fmt.Sprintf("漏洞整理 · task#%d", task)).Scan(&conv); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO finding_case_review_runs(conversation_id,task_id,finding_ids) VALUES($1,$2,$3)`, conv, task, string(raw)); err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, FindingCaseReviewReservation{TaskID: task, ConversationID: conv, FindingIDs: ids})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFindingCaseReviewConcurrentReservation(t *testing.T) {
|
||||
d, task, ids := caseTestDB(t)
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan error, 8)
|
||||
for range 8 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, err := d.CreateFindingCaseReviews(context.Background(), map[int64][]int64{task.ID: ids[:2]})
|
||||
results <- err
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
success := 0
|
||||
for err := range results {
|
||||
if err == nil {
|
||||
success++
|
||||
} else if !errors.Is(err, ErrFindingCaseReviewBusy) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if success != 1 {
|
||||
t.Fatalf("created %d reviews instead of one", success)
|
||||
}
|
||||
rows, err := d.Query(`SELECT conversation_id FROM finding_case_review_runs WHERE task_id=$1`, task.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var convs []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
convs = append(convs, id)
|
||||
}
|
||||
rows.Close()
|
||||
t.Cleanup(func() {
|
||||
for _, id := range convs {
|
||||
d.Exec(`DELETE FROM conversations WHERE id=$1`, id)
|
||||
}
|
||||
})
|
||||
if _, err := d.Exec(`UPDATE finding_case_review_runs SET state='done' WHERE task_id=$1`, task.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next, err := d.CreateFindingCaseReviews(t.Context(), map[int64][]int64{task.ID: ids[2:]})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
convs = append(convs, next[0].ConversationID)
|
||||
}
|
||||
|
||||
func TestFindingCaseReviewMixedRequestRollsBack(t *testing.T) {
|
||||
d, task, ids := caseTestDB(t)
|
||||
other, err := d.CreateTask("other review", "goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { d.Exec(`DELETE FROM findings WHERE task_id=$1`, other.ID); d.DeleteTask(other.ID) })
|
||||
fid, err := d.AddFinding(other.ID, 0, "IDOR", "other", "low", "other", "proof", "worker", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current, err := d.CreateFindingCaseReviews(t.Context(), map[int64][]int64{other.ID: {fid}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Exec(`DELETE FROM conversations WHERE id=$1`, current[0].ConversationID)
|
||||
if _, err := d.CreateFindingCaseReviews(t.Context(), map[int64][]int64{task.ID: ids, other.ID: {fid}}); !errors.Is(err, ErrFindingCaseReviewBusy) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int
|
||||
if err := d.QueryRow(`SELECT count(*) FROM finding_case_review_runs WHERE task_id=$1`, task.ID).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("partial enqueue: %d %v", count, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrFindingCaseConflict = errors.New("漏洞组已变更,请重新读取证据后再操作")
|
||||
|
||||
type FindingCase struct {
|
||||
ID int64 `json:"id,string"`
|
||||
TaskID *int64 `json:"task_id,string"`
|
||||
Title string `json:"title"`
|
||||
Reason string `json:"reason"`
|
||||
Severity string `json:"severity"`
|
||||
SeverityReason string `json:"severity_reason"`
|
||||
Report string `json:"report,omitempty"`
|
||||
Version int64 `json:"version"`
|
||||
ReportVersion int64 `json:"report_version"`
|
||||
Active bool `json:"active"`
|
||||
Count int `json:"count"`
|
||||
Critical int `json:"critical"`
|
||||
High int `json:"high"`
|
||||
Medium int `json:"medium"`
|
||||
Low int `json:"low"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
type FindingCaseEvent struct {
|
||||
ID int64 `json:"id,string"`
|
||||
Action string `json:"action"`
|
||||
Actor string `json:"actor"`
|
||||
Reason string `json:"reason"`
|
||||
FindingIDs json.RawMessage `json:"finding_ids"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
type FindingCaseSuggestion struct {
|
||||
ID int64 `json:"id,string"`
|
||||
TaskID int64 `json:"task_id,string"`
|
||||
LeftID int64 `json:"left_id,string"`
|
||||
RightID int64 `json:"right_id,string"`
|
||||
Title string `json:"title"`
|
||||
Reason string `json:"reason"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
type FindingCaseRow struct {
|
||||
Case *FindingCase `json:"case,omitempty"`
|
||||
FindingID int64 `json:"finding_id,string,omitempty"`
|
||||
MatchedIDs []int64 `json:"matched_ids"`
|
||||
}
|
||||
|
||||
const caseColumns = `c.id,c.task_id,c.title,c.reason,c.severity,c.severity_reason,c.report,c.version,c.report_version,c.active,c.created_at`
|
||||
|
||||
func scanCase(row interface{ Scan(...any) error }) (*FindingCase, error) {
|
||||
c := new(FindingCase)
|
||||
err := row.Scan(&c.ID, &c.TaskID, &c.Title, &c.Reason, &c.Severity, &c.SeverityReason, &c.Report, &c.Version, &c.ReportVersion, &c.Active, &c.CreatedAt)
|
||||
return c, err
|
||||
}
|
||||
func (d *DB) GetFindingCase(id int64) (*FindingCase, error) {
|
||||
c, err := scanCase(d.QueryRow(`SELECT `+caseColumns+` FROM finding_cases c WHERE id=$1`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = d.QueryRow(`SELECT count(*),count(*) FILTER(WHERE severity='critical'),count(*) FILTER(WHERE severity='high'),count(*) FILTER(WHERE severity='medium'),count(*) FILTER(WHERE severity='low') FROM findings f JOIN finding_case_members m ON m.finding_id=f.id WHERE m.case_id=$1`, id).Scan(&c.Count, &c.Critical, &c.High, &c.Medium, &c.Low)
|
||||
return c, err
|
||||
}
|
||||
func (d *DB) FindingCaseID(id int64) (int64, error) {
|
||||
var cid int64
|
||||
err := d.QueryRow(`SELECT case_id FROM finding_case_members WHERE finding_id=$1`, id).Scan(&cid)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
return cid, err
|
||||
}
|
||||
func (d *DB) FindingCaseMembers(id int64, page, size int) ([]*DBFinding, int, error) {
|
||||
var count int
|
||||
if err := d.QueryRow(`SELECT count(*) FROM finding_case_members WHERE case_id=$1`, id).Scan(&count); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows, err := d.Query(`SELECT `+findingSelectCols+` FROM findings f LEFT JOIN tasks t ON t.id=f.task_id JOIN finding_case_members m ON m.finding_id=f.id WHERE m.case_id=$1 ORDER BY f.id LIMIT $2 OFFSET $3`, id, size, (page-1)*size)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
fs, err := scanFindings(rows)
|
||||
return fs, count, err
|
||||
}
|
||||
func (d *DB) FindingCaseEvents(id int64) ([]FindingCaseEvent, error) {
|
||||
rows, err := d.Query(`SELECT id,action,actor,reason,finding_ids,created_at FROM finding_case_events WHERE case_id=$1 ORDER BY id DESC`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []FindingCaseEvent{}
|
||||
for rows.Next() {
|
||||
var e FindingCaseEvent
|
||||
if err := rows.Scan(&e.ID, &e.Action, &e.Actor, &e.Reason, &e.FindingIDs, &e.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
func (d *DB) ListFindingCases(filter FindingFilter, page, size int) ([]FindingCaseRow, int, error) {
|
||||
var err error
|
||||
filter, err = d.applyAssetScope(filter)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
where, args := filter.where()
|
||||
base := `WITH matched AS (SELECT f.id,f.severity,f.created_at,COALESCE(m.case_id,0) cid FROM findings f LEFT JOIN tasks t ON t.id=f.task_id LEFT JOIN finding_case_members m ON m.finding_id=f.id` + where + `), entities AS (SELECT CASE WHEN cid>0 THEN 'c:'||cid ELSE 'f:'||id END key,MAX(cid) cid,MIN(id) fid,jsonb_agg(id ORDER BY id) matched_ids,MAX(created_at) created,MAX(CASE severity WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END) severity_rank FROM matched GROUP BY key)`
|
||||
var total int
|
||||
if err := d.QueryRow(base+` SELECT count(*) FROM entities`, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
order := `e.created DESC,e.fid DESC`
|
||||
if filter.Sort == "severity" {
|
||||
order = `e.severity_rank DESC,` + order
|
||||
}
|
||||
args = append(args, size, (page-1)*size)
|
||||
rows, err := d.Query(base+fmt.Sprintf(` SELECT e.cid,e.fid,e.matched_ids FROM entities e ORDER BY %s LIMIT $%d OFFSET $%d`, order, len(args)-1, len(args)), args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out := []FindingCaseRow{}
|
||||
groups := []int64{}
|
||||
for rows.Next() {
|
||||
var cid, fid int64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&cid, &fid, &raw); err != nil {
|
||||
rows.Close()
|
||||
return nil, 0, err
|
||||
}
|
||||
r := FindingCaseRow{FindingID: fid}
|
||||
if err := json.Unmarshal(raw, &r.MatchedIDs); err != nil {
|
||||
rows.Close()
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, r)
|
||||
groups = append(groups, cid)
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for i, cid := range groups {
|
||||
if cid > 0 {
|
||||
c, err := scanCase(d.QueryRow(`SELECT `+strings.Replace(caseColumns, "c.report,", "''::text,", 1)+` FROM finding_cases c WHERE id=$1`, cid))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := d.QueryRow(`SELECT count(*),count(*) FILTER(WHERE severity='critical'),count(*) FILTER(WHERE severity='high'),count(*) FILTER(WHERE severity='medium'),count(*) FILTER(WHERE severity='low') FROM findings f JOIN finding_case_members m ON m.finding_id=f.id WHERE m.case_id=$1`, cid).Scan(&c.Count, &c.Critical, &c.High, &c.Medium, &c.Low); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out[i].Case = c
|
||||
out[i].FindingID = 0
|
||||
}
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
func sortedFindingIDs(ids []int64) ([]int64, error) {
|
||||
set := map[int64]bool{}
|
||||
out := []int64{}
|
||||
for _, id := range ids {
|
||||
if id <= 0 {
|
||||
return nil, errors.New("无效漏洞编号")
|
||||
}
|
||||
if !set[id] {
|
||||
set[id] = true
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||
if len(out) < 2 {
|
||||
return nil, errors.New("至少需要两条漏洞记录")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func lockCaseTask(tx *sql.Tx, taskID int64) error {
|
||||
if taskID <= 0 {
|
||||
return errors.New("归并需要仍存在的同一任务")
|
||||
}
|
||||
if _, err := tx.Exec(`SELECT pg_advisory_xact_lock($1)`, int64(5100000000000000)+taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
var id int64
|
||||
return tx.QueryRow(`SELECT id FROM tasks WHERE id=$1 FOR UPDATE`, taskID).Scan(&id)
|
||||
}
|
||||
func (d *DB) MergeFindingCase(ctx context.Context, taskID int64, ids []int64, title, reason, actor string) (int64, error) {
|
||||
return d.mergeFindingCase(ctx, taskID, ids, title, reason, actor, 0)
|
||||
}
|
||||
func (d *DB) mergeFindingCase(ctx context.Context, taskID int64, ids []int64, title, reason, actor string, suggestionID int64) (int64, error) {
|
||||
ids, err := sortedFindingIDs(ids)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if strings.TrimSpace(title) == "" || strings.TrimSpace(reason) == "" {
|
||||
return 0, errors.New("必须提供漏洞名称及基于证据的归并理由")
|
||||
}
|
||||
var cid int64
|
||||
err = d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
|
||||
if err := lockCaseTask(tx, taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
if suggestionID > 0 {
|
||||
var state string
|
||||
if err := tx.QueryRow(`SELECT state FROM finding_case_suggestions WHERE id=$1 FOR UPDATE`, suggestionID).Scan(&state); err != nil {
|
||||
return err
|
||||
}
|
||||
if state != "pending" {
|
||||
return ErrFindingCaseConflict
|
||||
}
|
||||
}
|
||||
groupSet := map[int64]bool{}
|
||||
for _, id := range ids {
|
||||
var tid sql.NullInt64
|
||||
var group int64
|
||||
if err := tx.QueryRow(`SELECT f.task_id,COALESCE(m.case_id,0) FROM findings f LEFT JOIN finding_case_members m ON m.finding_id=f.id WHERE f.id=$1`, id).Scan(&tid, &group); err != nil {
|
||||
return err
|
||||
}
|
||||
if !tid.Valid || tid.Int64 != taskID {
|
||||
return errors.New("只能归并同一任务的漏洞")
|
||||
}
|
||||
if group > 0 {
|
||||
groupSet[group] = true
|
||||
}
|
||||
}
|
||||
groups := []int64{}
|
||||
for g := range groupSet {
|
||||
groups = append(groups, g)
|
||||
}
|
||||
sort.Slice(groups, func(i, j int) bool { return groups[i] < groups[j] })
|
||||
all := map[int64]bool{}
|
||||
for _, id := range ids {
|
||||
all[id] = true
|
||||
}
|
||||
for _, g := range groups {
|
||||
rows, err := tx.Query(`SELECT finding_id FROM finding_case_members WHERE case_id=$1`, g)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
all[id] = true
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
ids = ids[:0]
|
||||
for id := range all {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
raw, _ := json.Marshal(ids)
|
||||
var blocked bool
|
||||
if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_blocks WHERE left_id IN(SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb)) AND right_id IN(SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb)))`, string(raw)).Scan(&blocked); err != nil {
|
||||
return err
|
||||
}
|
||||
if blocked {
|
||||
return errors.New("人工已否决或撤销这些记录的归并")
|
||||
}
|
||||
if len(groups) > 0 {
|
||||
cid = groups[0]
|
||||
} else {
|
||||
if err := tx.QueryRow(`INSERT INTO finding_cases(task_id,origin_task_id,title,reason) VALUES($1,$1,$2,$3) RETURNING id`, taskID, title, reason).Scan(&cid); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var changed int64
|
||||
for _, id := range ids {
|
||||
res, err := tx.Exec(`INSERT INTO finding_case_members(finding_id,case_id) VALUES($1,$2) ON CONFLICT(finding_id) DO UPDATE SET case_id=EXCLUDED.case_id WHERE finding_case_members.case_id<>EXCLUDED.case_id`, id, cid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
changed += n
|
||||
}
|
||||
for _, g := range groups {
|
||||
if g != cid {
|
||||
if _, err := tx.Exec(`UPDATE finding_cases SET active=false,version=version+1 WHERE id=$1`, g); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed > 0 {
|
||||
if _, err := tx.Exec(`UPDATE finding_cases SET version=version+1 WHERE id=$1`, cid); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO finding_case_events(case_id,action,actor,reason,finding_ids) VALUES($1,'merge',$2,$3,$4)`, cid, actor, reason, string(raw)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := tx.Exec(`UPDATE finding_case_suggestions SET state='accepted' WHERE state='pending' AND left_id IN(SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb)) AND right_id IN(SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb))`, string(raw))
|
||||
return err
|
||||
})
|
||||
return cid, err
|
||||
}
|
||||
func (d *DB) RemoveFindingCaseMember(ctx context.Context, cid, fid int64, reason string) error {
|
||||
c, err := d.GetFindingCase(cid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c == nil {
|
||||
return errors.New("归并组不存在")
|
||||
}
|
||||
return d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
|
||||
var origin int64
|
||||
if err := tx.QueryRow(`SELECT origin_task_id FROM finding_cases WHERE id=$1`, cid).Scan(&origin); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`SELECT pg_advisory_xact_lock($1)`, int64(5100000000000000)+origin); err != nil {
|
||||
return err
|
||||
}
|
||||
// Deleting a task retains its findings and folder history.
|
||||
var live int64
|
||||
if err := tx.QueryRow(`SELECT id FROM tasks WHERE id=$1 FOR UPDATE`, origin).Scan(&live); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
var current int64
|
||||
if err := tx.QueryRow(`SELECT case_id FROM finding_case_members WHERE finding_id=$1`, fid).Scan(¤t); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
var removed bool
|
||||
// A repeated removal, or a last member released by dissolution, is idempotent.
|
||||
err = tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_events WHERE case_id=$1 AND finding_ids @> jsonb_build_array($2::bigint))`, cid, fid).Scan(&removed)
|
||||
if err == nil && removed {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
if current != cid {
|
||||
return ErrFindingCaseConflict
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO finding_case_blocks(left_id,right_id,reason) SELECT LEAST($1,finding_id),GREATEST($1,finding_id),$3 FROM finding_case_members WHERE case_id=$2 AND finding_id<>$1 ON CONFLICT DO NOTHING`, fid, cid, reason); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO finding_case_events(case_id,action,actor,reason,finding_ids) VALUES($1,'detach','human',$2,jsonb_build_array($3::bigint))`, cid, reason, fid); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec(`DELETE FROM finding_case_members WHERE case_id=$1 AND finding_id=$2`, cid, fid)
|
||||
return err
|
||||
})
|
||||
}
|
||||
func (d *DB) UpdateFindingCaseReport(ctx context.Context, cid, version int64, title, report, severity, reason string) error {
|
||||
if !ValidSeverity(severity) || strings.TrimSpace(report) == "" || strings.TrimSpace(reason) == "" || strings.TrimSpace(title) == "" {
|
||||
return errors.New("完整报告、名称、有效等级和评级依据必填")
|
||||
}
|
||||
return d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
|
||||
c, err := scanCase(tx.QueryRow(`SELECT `+caseColumns+` FROM finding_cases c WHERE id=$1 FOR UPDATE`, cid))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !c.Active || c.Version != version {
|
||||
return ErrFindingCaseConflict
|
||||
}
|
||||
if c.Title == title && c.Report == report && c.Severity == severity && c.SeverityReason == reason && c.ReportVersion == version {
|
||||
return nil
|
||||
}
|
||||
_, err = tx.Exec(`UPDATE finding_cases SET title=$2,report=$3,severity=$4,severity_reason=$5,report_version=$6 WHERE id=$1`, cid, title, report, severity, reason, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`INSERT INTO finding_case_events(case_id,action,actor,reason) VALUES($1,'report_updated','reporter',$2)`, cid, "统一报告已更新;评级依据:"+reason)
|
||||
return err
|
||||
})
|
||||
}
|
||||
func (d *DB) SuggestFindingCase(ctx context.Context, taskID, left, right int64, title, reason string) (int64, error) {
|
||||
if strings.TrimSpace(title) == "" || strings.TrimSpace(reason) == "" {
|
||||
return 0, errors.New("建议需要名称和具体证据理由")
|
||||
}
|
||||
ids, err := sortedFindingIDs([]int64{left, right})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var id int64
|
||||
err = d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
|
||||
if err := lockCaseTask(tx, taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, fid := range ids {
|
||||
var tid sql.NullInt64
|
||||
if err := tx.QueryRow(`SELECT task_id FROM findings WHERE id=$1`, fid).Scan(&tid); err != nil {
|
||||
return err
|
||||
}
|
||||
if !tid.Valid || tid.Int64 != taskID {
|
||||
return errors.New("只能建议同一任务的归并")
|
||||
}
|
||||
}
|
||||
var blocked bool
|
||||
if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_blocks WHERE left_id=$1 AND right_id=$2)`, ids[0], ids[1]).Scan(&blocked); err != nil {
|
||||
return err
|
||||
}
|
||||
if blocked {
|
||||
return errors.New("人工已否决该归并")
|
||||
}
|
||||
return tx.QueryRow(`INSERT INTO finding_case_suggestions(task_id,left_id,right_id,title,reason) VALUES($1,$2,$3,$4,$5) ON CONFLICT(left_id,right_id) DO UPDATE SET reason=EXCLUDED.reason WHERE finding_case_suggestions.state='pending' RETURNING id`, taskID, ids[0], ids[1], title, reason).Scan(&id)
|
||||
})
|
||||
return id, err
|
||||
}
|
||||
func (d *DB) FindingCaseSuggestions(taskID int64) ([]FindingCaseSuggestion, error) {
|
||||
rows, err := d.Query(`SELECT id,task_id,left_id,right_id,title,reason,state FROM finding_case_suggestions WHERE state='pending' AND ($1::bigint=0 OR task_id=$1) ORDER BY id DESC`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []FindingCaseSuggestion{}
|
||||
for rows.Next() {
|
||||
var s FindingCaseSuggestion
|
||||
if err := rows.Scan(&s.ID, &s.TaskID, &s.LeftID, &s.RightID, &s.Title, &s.Reason, &s.State); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
func (d *DB) ResolveFindingCaseSuggestion(ctx context.Context, id int64, accept bool) (int64, error) {
|
||||
var s FindingCaseSuggestion
|
||||
if err := d.QueryRow(`SELECT task_id,left_id,right_id,title,reason,state FROM finding_case_suggestions WHERE id=$1`, id).Scan(&s.TaskID, &s.LeftID, &s.RightID, &s.Title, &s.Reason, &s.State); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if s.State != "pending" {
|
||||
return 0, ErrFindingCaseConflict
|
||||
}
|
||||
if accept {
|
||||
return d.mergeFindingCase(ctx, s.TaskID, []int64{s.LeftID, s.RightID}, s.Title, s.Reason, "human", id)
|
||||
}
|
||||
return 0, d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
|
||||
if err := lockCaseTask(tx, s.TaskID); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := tx.Exec(`UPDATE finding_case_suggestions SET state='rejected' WHERE id=$1 AND state='pending'`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n != 1 {
|
||||
return ErrFindingCaseConflict
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO finding_case_blocks(left_id,right_id,reason) VALUES($1,$2,'人工否决') ON CONFLICT DO NOTHING`, s.LeftID, s.RightID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
type FindingDistinctStats struct {
|
||||
Total int `json:"total"`
|
||||
Reports int `json:"reports"`
|
||||
Critical int `json:"critical"`
|
||||
High int `json:"high"`
|
||||
Medium int `json:"medium"`
|
||||
Low int `json:"low"`
|
||||
Unassessed int `json:"unassessed"`
|
||||
}
|
||||
|
||||
func (d *DB) DistinctFindingStats(taskID string) (*FindingDistinctStats, error) {
|
||||
filter := FindingFilter{TaskID: taskID}
|
||||
where, args := filter.where()
|
||||
s := new(FindingDistinctStats)
|
||||
err := d.QueryRow(`WITH observations AS (SELECT f.id,f.severity,COALESCE(m.case_id,0) cid FROM findings f LEFT JOIN tasks t ON t.id=f.task_id LEFT JOIN finding_case_members m ON m.finding_id=f.id`+where+`), defects AS (SELECT severity FROM observations WHERE cid=0 UNION ALL SELECT c.severity FROM finding_cases c WHERE c.id IN(SELECT cid FROM observations WHERE cid>0)) SELECT count(*),(SELECT count(*) FROM observations),count(*) FILTER(WHERE severity='critical'),count(*) FILTER(WHERE severity='high'),count(*) FILTER(WHERE severity='medium'),count(*) FILTER(WHERE severity='low'),count(*) FILTER(WHERE severity='') FROM defects`, args...).Scan(&s.Total, &s.Reports, &s.Critical, &s.High, &s.Medium, &s.Low, &s.Unassessed)
|
||||
return s, err
|
||||
}
|
||||
|
||||
// Compact candidate pages exclude PoC/report bodies and do not treat severity as identity.
|
||||
func (d *DB) FindingCaseCandidates(fid int64, page, size int, scope ...[]int64) ([]map[string]any, int, error) {
|
||||
f, err := d.FindingCaseSummary(fid)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if f == nil || f.TaskID == nil {
|
||||
return nil, 0, errors.New("漏洞任务不可用")
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
assets, _ := json.Marshal(f.AssetIDs)
|
||||
if f.AssetIDs == nil {
|
||||
assets = []byte("[]")
|
||||
}
|
||||
// Type/asset/name only retrieve candidates; the Reporter must still prove the root cause.
|
||||
where := ` WHERE f.task_id=$1 AND f.id<>$2 AND NOT EXISTS(SELECT 1 FROM finding_case_blocks b WHERE b.left_id=LEAST(f.id,$2::bigint) AND b.right_id=GREATEST(f.id,$2::bigint))
|
||||
AND ((trim($3::text)<>'' AND lower(f.vulnclass)=lower($3)) OR (trim($4::text)<>'' AND lower(f.name)=lower($4))
|
||||
OR EXISTS(SELECT 1 FROM jsonb_array_elements(COALESCE(NULLIF(f.asset_ids,'null'::jsonb),'[]'::jsonb)) a WHERE a.value IN(SELECT value FROM jsonb_array_elements($5::jsonb))))`
|
||||
args := []any{*f.TaskID, fid, f.VulnClass, f.Name, string(assets)}
|
||||
if len(scope) > 0 {
|
||||
raw, err := json.Marshal(scope[0])
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
where += ` AND f.id IN (SELECT jsonb_array_elements_text($6::jsonb)::bigint)`
|
||||
args = append(args, string(raw))
|
||||
}
|
||||
var total int
|
||||
if err := d.QueryRow(`SELECT count(*) FROM findings f`+where, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
args = append(args, size, (page-1)*size)
|
||||
rows, err := d.Query(`SELECT f.id,f.name,f.vulnclass,f.severity,left(f.summary,400),f.asset_ids,COALESCE(m.case_id,0) FROM findings f LEFT JOIN finding_case_members m ON m.finding_id=f.id`+where+fmt.Sprintf(` ORDER BY f.id DESC LIMIT $%d OFFSET $%d`, len(args)-1, len(args)), args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, cid int64
|
||||
var name, class, severity, summary string
|
||||
var assetIDs json.RawMessage
|
||||
if err := rows.Scan(&id, &name, &class, &severity, &summary, &assetIDs, &cid); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, map[string]any{"finding_id": strconv.FormatInt(id, 10), "name": name, "vulnclass": class, "severity": severity, "summary": summary, "asset_ids": assetIDs, "case_id": strconv.FormatInt(cid, 10)})
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// Batch task-list counters avoid an additional database round trip per task.
|
||||
func (d *DB) DistinctFindingStatsByTask() (map[string]*FindingDistinctStats, error) {
|
||||
rows, err := d.Query(`WITH observations AS (
|
||||
SELECT f.task_id,CASE WHEN m.case_id IS NULL THEN 'f:'||f.id ELSE 'c:'||m.case_id END entity,
|
||||
CASE WHEN m.case_id IS NULL THEN f.severity ELSE c.severity END severity
|
||||
FROM findings f LEFT JOIN finding_case_members m ON m.finding_id=f.id LEFT JOIN finding_cases c ON c.id=m.case_id WHERE f.task_id IS NOT NULL)
|
||||
SELECT task_id,count(DISTINCT entity),count(*),
|
||||
count(DISTINCT entity) FILTER(WHERE severity='critical'),count(DISTINCT entity) FILTER(WHERE severity='high'),
|
||||
count(DISTINCT entity) FILTER(WHERE severity='medium'),count(DISTINCT entity) FILTER(WHERE severity='low'),
|
||||
count(DISTINCT entity) FILTER(WHERE severity='') FROM observations GROUP BY task_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]*FindingDistinctStats{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
s := new(FindingDistinctStats)
|
||||
if err := rows.Scan(&id, &s.Total, &s.Reports, &s.Critical, &s.High, &s.Medium, &s.Low, &s.Unassessed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[strconv.FormatInt(id, 10)] = s
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Folder rows and candidate searches omit PoC bodies. The original table can
|
||||
// request evidence for ungrouped rows without fetching report bodies.
|
||||
func (d *DB) FindingCaseSummary(id int64, originalRow ...bool) (*DBFinding, error) {
|
||||
cols := strings.Replace(findingSelectCols, "f.evidence,", "''::text,", 1)
|
||||
if len(originalRow) > 0 && originalRow[0] {
|
||||
cols = findingSelectCols
|
||||
}
|
||||
rows, err := d.Query(`SELECT `+cols+` FROM findings f LEFT JOIN tasks t ON t.id=f.task_id WHERE f.id=$1`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
fs, err := scanFindings(rows)
|
||||
if err != nil || len(fs) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return fs[0], nil
|
||||
}
|
||||
|
||||
// FindingCaseListContext contains bounded metadata for collapsed folder rows.
|
||||
// Reports and member evidence are still fetched only when opened.
|
||||
type FindingCaseListContext struct {
|
||||
TaskName string
|
||||
TaskDescription string
|
||||
AssetIDs []int64
|
||||
AssetCount int
|
||||
LastFoundAt time.Time
|
||||
}
|
||||
|
||||
func (d *DB) FindingCaseListContexts(ids []int64) (map[int64]FindingCaseListContext, error) {
|
||||
result := map[int64]FindingCaseListContext{}
|
||||
if len(ids) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
raw, err := json.Marshal(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := d.Query(`SELECT c.id,COALESCE(t.name,''),COALESCE(t.description,''),
|
||||
COALESCE((SELECT MAX(f.created_at) FROM finding_case_members m JOIN findings f ON f.id=m.finding_id WHERE m.case_id=c.id),c.created_at),
|
||||
COALESCE((SELECT jsonb_agg(v.id ORDER BY v.id) FROM (
|
||||
SELECT DISTINCT a.id FROM finding_case_members m JOIN findings f ON f.id=m.finding_id
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(f.asset_ids) e(v) JOIN assets a ON a.id=e.v::bigint
|
||||
WHERE m.case_id=c.id ORDER BY a.id LIMIT 4) v),'[]'::jsonb),
|
||||
(SELECT COUNT(DISTINCT a.id) FROM finding_case_members m JOIN findings f ON f.id=m.finding_id
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(f.asset_ids) e(v) JOIN assets a ON a.id=e.v::bigint WHERE m.case_id=c.id)
|
||||
FROM finding_cases c LEFT JOIN tasks t ON t.id=c.task_id
|
||||
WHERE c.id IN (SELECT jsonb_array_elements_text($1::jsonb)::bigint)`, string(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var c FindingCaseListContext
|
||||
var assets []byte
|
||||
if err := rows.Scan(&id, &c.TaskName, &c.TaskDescription, &c.LastFoundAt, &assets, &c.AssetCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(assets, &c.AssetIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[id] = c
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
func (d *DB) MatchingFindingReportCount(filter FindingFilter) (int, error) {
|
||||
filter, err := d.applyAssetScope(filter)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
where, args := filter.where()
|
||||
var n int
|
||||
err = d.QueryRow(`SELECT COUNT(*) FROM findings f LEFT JOIN tasks t ON t.id=f.task_id`+where, args...).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func caseTestDB(t *testing.T) (*DB, *Task, []int64) {
|
||||
t.Helper()
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { d.Close() })
|
||||
task, err := d.CreateTask("case integration", "goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := []int64{}
|
||||
for _, sev := range []string{"high", "medium", "low", "high"} {
|
||||
id, err := d.AddFinding(task.ID, 0, "IDOR", "order access", sev, "GET /orders/{id}", "original PoC "+sev, "worker", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM findings WHERE task_id=$1`, task.ID); _ = d.DeleteTask(task.ID) })
|
||||
return d, task, ids
|
||||
}
|
||||
func TestFindingCaseMergePreservesRecordsAndVersions(t *testing.T) {
|
||||
d, task, ids := caseTestDB(t)
|
||||
ctx := context.Background()
|
||||
id, err := d.MergeFindingCase(ctx, task.ID, ids[:3], "订单越权", "同一接口同一权限检查缺失", "reporter")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := d.GetFindingCase(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Count != 3 || c.High != 1 || c.Medium != 1 || c.Low != 1 {
|
||||
t.Fatalf("counts %+v", c)
|
||||
}
|
||||
stats, err := d.DistinctFindingStats(fmt.Sprint(task.ID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
batch, err := d.DistinctFindingStatsByTask()
|
||||
if err != nil || batch[fmt.Sprint(task.ID)] == nil || *batch[fmt.Sprint(task.ID)] != *stats {
|
||||
t.Fatalf("batch stats %+v %v", batch, err)
|
||||
}
|
||||
if stats.Total != 2 || stats.Reports != 4 || stats.Unassessed != 1 {
|
||||
t.Fatalf("stats %+v", stats)
|
||||
}
|
||||
again, err := d.MergeFindingCase(ctx, task.ID, ids[:3], "same", "same", "reporter")
|
||||
if err != nil || again != id {
|
||||
t.Fatalf("idempotency %d %v", again, err)
|
||||
}
|
||||
same, _ := d.GetFindingCase(id)
|
||||
if same.Version != c.Version {
|
||||
t.Fatal("idempotent merge invalidated report")
|
||||
}
|
||||
if err := d.UpdateFindingCaseReport(ctx, id, c.Version, "订单越权", "complete PoC", "high", "verified sensitive data"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, fid := range ids[:3] {
|
||||
f, err := d.GetFinding(fid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.Severity != []string{"high", "medium", "low"}[i] || f.Evidence == "" || f.Status != FindingPending {
|
||||
t.Fatalf("original overwritten %+v", f)
|
||||
}
|
||||
}
|
||||
if _, err := d.Exec(`UPDATE findings SET evidence='new proof' WHERE id=$1`, ids[1]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.UpdateFindingCaseReport(ctx, id, c.Version, "x", "stale", "low", "x"); err != ErrFindingCaseConflict {
|
||||
t.Fatalf("stale accepted %v", err)
|
||||
}
|
||||
if err := d.RemoveFindingCaseMember(ctx, id, ids[1], "different cause"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := d.MergeFindingCase(ctx, task.ID, ids[:3], "x", "x", "reporter"); err == nil {
|
||||
t.Fatal("human veto ignored")
|
||||
}
|
||||
if err := d.RemoveFindingCaseMember(ctx, id, ids[2], "undo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, _ = d.GetFindingCase(id)
|
||||
if c.Active {
|
||||
t.Fatal("singleton remains group")
|
||||
}
|
||||
for _, fid := range ids[:3] {
|
||||
g, err := d.FindingCaseID(fid)
|
||||
if err != nil || g != 0 {
|
||||
t.Fatalf("dangling membership %d %v", g, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestFindingCaseScopeSuggestionsAndDeletion(t *testing.T) {
|
||||
d, task, ids := caseTestDB(t)
|
||||
ctx := context.Background()
|
||||
other, err := d.CreateTask("other", "goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.DeleteTask(other.ID)
|
||||
fid, err := d.AddFinding(other.ID, 0, "IDOR", "other", "high", "same", "poc", "worker", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Exec(`DELETE FROM findings WHERE id=$1`, fid)
|
||||
if _, err := d.MergeFindingCase(ctx, task.ID, []int64{ids[0], fid}, "x", "x", "reporter"); err == nil {
|
||||
t.Fatal("cross task accepted")
|
||||
}
|
||||
sid, err := d.SuggestFindingCase(ctx, task.ID, ids[0], ids[1], "order", "uncertain")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := d.MergeFindingCase(ctx, task.ID, ids[:2], "x", "x", "reporter"); err == nil {
|
||||
t.Fatal("rejection ignored")
|
||||
}
|
||||
cid, err := d.MergeFindingCase(ctx, task.ID, ids[2:], "x", "proof", "reporter")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := d.DeleteFinding(ids[3]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, _ := d.GetFindingCase(cid)
|
||||
if c.Active {
|
||||
t.Fatal("deletion did not dissolve group")
|
||||
}
|
||||
f, err := d.GetFinding(ids[2])
|
||||
if err != nil || f == nil {
|
||||
t.Fatal("surviving finding lost")
|
||||
}
|
||||
}
|
||||
func TestFindingCaseConcurrentUnion(t *testing.T) {
|
||||
d, task, ids := caseTestDB(t)
|
||||
ctx := context.Background()
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 3)
|
||||
for _, pair := range [][]int64{{ids[0], ids[1]}, {ids[1], ids[2]}, {ids[2], ids[3]}} {
|
||||
pair := pair
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, err := d.MergeFindingCase(ctx, task.ID, pair, "orders", "same authorization check", "reporter")
|
||||
errs <- err
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
group := int64(0)
|
||||
for _, fid := range ids {
|
||||
cid, err := d.FindingCaseID(fid)
|
||||
if err != nil || cid == 0 {
|
||||
t.Fatalf("membership %v", err)
|
||||
}
|
||||
if group != 0 && cid != group {
|
||||
t.Fatal("overlapping parallel groups")
|
||||
}
|
||||
group = cid
|
||||
}
|
||||
c, _ := d.GetFindingCase(group)
|
||||
if c.Count != 4 {
|
||||
t.Fatalf("lost member %+v", c)
|
||||
}
|
||||
rows, total, err := d.ListFindingCases(FindingFilter{TaskID: fmt.Sprint(task.ID), Severity: "low"}, 1, 10)
|
||||
if err != nil || total != 1 || len(rows) != 1 || rows[0].Case.Count != 4 || len(rows[0].MatchedIDs) != 1 {
|
||||
t.Fatalf("filter/aggregation %d %+v %v", total, rows, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindingCaseTaskDeletionAndRepeatedDetach(t *testing.T) {
|
||||
d, task, ids := caseTestDB(t)
|
||||
ctx := context.Background()
|
||||
cid, err := d.MergeFindingCase(ctx, task.ID, ids[:3], "orders", "same missing permission", "reporter")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A source task can disappear without deleting standalone reports or breaking old links.
|
||||
if err := d.DeleteTask(task.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
for _, id := range ids {
|
||||
_, _ = d.DeleteFinding(id)
|
||||
}
|
||||
}()
|
||||
c, err := d.GetFindingCase(cid)
|
||||
if err != nil || c == nil || c.TaskID != nil || c.Count != 3 {
|
||||
t.Fatalf("retention %+v %v", c, err)
|
||||
}
|
||||
if err := d.RemoveFindingCaseMember(ctx, cid, ids[0], "undo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.RemoveFindingCaseMember(ctx, cid, ids[0], "retry"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.RemoveFindingCaseMember(ctx, cid, ids[1], "undo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.RemoveFindingCaseMember(ctx, cid, ids[2], "released by dissolution"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, _ = d.GetFindingCase(cid)
|
||||
if c.Active || c.Count != 0 {
|
||||
t.Fatalf("folder not dissolved %+v", c)
|
||||
}
|
||||
for _, id := range ids[:3] {
|
||||
f, e := d.GetFinding(id)
|
||||
if e != nil || f == nil || f.Evidence == "" {
|
||||
t.Fatalf("original lost %d %v", id, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindingCaseSuggestionResolutionIsAtomic(t *testing.T) {
|
||||
d, task, ids := caseTestDB(t)
|
||||
ctx := context.Background()
|
||||
sid, err := d.SuggestFindingCase(ctx, task.ID, ids[0], ids[1], "orders", "uncertain")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
results := make(chan error, 2)
|
||||
var wg sync.WaitGroup
|
||||
for _, accept := range []bool{true, false} {
|
||||
wg.Add(1)
|
||||
go func(a bool) { defer wg.Done(); _, e := d.ResolveFindingCaseSuggestion(ctx, sid, a); results <- e }(accept)
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
success := 0
|
||||
for err := range results {
|
||||
if err == nil {
|
||||
success++
|
||||
}
|
||||
}
|
||||
if success != 1 {
|
||||
t.Fatalf("%d successful conflicting resolutions", success)
|
||||
}
|
||||
var state string
|
||||
if err := d.QueryRow(`SELECT state FROM finding_case_suggestions WHERE id=$1`, sid).Scan(&state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cid, _ := d.FindingCaseID(ids[0])
|
||||
if (state == "accepted") != (cid > 0) {
|
||||
t.Fatalf("inconsistent resolution %s %d", state, cid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindingCaseCandidatesAreCompactAndRelated(t *testing.T) {
|
||||
d, task, ids := caseTestDB(t)
|
||||
if _, err := d.Exec(`UPDATE findings SET vulnclass='SQLi',name='search injection' WHERE id=$1`, ids[3]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, total, err := d.FindingCaseCandidates(ids[0], 1, 1)
|
||||
if err != nil || total != 2 || len(items) != 1 {
|
||||
t.Fatalf("candidate pagination %d %+v %v", total, items, err)
|
||||
}
|
||||
if _, ok := items[0]["report"]; ok {
|
||||
t.Fatal("report included in candidate summary")
|
||||
}
|
||||
if _, ok := items[0]["evidence"]; ok {
|
||||
t.Fatal("evidence included in candidate summary")
|
||||
}
|
||||
cid, err := d.MergeFindingCase(context.Background(), task.ID, ids[:2], "orders", "proof", "reporter")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.RemoveFindingCaseMember(context.Background(), cid, ids[1], "different cause"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, total, err = d.FindingCaseCandidates(ids[0], 1, 20)
|
||||
if err != nil || total != 1 {
|
||||
t.Fatalf("veto excluded %d %v", total, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindingCaseViewContextAndAssetFiltering(t *testing.T) {
|
||||
d, task, ids := caseTestDB(t)
|
||||
if _, err := d.Exec(`UPDATE tasks SET name='Order API' WHERE id=$1`, task.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assets := []int64{}
|
||||
for i := 0; i < 6; i++ {
|
||||
aid := seedTreeAsset(t, d, "service", map[string]any{"url": fmt.Sprintf("https://view-context-%d-%d.example", task.ID, i)})
|
||||
assets = append(assets, aid)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for _, a := range assets {
|
||||
_, _ = d.Exec(`DELETE FROM assets WHERE id=$1`, a)
|
||||
}
|
||||
})
|
||||
for i, id := range ids[:3] {
|
||||
raw, _ := json.Marshal(assets[i*2 : i*2+2])
|
||||
if _, err := d.Exec(`UPDATE findings SET asset_ids=$2 WHERE id=$1`, id, string(raw)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cid, err := d.MergeFindingCase(t.Context(), task.ID, ids[:3], "order access", "same permission check", "human")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contexts, err := d.FindingCaseListContexts([]int64{cid})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := contexts[cid]
|
||||
if c.TaskName != "Order API" || c.TaskDescription != task.Description || c.AssetCount != 6 || !reflect.DeepEqual(c.AssetIDs, assets[:4]) || c.LastFoundAt.IsZero() {
|
||||
t.Fatalf("context %+v", c)
|
||||
}
|
||||
filter := FindingFilter{TaskID: fmt.Sprint(task.ID), AssetScope: fmt.Sprintf("a:%d", assets[0])}
|
||||
rows, total, err := d.ListFindingCases(filter, 1, 20)
|
||||
if err != nil || total != 1 || len(rows) != 1 || rows[0].Case == nil || rows[0].Case.ID != cid || !reflect.DeepEqual(rows[0].MatchedIDs, ids[:1]) {
|
||||
t.Fatalf("filtered rows %+v total %d err %v", rows, total, err)
|
||||
}
|
||||
members, n, err := d.FindingCaseMembers(cid, 1, 20)
|
||||
if err != nil || n != 3 || len(members) != 3 {
|
||||
t.Fatalf("all folder members %d %v", n, err)
|
||||
}
|
||||
matches, err := d.MatchingFindingReportCount(filter)
|
||||
if err != nil || matches != 1 {
|
||||
t.Fatalf("matching reports %d %v", matches, err)
|
||||
}
|
||||
rows, total, err = d.ListFindingCases(FindingFilter{TaskID: fmt.Sprint(task.ID), Severity: "medium"}, 1, 20)
|
||||
if err != nil || total != 1 || len(rows) != 1 || rows[0].Case.Count != 3 || rows[0].Case.High != 1 || rows[0].Case.Medium != 1 || rows[0].Case.Low != 1 {
|
||||
t.Fatalf("severity filter %+v %v", rows, err)
|
||||
}
|
||||
matches, err = d.MatchingFindingReportCount(FindingFilter{TaskID: fmt.Sprint(task.ID), Severity: "medium"})
|
||||
if err != nil || matches != 1 {
|
||||
t.Fatalf("severity reports %d %v", matches, err)
|
||||
}
|
||||
// Group has no unified severity yet; it must sort by its original high report.
|
||||
if _, err := d.Exec(`UPDATE findings SET severity='low' WHERE id=$1`, ids[3]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, total, err = d.ListFindingCases(FindingFilter{TaskID: fmt.Sprint(task.ID), Sort: "severity"}, 1, 1)
|
||||
if err != nil || total != 2 || len(rows) != 1 || rows[0].Case == nil || rows[0].Case.ID != cid {
|
||||
t.Fatalf("original severity order %+v %v", rows, err)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ import (
|
||||
// DBFinding is a row in the standalone findings table. It persists across task
|
||||
// deletion unless the caller explicitly requests related finding cleanup.
|
||||
type DBFinding struct {
|
||||
ExportCaseID int64 // transient unified-export identity, never a finding handle
|
||||
ExportMemberIDs []int64
|
||||
|
||||
TrafficCount int
|
||||
EvidenceVersion int64
|
||||
ReportEvidenceVersion int64
|
||||
@@ -524,6 +527,8 @@ func (d *DB) ListFindingsForExport(f FindingFilter, ids []int64) ([]*DBFinding,
|
||||
// and vuln-class filter — computed server-side so it stays exact regardless of
|
||||
// pagination.
|
||||
type FindingStats struct {
|
||||
Distinct *FindingDistinctStats `json:"distinct,omitempty"`
|
||||
|
||||
Total int `json:"total"`
|
||||
Pending int `json:"pending"`
|
||||
Critical int `json:"critical"`
|
||||
|
||||
@@ -1384,3 +1384,94 @@ CREATE INDEX IF NOT EXISTS idx_notification_deliveries_batch
|
||||
ON notification_deliveries(batch_id) WHERE batch_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_channel
|
||||
ON notification_deliveries(channel_id, id DESC);
|
||||
|
||||
-- A case collects repeated observations of one defect without rewriting findings.
|
||||
CREATE TABLE IF NOT EXISTS finding_cases (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
task_id BIGINT REFERENCES tasks(id) ON DELETE SET NULL,
|
||||
origin_task_id BIGINT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
severity TEXT NOT NULL DEFAULT '',
|
||||
severity_reason TEXT NOT NULL DEFAULT '',
|
||||
report TEXT NOT NULL DEFAULT '',
|
||||
version BIGINT NOT NULL DEFAULT 1,
|
||||
report_version BIGINT NOT NULL DEFAULT 0,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS finding_case_members (
|
||||
finding_id BIGINT PRIMARY KEY REFERENCES findings(id) ON DELETE CASCADE,
|
||||
case_id BIGINT NOT NULL REFERENCES finding_cases(id),
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_finding_case_members_case ON finding_case_members(case_id);
|
||||
CREATE TABLE IF NOT EXISTS finding_case_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
case_id BIGINT NOT NULL REFERENCES finding_cases(id),
|
||||
action TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
finding_ids JSONB NOT NULL DEFAULT '[]',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS finding_case_suggestions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
left_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE,
|
||||
right_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(left_id,right_id),
|
||||
CHECK(left_id < right_id)
|
||||
);
|
||||
-- Rejected/detached pairs are a durable human veto, also checked on group unions.
|
||||
CREATE TABLE IF NOT EXISTS finding_case_blocks (
|
||||
left_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE,
|
||||
right_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE,
|
||||
reason TEXT NOT NULL,
|
||||
PRIMARY KEY(left_id,right_id), CHECK(left_id < right_id)
|
||||
);
|
||||
CREATE OR REPLACE FUNCTION finding_case_changed() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE cid BIGINT; remaining INTEGER;
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME = 'findings' THEN
|
||||
IF OLD IS NOT DISTINCT FROM NEW THEN RETURN NEW; END IF;
|
||||
SELECT case_id INTO cid FROM finding_case_members WHERE finding_id=NEW.id;
|
||||
ELSE
|
||||
cid := OLD.case_id;
|
||||
END IF;
|
||||
IF cid IS NOT NULL THEN
|
||||
UPDATE finding_cases SET version=version+1 WHERE id=cid;
|
||||
IF TG_OP='DELETE' THEN
|
||||
SELECT count(*) INTO remaining FROM finding_case_members WHERE case_id=cid;
|
||||
INSERT INTO finding_case_events(case_id,action,actor,reason,finding_ids)
|
||||
VALUES(cid,'member_deleted','system','成员移出或删除',jsonb_build_array(OLD.finding_id));
|
||||
IF remaining < 2 THEN
|
||||
UPDATE finding_cases SET active=false WHERE id=cid;
|
||||
DELETE FROM finding_case_members WHERE case_id=cid;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END $$;
|
||||
DROP TRIGGER IF EXISTS finding_case_content_changed ON findings;
|
||||
CREATE TRIGGER finding_case_content_changed AFTER UPDATE ON findings
|
||||
FOR EACH ROW EXECUTE FUNCTION finding_case_changed();
|
||||
DROP TRIGGER IF EXISTS finding_case_member_deleted ON finding_case_members;
|
||||
CREATE TRIGGER finding_case_member_deleted AFTER DELETE ON finding_case_members
|
||||
FOR EACH ROW EXECUTE FUNCTION finding_case_changed();
|
||||
CREATE TABLE IF NOT EXISTS finding_case_review_runs (
|
||||
conversation_id BIGINT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
task_id BIGINT REFERENCES tasks(id) ON DELETE SET NULL,
|
||||
state TEXT NOT NULL DEFAULT 'queued',
|
||||
error TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
ALTER TABLE finding_case_review_runs ADD COLUMN IF NOT EXISTS finding_ids JSONB NOT NULL DEFAULT '[]';
|
||||
|
||||
ALTER TABLE finding_case_review_runs ADD COLUMN IF NOT EXISTS reviewed_ids JSONB NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE finding_case_review_runs ADD COLUMN IF NOT EXISTS conclusion JSONB NOT NULL DEFAULT '{}';
|
||||
CREATE INDEX IF NOT EXISTS idx_finding_case_reviews_active_task ON finding_case_review_runs(task_id) WHERE state IN ('queued','running');
|
||||
+10
-2
@@ -1,6 +1,9 @@
|
||||
package db
|
||||
|
||||
import "database/sql"
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// Settings is a tiny key-value store for global app config the UI toggles at
|
||||
// runtime (e.g. traffic_capture). Missing keys fall back to caller defaults.
|
||||
@@ -19,7 +22,12 @@ func (d *DB) GetSetting(key string) (value string, ok bool, err error) {
|
||||
|
||||
// SetSetting upserts a setting value.
|
||||
func (d *DB) SetSetting(key, value string) error {
|
||||
_, err := d.Exec(`
|
||||
return d.SetSettingContext(context.Background(), key, value)
|
||||
}
|
||||
|
||||
// SetSettingContext upserts a setting and respects cancellation or a deadline.
|
||||
func (d *DB) SetSettingContext(ctx context.Context, key, value string) error {
|
||||
_, err := d.ExecContext(ctx, `
|
||||
INSERT INTO settings(key, value) VALUES ($1, $2)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, key, value)
|
||||
return err
|
||||
|
||||
+11
-1
@@ -55,6 +55,9 @@ func FindingsMarkdown(fs []*db.DBFinding, generatedAt time.Time) string {
|
||||
} {
|
||||
fmt.Fprintf(&b, "| %s | %d |\n", s.label, counts[s.key])
|
||||
}
|
||||
if counts["unassessed"] > 0 {
|
||||
fmt.Fprintf(&b, "| 待评估 | %d |\n", counts["unassessed"])
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
if len(items) == 0 {
|
||||
@@ -127,6 +130,9 @@ func FindingFilename(f *db.DBFinding) string {
|
||||
sev := nz(f.Severity, "info")
|
||||
title := findingTitle(f)
|
||||
name := fmt.Sprintf("%s_%s_#%d", sev, title, f.ID)
|
||||
if f.ExportCaseID > 0 {
|
||||
name = fmt.Sprintf("case_%d_%s_%s", f.ExportCaseID, sev, title)
|
||||
}
|
||||
name = unsafeFilenameChars.ReplaceAllString(name, "_")
|
||||
name = strings.Trim(name, "._")
|
||||
if name == "" {
|
||||
@@ -151,8 +157,12 @@ func FindingsCSV(fs []*db.DBFinding) []byte {
|
||||
w := csv.NewWriter(&buf)
|
||||
_ = w.Write([]string{"ID", "名称", "类别", "严重等级", "状态", "所属任务", "发现时间", "概述", "流量证据数量", "流量证据ID"})
|
||||
for _, f := range items {
|
||||
id := fmt.Sprintf("%d", f.ID)
|
||||
if f.ExportCaseID > 0 {
|
||||
id = fmt.Sprintf("case:%d", f.ExportCaseID)
|
||||
}
|
||||
_ = w.Write([]string{
|
||||
fmt.Sprintf("%d", f.ID),
|
||||
id,
|
||||
findingTitle(f),
|
||||
f.VulnClass,
|
||||
nz(f.Severity, "info"),
|
||||
|
||||
+2
-2
@@ -116,11 +116,11 @@ func extractToken(r *http.Request) string {
|
||||
}
|
||||
|
||||
// requireAuth wraps h with JWT validation.
|
||||
// /api/auth/* and /api/health are exempt.
|
||||
// /api/auth/*, /api/health and the Basic Auth gate check are exempt.
|
||||
func (s *Server) requireAuth(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p := r.URL.Path
|
||||
if strings.HasPrefix(p, "/api/auth/") || p == "/api/health" {
|
||||
if strings.HasPrefix(p, "/api/auth/") || p == "/api/health" || p == "/api/basic-auth/check" {
|
||||
h.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const basicAuthKey = "auth.http_basic"
|
||||
const basicAuthCookie = "artex_basic_auth"
|
||||
const basicAuthTTL = 12 * time.Hour
|
||||
const basicAuthSaveTimeout = 10 * time.Second
|
||||
|
||||
type basicAuthConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
Revision string `json:"revision"`
|
||||
}
|
||||
|
||||
type basicAuthSettingsStore interface {
|
||||
GetSetting(string) (string, bool, error)
|
||||
SetSettingContext(context.Context, string, string) error
|
||||
}
|
||||
|
||||
// Keep one atomic configuration in the DB and publish it only after a successful
|
||||
// save. Requests use the in-memory snapshot, avoiding a DB read for every asset.
|
||||
type basicAuthGate struct {
|
||||
saveMu sync.Mutex // serializes persistence without blocking request snapshots
|
||||
mu sync.RWMutex
|
||||
cfg basicAuthConfig
|
||||
store basicAuthSettingsStore
|
||||
}
|
||||
|
||||
func (g *basicAuthGate) load(store basicAuthSettingsStore) error {
|
||||
g.saveMu.Lock()
|
||||
defer g.saveMu.Unlock()
|
||||
raw, ok, err := store.GetSetting(basicAuthKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var cfg basicAuthConfig
|
||||
if ok {
|
||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||
return fmt.Errorf("invalid HTTP Basic Auth configuration")
|
||||
}
|
||||
}
|
||||
if cfg.Enabled {
|
||||
if !validBasicUsername(cfg.Username) || cfg.Revision == "" {
|
||||
return fmt.Errorf("incomplete HTTP Basic Auth configuration")
|
||||
}
|
||||
if _, err := bcrypt.Cost([]byte(cfg.PasswordHash)); err != nil {
|
||||
return fmt.Errorf("invalid HTTP Basic Auth password hash")
|
||||
}
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.cfg, g.store = cfg, store
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *basicAuthGate) snapshot() basicAuthConfig {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
return g.cfg
|
||||
}
|
||||
|
||||
func validBasicUsername(username string) bool {
|
||||
return strings.TrimSpace(username) != "" && len(username) <= 128 &&
|
||||
!strings.Contains(username, ":") && strings.IndexFunc(username, unicode.IsControl) == -1
|
||||
}
|
||||
|
||||
// A separate signing key prevents a gate cookie from being used as an admin JWT.
|
||||
func (s *Server) basicAuthSigningKey() []byte {
|
||||
m := hmac.New(sha256.New, s.jwtKey)
|
||||
m.Write([]byte("artex/http-basic-auth/session/v1"))
|
||||
return m.Sum(nil)
|
||||
}
|
||||
|
||||
func (s *Server) validBasicAuthCookie(r *http.Request, cfg basicAuthConfig) bool {
|
||||
cookie, err := r.Cookie(basicAuthCookie)
|
||||
if err != nil || cfg.Revision == "" {
|
||||
return false
|
||||
}
|
||||
claims := &jwt.RegisteredClaims{}
|
||||
_, err = jwt.ParseWithClaims(cookie.Value, claims, func(*jwt.Token) (any, error) {
|
||||
return s.basicAuthSigningKey(), nil
|
||||
}, jwt.WithValidMethods([]string{"HS256"}), jwt.WithExpirationRequired(),
|
||||
jwt.WithAudience("artex-basic-auth"), jwt.WithSubject(cfg.Revision))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (s *Server) setBasicAuthCookie(w http.ResponseWriter, r *http.Request, cfg basicAuthConfig) error {
|
||||
cookie := &http.Cookie{
|
||||
Name: basicAuthCookie, Path: "/", HttpOnly: true,
|
||||
Secure: r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https"),
|
||||
SameSite: http.SameSiteStrictMode, MaxAge: -1,
|
||||
}
|
||||
if cfg.Enabled {
|
||||
now := time.Now()
|
||||
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
|
||||
Subject: cfg.Revision, Audience: jwt.ClaimStrings{"artex-basic-auth"},
|
||||
IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(basicAuthTTL)),
|
||||
}).SignedString(s.basicAuthSigningKey())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cookie.Value, cookie.MaxAge = token, int(basicAuthTTL.Seconds())
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) requireBasicAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := s.basicAuth.snapshot()
|
||||
// Health probes stay public; CORS preflight contains no credentials and
|
||||
// the API CORS handler returns 204 without invoking any business handler.
|
||||
if !cfg.Enabled || (r.URL.Path == "/api/health" && (r.Method == "GET" || r.Method == "HEAD")) ||
|
||||
(r.Method == "OPTIONS" && strings.HasPrefix(r.URL.Path, "/api/")) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if s.validBasicAuthCookie(r, cfg) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok || len(password) > maxPasswordBytes || username != cfg.Username ||
|
||||
bcrypt.CompareHashAndPassword([]byte(cfg.PasswordHash), []byte(password)) != nil {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="ARTEX", charset="UTF-8"`)
|
||||
writeErr(w, http.StatusUnauthorized, "请先完成 HTTP Basic Auth 验证")
|
||||
return
|
||||
}
|
||||
// Browser API requests already use Authorization: Bearer. A separate
|
||||
// HttpOnly cookie keeps the gate satisfied without replacing that header.
|
||||
if err := s.setBasicAuthCookie(w, r, cfg); err != nil {
|
||||
writeErr(w, 500, "认证会话生成失败")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func basicAuthPublicSettings(cfg basicAuthConfig) map[string]any {
|
||||
return map[string]any{"enabled": cfg.Enabled, "username": cfg.Username, "password_set": cfg.PasswordHash != ""}
|
||||
}
|
||||
|
||||
func (s *Server) getBasicAuthSettings(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, 200, basicAuthPublicSettings(s.basicAuth.snapshot()))
|
||||
}
|
||||
|
||||
func (s *Server) saveBasicAuthSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Username *string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
writeErr(w, 400, "请求格式错误")
|
||||
return
|
||||
}
|
||||
g := &s.basicAuth
|
||||
g.saveMu.Lock()
|
||||
defer g.saveMu.Unlock()
|
||||
g.mu.RLock()
|
||||
cfg, store := g.cfg, g.store
|
||||
g.mu.RUnlock()
|
||||
if store == nil {
|
||||
writeErr(w, 503, errDataSourceUnavailable)
|
||||
return
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
cfg.Enabled = *req.Enabled
|
||||
}
|
||||
if req.Username != nil {
|
||||
cfg.Username = strings.TrimSpace(*req.Username)
|
||||
}
|
||||
if (cfg.Enabled || cfg.Username != "") && !validBasicUsername(cfg.Username) {
|
||||
writeErr(w, 400, "Basic Auth 用户名不能为空、超过 128 字节或包含冒号及控制字符")
|
||||
return
|
||||
}
|
||||
if req.Password != "" {
|
||||
if msg := validatePassword(req.Password); msg != "" {
|
||||
writeErr(w, 400, msg)
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
writeErr(w, 500, "密码加密失败")
|
||||
return
|
||||
}
|
||||
cfg.PasswordHash = string(hash)
|
||||
}
|
||||
if cfg.Enabled && cfg.PasswordHash == "" {
|
||||
writeErr(w, 400, "请先设置 Basic Auth 密码")
|
||||
return
|
||||
}
|
||||
// Every save revokes previous gate cookies, including disable/re-enable.
|
||||
cfg.Revision = uuid.NewString()
|
||||
data, err := json.Marshal(cfg)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), basicAuthSaveTimeout)
|
||||
defer cancel()
|
||||
if err != nil || store.SetSettingContext(ctx, basicAuthKey, string(data)) != nil {
|
||||
writeErr(w, 503, "Basic Auth 配置保存失败,请重试")
|
||||
return
|
||||
}
|
||||
// Hashing, persistence and response output never hold the request mutex.
|
||||
// Publish only committed settings; requests keep using the previous snapshot
|
||||
// until this short critical section completes.
|
||||
g.mu.Lock()
|
||||
g.cfg = cfg
|
||||
g.mu.Unlock()
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
// Keep the administrator who just saved the settings in their session.
|
||||
if err := s.setBasicAuthCookie(w, r, cfg); err != nil {
|
||||
writeErr(w, 500, "认证会话生成失败")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, basicAuthPublicSettings(cfg))
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type basicAuthMemoryStore struct {
|
||||
raw string
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *basicAuthMemoryStore) GetSetting(string) (string, bool, error) {
|
||||
return m.raw, m.raw != "", m.err
|
||||
}
|
||||
func (m *basicAuthMemoryStore) SetSettingContext(ctx context.Context, _ string, value string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.err != nil {
|
||||
return m.err
|
||||
}
|
||||
m.raw = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func basicAuthTestServer(t *testing.T) (*Server, *basicAuthMemoryStore) {
|
||||
t.Helper()
|
||||
store := &basicAuthMemoryStore{}
|
||||
s := &Server{jwtKey: []byte("test-only-signing-key-32-characters")}
|
||||
if err := s.basicAuth.load(store); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s, store
|
||||
}
|
||||
|
||||
func saveBasicAuthForTest(s *Server, body string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
s.saveBasicAuthSettings(w, httptest.NewRequest("PUT", "/api/settings/basic-auth", strings.NewReader(body)))
|
||||
return w
|
||||
}
|
||||
|
||||
type blockedBasicAuthStore struct {
|
||||
basicAuthSettingsStore
|
||||
started chan basicAuthConfig
|
||||
release chan struct{}
|
||||
contexts chan context.Context
|
||||
}
|
||||
|
||||
func (b *blockedBasicAuthStore) SetSettingContext(ctx context.Context, key, value string) error {
|
||||
var cfg basicAuthConfig
|
||||
if err := json.Unmarshal([]byte(value), &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
b.started <- cfg
|
||||
if b.contexts != nil {
|
||||
b.contexts <- ctx
|
||||
}
|
||||
select {
|
||||
case <-b.release:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
return b.basicAuthSettingsStore.SetSettingContext(ctx, key, value)
|
||||
}
|
||||
|
||||
func TestBasicAuthBlockedSaveDoesNotBlockRequests(t *testing.T) {
|
||||
for _, enabled := range []bool{false, true} {
|
||||
name := "disabled"
|
||||
if enabled {
|
||||
name = "enabled"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
s, store := basicAuthTestServer(t)
|
||||
body := `{"enabled":false,"username":"entry","password":"original-pass"}`
|
||||
if enabled {
|
||||
body = `{"enabled":true,"username":"entry","password":"original-pass"}`
|
||||
}
|
||||
initial := saveBasicAuthForTest(s, body)
|
||||
if initial.Code != http.StatusOK {
|
||||
t.Fatal(initial.Body.String())
|
||||
}
|
||||
before := s.basicAuth.snapshot()
|
||||
blocked := &blockedBasicAuthStore{basicAuthSettingsStore: store, started: make(chan basicAuthConfig, 2), release: make(chan struct{})}
|
||||
s.basicAuth.mu.Lock()
|
||||
s.basicAuth.store = blocked
|
||||
s.basicAuth.mu.Unlock()
|
||||
var releaseOnce sync.Once
|
||||
release := func() { releaseOnce.Do(func() { close(blocked.release) }) }
|
||||
t.Cleanup(release)
|
||||
first := make(chan *httptest.ResponseRecorder, 1)
|
||||
go func() { first <- saveBasicAuthForTest(s, `{"username":"new-entry"}`) }()
|
||||
select {
|
||||
case <-blocked.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("save did not reach database")
|
||||
}
|
||||
// Exercise every kind of request sharing the gate while persistence waits.
|
||||
reads := make(chan error, 1)
|
||||
go func() {
|
||||
if s.basicAuth.snapshot() != before {
|
||||
reads <- errors.New("uncommitted settings became visible")
|
||||
return
|
||||
}
|
||||
h := s.requireBasicAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }))
|
||||
for _, path := range []string{"/", "/_next/static/app.js", "/api/tasks", "/api/health"} {
|
||||
r := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
if enabled && path != "/api/health" {
|
||||
r.AddCookie(initial.Result().Cookies()[0])
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != http.StatusNoContent {
|
||||
reads <- errors.New("request rejected while old configuration is still active")
|
||||
return
|
||||
}
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/health", nil))
|
||||
if w.Code != http.StatusOK {
|
||||
reads <- errors.New("health handler unavailable during save")
|
||||
return
|
||||
}
|
||||
reads <- nil
|
||||
}()
|
||||
select {
|
||||
case err := <-reads:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("database save blocked configuration reads and HTTP requests")
|
||||
}
|
||||
// A concurrent partial update must wait and preserve the first update.
|
||||
second := make(chan *httptest.ResponseRecorder, 1)
|
||||
go func() { second <- saveBasicAuthForTest(s, `{"enabled":true}`) }()
|
||||
select {
|
||||
case <-blocked.started:
|
||||
t.Fatal("concurrent saves reached persistence together")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
release()
|
||||
for _, done := range []chan *httptest.ResponseRecorder{first, second} {
|
||||
select {
|
||||
case response := <-done:
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatal(response.Body.String())
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("save did not finish after database release")
|
||||
}
|
||||
}
|
||||
cfg := s.basicAuth.snapshot()
|
||||
if cfg.Username != "new-entry" || !cfg.Enabled || cfg.PasswordHash != before.PasswordHash {
|
||||
t.Fatal("concurrent partial save lost a committed update")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthBlockedSaveRespectsRequestCancellation(t *testing.T) {
|
||||
s, store := basicAuthTestServer(t)
|
||||
before := s.basicAuth.snapshot()
|
||||
blocked := &blockedBasicAuthStore{basicAuthSettingsStore: store, started: make(chan basicAuthConfig, 1), release: make(chan struct{}), contexts: make(chan context.Context, 1)}
|
||||
s.basicAuth.mu.Lock()
|
||||
s.basicAuth.store = blocked
|
||||
s.basicAuth.mu.Unlock()
|
||||
t.Cleanup(func() { close(blocked.release) })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
r := httptest.NewRequest(http.MethodPut, "/api/settings/basic-auth", strings.NewReader(`{"username":"new-entry"}`)).WithContext(ctx)
|
||||
done := make(chan *httptest.ResponseRecorder, 1)
|
||||
go func() {
|
||||
w := httptest.NewRecorder()
|
||||
s.saveBasicAuthSettings(w, r)
|
||||
done <- w
|
||||
}()
|
||||
select {
|
||||
case <-blocked.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("save did not reach database")
|
||||
}
|
||||
writeContext := <-blocked.contexts
|
||||
deadline, bounded := writeContext.Deadline()
|
||||
if !bounded || time.Until(deadline) > 10*time.Second {
|
||||
t.Fatal("database write has no bounded deadline")
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case w := <-done:
|
||||
if w.Code != http.StatusServiceUnavailable || len(w.Result().Cookies()) != 0 {
|
||||
t.Fatalf("canceled save must fail without issuing a cookie: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("database save ignored request cancellation")
|
||||
}
|
||||
if s.basicAuth.snapshot() != before || store.raw != "" {
|
||||
t.Fatal("canceled save changed the committed settings")
|
||||
}
|
||||
// A canceled save must also release the save mutex for a later update.
|
||||
s.basicAuth.mu.Lock()
|
||||
s.basicAuth.store = store
|
||||
s.basicAuth.mu.Unlock()
|
||||
w := saveBasicAuthForTest(s, `{"username":"later-entry"}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("later save failed: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthGate(t *testing.T) {
|
||||
s, _ := basicAuthTestServer(t)
|
||||
h := s.requireBasicAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) }))
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest("GET", "/login", nil))
|
||||
if w.Code != 204 {
|
||||
t.Fatal("must default to disabled")
|
||||
}
|
||||
w = saveBasicAuthForTest(s, `{"enabled":true,"username":"entry","password":"密码:test-pass"}`)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("enable: %s", w.Body.String())
|
||||
}
|
||||
cookie := w.Result().Cookies()[0]
|
||||
if !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode || cookie.Path != "/" {
|
||||
t.Fatalf("unsafe cookie: %+v", cookie)
|
||||
}
|
||||
if verifyJWT(cookie.Value, s.jwtKey) {
|
||||
t.Fatal("gate cookie accepted as admin JWT")
|
||||
}
|
||||
adminJWT, _ := signJWT(s.jwtKey)
|
||||
for _, tc := range []struct {
|
||||
name, path, user, pass, bearer string
|
||||
cookie *http.Cookie
|
||||
want int
|
||||
}{
|
||||
{name: "page challenge", path: "/", want: 401},
|
||||
{name: "login challenge", path: "/login", want: 401},
|
||||
{name: "asset challenge", path: "/_next/static/app.js", want: 401},
|
||||
{name: "auth API challenge", path: "/api/auth/status", want: 401},
|
||||
{name: "check challenge", path: "/api/basic-auth/check", want: 401},
|
||||
{name: "health exempt", path: "/api/health", want: 204},
|
||||
{name: "health prefix not exempt", path: "/api/health/anything", want: 401},
|
||||
{name: "wrong password", path: "/login", user: "entry", pass: "wrong", want: 401},
|
||||
{name: "wrong user", path: "/login", user: "wrong", pass: "密码:test-pass", want: 401},
|
||||
{name: "correct Unicode credentials", path: "/login", user: "entry", pass: "密码:test-pass", want: 204},
|
||||
{name: "gate cookie and bearer", path: "/api/tasks", cookie: cookie, bearer: adminJWT, want: 204},
|
||||
{name: "admin JWT cannot bypass gate", path: "/api/tasks", bearer: adminJWT, want: 401},
|
||||
{name: "tampered cookie", path: "/login", cookie: &http.Cookie{Name: basicAuthCookie, Value: cookie.Value + "x"}, want: 401},
|
||||
{name: "admin JWT is not gate cookie", path: "/login", cookie: &http.Cookie{Name: basicAuthCookie, Value: adminJWT}, want: 401},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", tc.path, nil)
|
||||
if tc.user != "" {
|
||||
r.SetBasicAuth(tc.user, tc.pass)
|
||||
}
|
||||
if tc.cookie != nil {
|
||||
r.AddCookie(tc.cookie)
|
||||
}
|
||||
if tc.bearer != "" {
|
||||
r.Header.Set("Authorization", "Bearer "+tc.bearer)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != tc.want {
|
||||
t.Fatalf("status=%d want %d: %s", w.Code, tc.want, w.Body.String())
|
||||
}
|
||||
if w.Code == 401 && w.Header().Get("WWW-Authenticate") != `Basic realm="ARTEX", charset="UTF-8"` {
|
||||
t.Fatal("missing browser challenge")
|
||||
}
|
||||
})
|
||||
}
|
||||
// Passing the outer gate must not grant access to the inner admin API.
|
||||
r := httptest.NewRequest("GET", "/api/settings/basic-auth", nil)
|
||||
r.AddCookie(cookie)
|
||||
w = httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, r)
|
||||
if w.Code != 401 {
|
||||
t.Fatal("gate cookie bypassed admin authentication")
|
||||
}
|
||||
r.Header.Set("Authorization", "Bearer "+adminJWT)
|
||||
w = httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, r)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("gate + JWT rejected: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthSettingsPersistenceAndRevocation(t *testing.T) {
|
||||
s, store := basicAuthTestServer(t)
|
||||
w := saveBasicAuthForTest(s, `{"enabled":true,"username":"entry","password":"test-pass"}`)
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
oldCookie := w.Result().Cookies()[0]
|
||||
before := s.basicAuth.snapshot()
|
||||
if strings.Contains(w.Body.String(), "password_hash") || strings.Contains(store.raw, "test-pass") {
|
||||
t.Fatal("password leaked")
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(before.PasswordHash), []byte("test-pass")) != nil {
|
||||
t.Fatal("password not hashed")
|
||||
}
|
||||
var restarted basicAuthGate
|
||||
if err := restarted.load(store); err != nil || restarted.snapshot() != before {
|
||||
t.Fatalf("restart lost config: %v", err)
|
||||
}
|
||||
w = saveBasicAuthForTest(s, `{"enabled":false,"password":""}`)
|
||||
if w.Code != 200 || w.Result().Cookies()[0].MaxAge != -1 {
|
||||
t.Fatal("disable failed")
|
||||
}
|
||||
w = saveBasicAuthForTest(s, `{"enabled":true,"password":""}`)
|
||||
if w.Code != 200 || s.basicAuth.snapshot().PasswordHash != before.PasswordHash {
|
||||
t.Fatal("blank password must preserve hash")
|
||||
}
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.AddCookie(oldCookie)
|
||||
if s.validBasicAuthCookie(r, s.basicAuth.snapshot()) {
|
||||
t.Fatal("old cookie survived disable/re-enable")
|
||||
}
|
||||
cookie := w.Result().Cookies()[0]
|
||||
w = saveBasicAuthForTest(s, `{"username":"new-entry","password":"new-pass-123"}`)
|
||||
r = httptest.NewRequest("GET", "/", nil)
|
||||
r.AddCookie(cookie)
|
||||
if w.Code != 200 || s.validBasicAuthCookie(r, s.basicAuth.snapshot()) {
|
||||
t.Fatal("credential change did not revoke cookie")
|
||||
}
|
||||
before = s.basicAuth.snapshot()
|
||||
store.err = errors.New("DB unavailable")
|
||||
w = saveBasicAuthForTest(s, `{"enabled":false}`)
|
||||
if w.Code != 503 || s.basicAuth.snapshot() != before {
|
||||
t.Fatal("failed save changed live config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthValidationAndLoadFailures(t *testing.T) {
|
||||
s, _ := basicAuthTestServer(t)
|
||||
for _, body := range []string{
|
||||
`{"enabled":true}`, `{"enabled":true,"username":"entry"}`,
|
||||
`{"username":"bad:name"}`, `{"username":"bad\nname"}`, `{"password":"short"}`,
|
||||
`{"password":"` + strings.Repeat("a", 73) + `"}`, `not-json`,
|
||||
} {
|
||||
if w := saveBasicAuthForTest(s, body); w.Code != 400 {
|
||||
t.Fatalf("body=%s status=%d", body, w.Code)
|
||||
}
|
||||
}
|
||||
for _, store := range []*basicAuthMemoryStore{
|
||||
{err: errors.New("unavailable")}, {raw: `not-json`}, {raw: `{"enabled":true}`},
|
||||
{raw: `{"enabled":true,"username":"entry","revision":"v1","password_hash":"invalid"}`},
|
||||
} {
|
||||
var gate basicAuthGate
|
||||
if err := gate.load(store); err == nil {
|
||||
t.Fatal("invalid persisted config did not fail closed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthExpiredCookieAndSecureFlag(t *testing.T) {
|
||||
s, _ := basicAuthTestServer(t)
|
||||
w := saveBasicAuthForTest(s, `{"enabled":true,"username":"entry","password":"test-pass"}`)
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
cfg := s.basicAuth.snapshot()
|
||||
for _, expires := range []*jwt.NumericDate{nil, jwt.NewNumericDate(time.Now().Add(-time.Minute))} {
|
||||
token, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
|
||||
Subject: cfg.Revision, Audience: jwt.ClaimStrings{"artex-basic-auth"}, ExpiresAt: expires,
|
||||
}).SignedString(s.basicAuthSigningKey())
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.AddCookie(&http.Cookie{Name: basicAuthCookie, Value: token})
|
||||
if s.validBasicAuthCookie(r, cfg) {
|
||||
t.Fatal("expired or unbounded cookie accepted")
|
||||
}
|
||||
}
|
||||
r := httptest.NewRequest("GET", "https://example.test/login", nil)
|
||||
w = httptest.NewRecorder()
|
||||
if err := s.setBasicAuthCookie(w, r, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !w.Result().Cookies()[0].Secure {
|
||||
t.Fatal("HTTPS cookie is not Secure")
|
||||
}
|
||||
var public map[string]any
|
||||
w = httptest.NewRecorder()
|
||||
s.getBasicAuthSettings(w, r)
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &public); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(public) != 3 || public["password_set"] != true {
|
||||
t.Fatalf("bad public settings: %v", public)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthCORS(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
origin string
|
||||
credentials bool
|
||||
}{
|
||||
{"http://127.0.0.1:3000", true}, {"https://evil.test", false}, {"http://127.0.0.1.evil.test:3000", false},
|
||||
} {
|
||||
r := httptest.NewRequest("OPTIONS", "http://127.0.0.1:8787/api/settings/basic-auth", nil)
|
||||
r.Header.Set("Origin", tc.origin)
|
||||
w := httptest.NewRecorder()
|
||||
cors(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("preflight reached handler") })).ServeHTTP(w, r)
|
||||
if w.Code != 204 || (w.Header().Get("Access-Control-Allow-Credentials") == "true") != tc.credentials {
|
||||
t.Fatalf("unexpected CORS for %s: %v", tc.origin, w.Header())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthPostgresLoginFlow(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable: %v", err)
|
||||
}
|
||||
defer m.Close()
|
||||
for _, key := range []string{basicAuthKey, authPassKey} {
|
||||
old, exists, err := m.pg.GetSetting(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func(key, old string, exists bool) {
|
||||
if exists {
|
||||
_ = m.pg.SetSetting(key, old)
|
||||
} else {
|
||||
_, _ = m.pg.Exec("DELETE FROM settings WHERE key=$1", key)
|
||||
}
|
||||
}(key, old, exists)
|
||||
}
|
||||
if err := m.pg.SetSetting(basicAuthKey, `{"enabled":false}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("admin-password"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.pg.SetSetting(authPassKey, string(hash)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &Server{m: m, jwtKey: []byte("test-only-signing-key-32-characters")}
|
||||
if err := s.basicAuth.load(m.pg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminJWT, err := signJWT(s.jwtKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `{"enabled":true,"username":"entry","password":"entry-password"}`
|
||||
r := httptest.NewRequest("PUT", "/api/settings/basic-auth", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, r)
|
||||
if w.Code != 401 || s.basicAuth.snapshot().Enabled {
|
||||
t.Fatal("unauthenticated settings write succeeded")
|
||||
}
|
||||
r = httptest.NewRequest("PUT", "/api/settings/basic-auth", strings.NewReader(body))
|
||||
r.Header.Set("Authorization", "Bearer "+adminJWT)
|
||||
w = httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, r)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("save: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
restarted := &Server{m: m, jwtKey: s.jwtKey}
|
||||
if err := restarted.basicAuth.load(m.pg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loginBody := `{"username":"ARTEX","password":"admin-password"}`
|
||||
r = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(loginBody))
|
||||
w = httptest.NewRecorder()
|
||||
restarted.Handler().ServeHTTP(w, r)
|
||||
if w.Code != 401 || w.Header().Get("WWW-Authenticate") == "" {
|
||||
t.Fatal("login was not protected after restart")
|
||||
}
|
||||
r = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(loginBody))
|
||||
r.SetBasicAuth("entry", "entry-password")
|
||||
w = httptest.NewRecorder()
|
||||
restarted.Handler().ServeHTTP(w, r)
|
||||
var login struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &login); err != nil || w.Code != 200 || !verifyJWT(login.Token, s.jwtKey) {
|
||||
t.Fatalf("Basic Auth + existing login failed: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
cookie := w.Result().Cookies()[0]
|
||||
r = httptest.NewRequest("PUT", "/api/settings/basic-auth", strings.NewReader(`{"enabled":false}`))
|
||||
r.AddCookie(cookie)
|
||||
r.Header.Set("Authorization", "Bearer "+login.Token)
|
||||
w = httptest.NewRecorder()
|
||||
restarted.Handler().ServeHTTP(w, r)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("disable with gate cookie and JWT: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
+53
-6
@@ -503,7 +503,35 @@ func (s *Server) runConversationTurn(ctx context.Context, cancel context.CancelC
|
||||
}()
|
||||
// Only the first turn executes a historical retest. Follow-up conversation
|
||||
// turns may explain the sealed result; the result tool refuses to overwrite it.
|
||||
finishStatus, finishReason := "failed", "复测未能启动"
|
||||
finishStatus, finishReason := "failed", "Agent 未能启动"
|
||||
if c.AgentKey == "reporter" {
|
||||
manualReview, err := s.isHistoricalFindingReview(c.ID)
|
||||
if err != nil {
|
||||
finishReason = err.Error()
|
||||
_, _ = s.m.pg.Exec(`UPDATE finding_case_review_runs SET state='failed',error=$2 WHERE conversation_id=$1`, c.ID, finishReason)
|
||||
return
|
||||
}
|
||||
if manualReview {
|
||||
ctx = agent.WithFindingCaseReview(ctx)
|
||||
}
|
||||
defer func() {
|
||||
state := "done"
|
||||
reason := ""
|
||||
if finishStatus != "completed" || ctx.Err() != nil {
|
||||
state = "failed"
|
||||
reason = finishReason
|
||||
if ctx.Err() != nil {
|
||||
reason = "整理已停止或服务关闭"
|
||||
}
|
||||
}
|
||||
if state == "done" && manualReview {
|
||||
if err := s.validateFindingReviewCompletion(c.ID); err != nil {
|
||||
state, reason = "failed", err.Error()
|
||||
}
|
||||
}
|
||||
_, _ = s.m.pg.Exec(`UPDATE finding_case_review_runs SET state=$2,error=$3 WHERE conversation_id=$1 AND state='running'`, c.ID, state, reason)
|
||||
}()
|
||||
}
|
||||
if c.AgentKey == db.FindingRetestAgentKey {
|
||||
// Read without the run cancellation so an immediate stop still seals pending.
|
||||
r, err := s.m.pg.FindingRetestForConversation(context.Background(), c.ID)
|
||||
@@ -668,11 +696,17 @@ func (s *Server) runAndPump(agentKey string, item triggeredRun) {
|
||||
// Caller holds queueMu.
|
||||
func (s *Server) nextTriggerRun(agentKey string, cfg triggerBehavior) triggeredRun {
|
||||
q := s.triggerQ[agentKey]
|
||||
if cfg.runMode == "parallel" || cfg.mergeMode == "none" {
|
||||
if q[0].conversationID > 0 || cfg.runMode == "parallel" || cfg.mergeMode == "none" {
|
||||
s.triggerQ[agentKey] = q[1:]
|
||||
return q[0]
|
||||
}
|
||||
if cfg.mergeMode == "all" {
|
||||
hasManual := false
|
||||
for _, item := range q {
|
||||
if item.conversationID > 0 {
|
||||
hasManual = true
|
||||
}
|
||||
}
|
||||
if cfg.mergeMode == "all" && !hasManual {
|
||||
s.triggerQ[agentKey] = q[:0:0]
|
||||
return mergeAllRuns(q)
|
||||
}
|
||||
@@ -685,7 +719,7 @@ func (s *Server) nextTriggerRun(agentKey string, cfg triggerBehavior) triggeredR
|
||||
group := []triggeredRun{head}
|
||||
rest := q[:0:0] // keep non-matching items in order
|
||||
for _, it := range q[1:] {
|
||||
if it.mergeable && it.taskID == head.taskID {
|
||||
if it.conversationID == 0 && it.mergeable && it.taskID == head.taskID {
|
||||
group = append(group, it)
|
||||
} else {
|
||||
rest = append(rest, it)
|
||||
@@ -798,12 +832,25 @@ func mergeAllRuns(items []triggeredRun) triggeredRun {
|
||||
func (s *Server) runTriggeredRun(item triggeredRun) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
if item.conversationID > 0 {
|
||||
_, _ = s.m.pg.Exec(`UPDATE finding_case_review_runs SET state='failed',error=$2 WHERE conversation_id=$1`, item.conversationID, fmt.Sprint(r))
|
||||
}
|
||||
log.Printf("[trigger] run for %s panicked: %v", item.agentKey, r)
|
||||
}
|
||||
}()
|
||||
pg := s.m.pg
|
||||
c, err := pg.CreateConversation(item.agentKey, firstLine(item.title, 60), nil)
|
||||
if err != nil {
|
||||
var c *db.Conversation
|
||||
var err error
|
||||
if item.conversationID > 0 {
|
||||
c, err = pg.GetConversation(item.conversationID)
|
||||
_, _ = pg.Exec(`UPDATE finding_case_review_runs SET state='running' WHERE conversation_id=$1`, item.conversationID)
|
||||
} else {
|
||||
c, err = pg.CreateConversation(item.agentKey, firstLine(item.title, 60), nil)
|
||||
}
|
||||
if err != nil || c == nil {
|
||||
if item.conversationID > 0 {
|
||||
_, _ = pg.Exec(`UPDATE finding_case_review_runs SET state='failed',error='整理会话不可用' WHERE conversation_id=$1`, item.conversationID)
|
||||
}
|
||||
log.Printf("[trigger] create conversation for %s failed: %v", item.agentKey, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ func rawString(raw json.RawMessage) string {
|
||||
|
||||
// ---- Task (frontend "Task") ---- created_at as RFC3339, plus a derived status.
|
||||
type TaskDTO struct {
|
||||
DistinctFindings *db.FindingDistinctStats `json:"distinct_findings,omitempty"`
|
||||
|
||||
ID string `json:"id"`
|
||||
ExplorationID int64 `json:"exploration_id"`
|
||||
Name string `json:"name"` // 可选任务名称;空=未命名
|
||||
@@ -303,6 +305,9 @@ func coverageAssetRefDTO(ref db.AssetRef) CoverageAssetRefDTO {
|
||||
// ---- Finding (frontend "Finding") ----
|
||||
|
||||
type FindingDTO struct {
|
||||
CaseID string `json:"case_id,omitempty"`
|
||||
MemberIDs []int64 `json:"member_ids,omitempty"`
|
||||
|
||||
TrafficCount int `json:"traffic_count"`
|
||||
EvidenceVersion int64 `json:"evidence_version"`
|
||||
ReportEvidenceVersion int64 `json:"report_evidence_version"`
|
||||
@@ -457,6 +462,12 @@ func findingFromDB(f *db.DBFinding, assets map[int64]*db.Asset) FindingDTO {
|
||||
Report: f.Report,
|
||||
TS: rfc3339(f.CreatedAt),
|
||||
}
|
||||
if f.ExportCaseID > 0 {
|
||||
d.ID = "case:" + i64s(f.ExportCaseID)
|
||||
d.FindingID = ""
|
||||
d.CaseID = i64s(f.ExportCaseID)
|
||||
d.MemberIDs = f.ExportMemberIDs
|
||||
}
|
||||
d.Assets = findingAssetDTOs(f.AssetIDs, assets)
|
||||
if f.TaskID != nil {
|
||||
d.TaskID = i64s(*f.TaskID)
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
type findingReviewDecision struct {
|
||||
FindingID int64 `json:"finding_id"`
|
||||
Verdict string `json:"verdict"`
|
||||
Reason string `json:"reason"`
|
||||
RootCauseEvidence string `json:"root_cause_evidence"`
|
||||
}
|
||||
type findingReviewConclusion struct {
|
||||
Summary string `json:"summary"`
|
||||
Decisions []findingReviewDecision `json:"decisions"`
|
||||
}
|
||||
|
||||
func (s *Server) isHistoricalFindingReview(conv int64) (bool, error) {
|
||||
var exists bool
|
||||
err := s.m.pg.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_review_runs WHERE conversation_id=$1)`, conv).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (s *Server) toolCompleteFindingCaseReview() actool.CoreTool {
|
||||
decision := objSchema(map[string]any{
|
||||
"finding_id": idParam("所选独立漏洞记录 ID"),
|
||||
"verdict": map[string]any{"type": "string", "enum": []string{"independent", "merged", "suggested"}},
|
||||
"reason": strParam("判断理由,接口或读写影响不同不能单独证明不同根因"),
|
||||
"root_cause_evidence": strParam("原始报告/证据中支持实际共同或不同根因的具体材料;不确定则说明缺失证据及已保存建议")}, "finding_id", "verdict", "reason", "root_cause_evidence")
|
||||
return wrTool("complete_finding_case_review", "历史整理结束前提交每条所选记录的根因判断并验收:必须已读取全部记录,疑似必须已有建议,归并必须已有完整最新统一报告。验收失败不能宣布完成。", objSchema(map[string]any{"summary": strParam("整理结果总结"), "decisions": map[string]any{"type": "array", "items": decision}}, "summary", "decisions"), func(ctx context.Context, raw json.RawMessage) (actool.Result, error) {
|
||||
var a findingReviewConclusion
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
conv := intercept.ConvIDFromContext(ctx)
|
||||
if err := s.saveFindingReviewConclusion(ctx, conv, a); err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
return actool.Text("历史整理验收通过;原始记录和报告保留,逐条根因判断已保存。"), nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) saveFindingReviewConclusion(ctx context.Context, conv int64, a findingReviewConclusion) error {
|
||||
if conv <= 0 {
|
||||
return errors.New("仅用于有上下文的历史整理会话")
|
||||
}
|
||||
return s.m.pg.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
|
||||
var idsRaw, readsRaw json.RawMessage
|
||||
var state string
|
||||
if err := tx.QueryRow(`SELECT finding_ids,reviewed_ids,state FROM finding_case_review_runs WHERE conversation_id=$1 FOR UPDATE`, conv).Scan(&idsRaw, &readsRaw, &state); err != nil {
|
||||
return err
|
||||
}
|
||||
if state != "running" {
|
||||
return errors.New("整理会话未运行,不能提交验收")
|
||||
}
|
||||
var ids, reads []int64
|
||||
if err := json.Unmarshal(idsRaw, &ids); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal(readsRaw, &reads); err != nil {
|
||||
return err
|
||||
}
|
||||
read := map[int64]bool{}
|
||||
for _, id := range reads {
|
||||
read[id] = true
|
||||
}
|
||||
decisions := map[int64]findingReviewDecision{}
|
||||
for _, d := range a.Decisions {
|
||||
if _, ok := decisions[d.FindingID]; ok {
|
||||
return errors.New("重复的判断编号")
|
||||
}
|
||||
decisions[d.FindingID] = d
|
||||
}
|
||||
if strings.TrimSpace(a.Summary) == "" || len(ids) != len(decisions) {
|
||||
return errors.New("须提供总结和覆盖全部所选记录的逐条判断")
|
||||
}
|
||||
for _, id := range ids {
|
||||
d, ok := decisions[id]
|
||||
if !ok || !read[id] {
|
||||
return fmt.Errorf("请先 get_finding_record 读取并判断所选记录 #%d", id)
|
||||
}
|
||||
if strings.TrimSpace(d.Reason) == "" || strings.TrimSpace(d.RootCauseEvidence) == "" {
|
||||
return fmt.Errorf("记录 #%d 缺少判断理由或根因证据", id)
|
||||
}
|
||||
var cid sql.NullInt64
|
||||
if err := tx.QueryRow(`SELECT m.case_id FROM findings f LEFT JOIN finding_case_members m ON m.finding_id=f.id WHERE f.id=$1`, id).Scan(&cid); err != nil {
|
||||
return err
|
||||
}
|
||||
switch d.Verdict {
|
||||
case "merged":
|
||||
if !cid.Valid {
|
||||
return fmt.Errorf("记录 #%d 尚未归并", id)
|
||||
}
|
||||
case "suggested":
|
||||
var pending bool
|
||||
if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_suggestions WHERE state='pending' AND (left_id=$1 OR right_id=$1))`, id).Scan(&pending); err != nil {
|
||||
return err
|
||||
}
|
||||
if !pending {
|
||||
return fmt.Errorf("记录 #%d 须先保存待确认建议", id)
|
||||
}
|
||||
case "independent":
|
||||
if cid.Valid {
|
||||
return fmt.Errorf("记录 #%d 已在文件夹中,不能声明为独立", id)
|
||||
}
|
||||
default:
|
||||
return errors.New("无效判断类型")
|
||||
}
|
||||
if cid.Valid {
|
||||
var report string
|
||||
var version, rv int64
|
||||
var active bool
|
||||
if err := tx.QueryRow(`SELECT report,version,report_version,active FROM finding_cases WHERE id=$1 FOR SHARE`, cid.Int64).Scan(&report, &version, &rv, &active); err != nil {
|
||||
return err
|
||||
}
|
||||
if !active || rv != version {
|
||||
return fmt.Errorf("文件夹 #%d 的统一报告待更新,请重新读取后更新", cid.Int64)
|
||||
}
|
||||
if err := validateUnifiedFindingReport(report); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
raw, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`UPDATE finding_case_review_runs SET conclusion=$2 WHERE conversation_id=$1`, conv, string(raw))
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) validateFindingReviewCompletion(conv int64) error {
|
||||
var raw json.RawMessage
|
||||
if err := s.m.pg.QueryRow(`SELECT conclusion FROM finding_case_review_runs WHERE conversation_id=$1`, conv).Scan(&raw); err != nil {
|
||||
return err
|
||||
}
|
||||
var a findingReviewConclusion
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(a.Decisions) == 0 {
|
||||
return errors.New("整理未通过验收:未提交 complete_finding_case_review 逐条根因判断;请查看执行记录")
|
||||
}
|
||||
// Recheck after the last model turn; concurrent evidence changes invalidate completion.
|
||||
return s.saveFindingReviewConclusion(context.Background(), conv, a)
|
||||
}
|
||||
|
||||
// Require substantive sections and balanced fenced blocks. This validates report
|
||||
// completeness, not the truth of a model's root-cause claim or a live exploit.
|
||||
func validateUnifiedFindingReport(report string) error {
|
||||
aliases := [][]string{{"概述", "overview", "summary"}, {"影响", "impact"}, {"前置", "prerequisite", "precondition"}, {"复现", "reproduction", "reproduce"}, {"证据", "evidence"}, {"poc", "完整脚本", "验证脚本", "proof of concept"}, {"根因", "root cause"}, {"修复", "remediation", "recommendation", "fix"}}
|
||||
names := []string{"概述", "影响", "前置条件", "复现步骤", "证据", "PoC", "根因分析", "修复建议"}
|
||||
found := make([]bool, len(aliases))
|
||||
current := -1
|
||||
hasContent := false
|
||||
fence := ""
|
||||
finish := func() {
|
||||
if current >= 0 && hasContent {
|
||||
found[current] = true
|
||||
}
|
||||
}
|
||||
for _, line := range strings.Split(report, "\n") {
|
||||
t := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(t, "```") || strings.HasPrefix(t, "~~~") {
|
||||
marker := t[:3]
|
||||
if fence == "" {
|
||||
fence = marker
|
||||
} else if fence == marker {
|
||||
fence = ""
|
||||
}
|
||||
continue
|
||||
}
|
||||
if fence != "" {
|
||||
if t != "" {
|
||||
hasContent = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(t, "#") {
|
||||
heading := strings.TrimLeft(t, "#")
|
||||
if len(heading) == len(t) || !strings.HasPrefix(heading, " ") {
|
||||
continue
|
||||
}
|
||||
// Subheadings belong to the current section unless they start another required section.
|
||||
next := -1
|
||||
lower := strings.ToLower(heading)
|
||||
for i, words := range aliases {
|
||||
for _, word := range words {
|
||||
if strings.Contains(lower, word) {
|
||||
next = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if next >= 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if next >= 0 {
|
||||
finish()
|
||||
current = next
|
||||
hasContent = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if t != "" && t != "---" && t != "***" && t != ">" {
|
||||
hasContent = true
|
||||
}
|
||||
}
|
||||
finish()
|
||||
if fence != "" {
|
||||
return errors.New("统一报告代码块未闭合,可能截断;请补全后再保存")
|
||||
}
|
||||
missing := []string{}
|
||||
for i, ok := range found {
|
||||
if !ok {
|
||||
missing = append(missing, names[i])
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("统一报告缺少有内容的章节:%s;请补全后保存", strings.Join(missing, "、"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) historicalFindingReviewSelection(ctx context.Context) ([]int64, error) {
|
||||
conv := intercept.ConvIDFromContext(ctx)
|
||||
if conv == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var raw json.RawMessage
|
||||
err := s.m.pg.QueryRow(`SELECT finding_ids FROM finding_case_review_runs WHERE conversation_id=$1`, conv).Scan(&raw)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := []int64{}
|
||||
err = json.Unmarshal(raw, &ids)
|
||||
return ids, err
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const completeFindingReviewReport = `# 同一权限缺陷
|
||||
## 概述
|
||||
同一对象权限检查缺失。
|
||||
## 影响
|
||||
已验证读取其他账号的订单。
|
||||
## 前置条件
|
||||
已存在的低权会话和订单编号。
|
||||
## 复现步骤
|
||||
对照自己的订单,再查看其他账号订单,响应证明同一检查缺失。
|
||||
## 证据
|
||||
原报告的保存响应及命令输出,未重新探测。
|
||||
## PoC
|
||||
完整命令在原始证据中,此测试使用隔离材料。
|
||||
## 根因分析
|
||||
同一订单 handler 未验证订单所有者。
|
||||
## 修复建议
|
||||
统一验证请求者与订单所有者,并回归对象权限检查。
|
||||
`
|
||||
|
||||
func TestUnifiedFindingReportRejectsIncompleteAndTruncated(t *testing.T) {
|
||||
if err := validateUnifiedFindingReport(completeFindingReviewReport); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range []string{strings.Split(completeFindingReviewReport, "## 根因分析")[0], completeFindingReviewReport + "\n```bash\nunclosed", strings.Replace(completeFindingReviewReport, "统一验证请求者与订单所有者,并回归对象权限检查。", "", 1)} {
|
||||
if err := validateUnifiedFindingReport(s); err == nil {
|
||||
t.Fatal("incomplete report accepted")
|
||||
}
|
||||
}
|
||||
// Section labels appearing inside a script are not real report sections.
|
||||
if err := validateUnifiedFindingReport("## 概述\ntext\n```bash\n" + completeFindingReviewReport + "\n```"); err == nil {
|
||||
t.Fatal("script comments counted as sections")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindingCaseReviewCompletionRequiresReadsAndFreshReport(t *testing.T) {
|
||||
s, task, ids := testCaseServer(t)
|
||||
runs, err := s.m.pg.CreateFindingCaseReviews(t.Context(), map[int64][]int64{task: ids})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conv := runs[0].ConversationID
|
||||
defer s.m.pg.Exec(`DELETE FROM conversations WHERE id=$1`, conv)
|
||||
if _, err := s.m.pg.Exec(`UPDATE finding_case_review_runs SET state='running' WHERE conversation_id=$1`, conv); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := intercept.WithConvID(context.Background(), conv)
|
||||
a := findingReviewConclusion{Summary: "根据对象权限检查的具体证据判断"}
|
||||
for _, id := range ids {
|
||||
a.Decisions = append(a.Decisions, findingReviewDecision{FindingID: id, Verdict: "merged", Reason: "同一 handler", RootCauseEvidence: "各记录的 baseline/proof 指向同一对象所有者校验缺失"})
|
||||
}
|
||||
if err := s.saveFindingReviewConclusion(ctx, conv, a); err == nil {
|
||||
t.Fatal("unread findings accepted")
|
||||
}
|
||||
for _, id := range ids {
|
||||
if _, err := s.performFindingCaseTool(ctx, "get_finding_record", findingCaseRequest{FindingID: json.RawMessage(fmt.Sprint(id))}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cid, err := s.m.pg.MergeFindingCase(ctx, task, ids, "orders", "same concrete check", "reporter")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.saveFindingReviewConclusion(ctx, conv, a); err == nil {
|
||||
t.Fatal("missing unified report accepted")
|
||||
}
|
||||
c, _ := s.m.pg.GetFindingCase(cid)
|
||||
if err := s.m.pg.UpdateFindingCaseReport(ctx, cid, c.Version, "orders", completeFindingReviewReport, "high", "verified impact"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.saveFindingReviewConclusion(ctx, conv, a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.validateFindingReviewCompletion(conv); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.checkCaseReviewScope(ctx, "update_finding_case_report", findingCaseRequest{CaseID: json.RawMessage(fmt.Sprint(cid))}); err == nil {
|
||||
t.Fatal("sealed review can still mutate")
|
||||
}
|
||||
if _, err := s.m.pg.Exec(`UPDATE findings SET evidence='new proof' WHERE id=$1`, ids[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.validateFindingReviewCompletion(conv); err == nil {
|
||||
t.Fatal("stale group reported completed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindingCaseReviewProtectsOriginalToolWrites(t *testing.T) {
|
||||
s, f, _ := trafficEvidenceServer(t)
|
||||
original, err := s.m.pg.GetFinding(f.FindingID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runs, err := s.m.pg.CreateFindingCaseReviews(t.Context(), map[int64][]int64{*original.TaskID: {original.ID}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conv := runs[0].ConversationID
|
||||
defer s.m.pg.Exec(`DELETE FROM conversations WHERE id=$1`, conv)
|
||||
ctx := intercept.WithConvID(context.Background(), conv)
|
||||
workflowCall(t, ctx, s.toolUpdateFindingReport(), map[string]any{"finding_id": f.NodeID, "report": "OVERWRITE", "evidence_version": original.EvidenceVersion}, true)
|
||||
if err := s.agentFindingTrafficAccess(ctx, f.FindingID, true); err == nil {
|
||||
t.Fatal("historical review can change evidence")
|
||||
}
|
||||
after, _ := s.m.pg.GetFinding(original.ID)
|
||||
if after.Report != original.Report || after.EvidenceVersion != original.EvidenceVersion || after.Status != original.Status || after.Severity != original.Severity {
|
||||
t.Fatal("original changed")
|
||||
}
|
||||
// Normal reporting is still able to write its own standalone report.
|
||||
workflowCall(t, context.Background(), s.toolUpdateFindingReport(), map[string]any{"finding_id": f.NodeID, "report": "normal report", "evidence_version": original.EvidenceVersion}, false)
|
||||
}
|
||||
|
||||
func TestFindingCaseReviewSuggestionNeedsSavedSuggestion(t *testing.T) {
|
||||
s, task, ids := testCaseServer(t)
|
||||
runs, err := s.m.pg.CreateFindingCaseReviews(t.Context(), map[int64][]int64{task: ids[:2]})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conv := runs[0].ConversationID
|
||||
defer s.m.pg.Exec(`DELETE FROM conversations WHERE id=$1`, conv)
|
||||
s.m.pg.Exec(`UPDATE finding_case_review_runs SET state='running' WHERE conversation_id=$1`, conv)
|
||||
ctx := intercept.WithConvID(context.Background(), conv)
|
||||
a := findingReviewConclusion{Summary: "需人工确认共同组件"}
|
||||
for _, id := range ids[:2] {
|
||||
s.performFindingCaseTool(ctx, "get_finding_record", findingCaseRequest{FindingID: json.RawMessage(fmt.Sprint(id))})
|
||||
a.Decisions = append(a.Decisions, findingReviewDecision{FindingID: id, Verdict: "suggested", Reason: "同一边界但根因未确认", RootCauseEvidence: "原报告指向对象权限边界,缺少共同组件证据"})
|
||||
}
|
||||
if err := s.saveFindingReviewConclusion(ctx, conv, a); err == nil {
|
||||
t.Fatal("unpersisted suggestion accepted")
|
||||
}
|
||||
if _, err := s.m.pg.SuggestFindingCase(ctx, task, ids[0], ids[1], "orders", "common component unknown"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.saveFindingReviewConclusion(ctx, conv, a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindingCaseReviewCandidatesStayWithinSelection(t *testing.T) {
|
||||
s, task, ids := testCaseServer(t)
|
||||
runs, err := s.m.pg.CreateFindingCaseReviews(t.Context(), map[int64][]int64{task: ids[:2]})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conv := runs[0].ConversationID
|
||||
defer s.m.pg.Exec(`DELETE FROM conversations WHERE id=$1`, conv)
|
||||
ctx := intercept.WithConvID(context.Background(), conv)
|
||||
a := findingCaseRequest{FindingID: json.RawMessage(fmt.Sprint(ids[0]))}
|
||||
v, err := s.performFindingCaseTool(ctx, "search_finding_duplicates", a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.(map[string]any)["total"].(int) != 1 {
|
||||
t.Fatal("unselected candidates leaked", v)
|
||||
}
|
||||
v, err = s.performFindingCaseTool(context.Background(), "search_finding_duplicates", a)
|
||||
if err != nil || v.(map[string]any)["total"].(int) != 2 {
|
||||
t.Fatal("normal candidate search changed", v, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
type findingCaseRequest struct {
|
||||
TaskID json.RawMessage `json:"task_id"`
|
||||
FindingID json.RawMessage `json:"finding_id"`
|
||||
IDs []json.RawMessage `json:"finding_ids"`
|
||||
CaseID json.RawMessage `json:"case_id"`
|
||||
Version int64 `json:"version"`
|
||||
Title string `json:"title"`
|
||||
Reason string `json:"reason"`
|
||||
Report string `json:"report"`
|
||||
Severity string `json:"severity"`
|
||||
SeverityReason string `json:"severity_reason"`
|
||||
Page int `json:"page"`
|
||||
}
|
||||
|
||||
func (a findingCaseRequest) ids() []int64 {
|
||||
out := []int64{}
|
||||
for _, raw := range a.IDs {
|
||||
out = append(out, parseProfileID(raw))
|
||||
}
|
||||
return out
|
||||
}
|
||||
func caseHTTPError(w http.ResponseWriter, err error) {
|
||||
code := 400
|
||||
if errors.Is(err, db.ErrFindingCaseConflict) || errors.Is(err, db.ErrFindingCaseReviewBusy) {
|
||||
code = 409
|
||||
}
|
||||
writeErr(w, code, err.Error())
|
||||
}
|
||||
func decodeCaseRequest(w http.ResponseWriter, r *http.Request, a any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
if err := json.NewDecoder(r.Body).Decode(a); err != nil {
|
||||
writeErr(w, 400, "无效请求正文")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func (s *Server) caseFindingDTO(f *db.DBFinding) FindingDTO {
|
||||
return findingFromDB(f, s.resolveAssetIDs(f.AssetIDs))
|
||||
}
|
||||
func (s *Server) listFindingCases(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
page := findingPaginationParam(q.Get("page"), 1, 0)
|
||||
size := findingPaginationParam(q.Get("limit"), 20, 100)
|
||||
rows, total, err := s.m.pg.ListFindingCases(findingFilterFromQuery(q), page, size)
|
||||
if err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
caseIDs := []int64{}
|
||||
for _, row := range rows {
|
||||
if row.Case != nil {
|
||||
caseIDs = append(caseIDs, row.Case.ID)
|
||||
}
|
||||
}
|
||||
contexts, err := s.m.pg.FindingCaseListContexts(caseIDs)
|
||||
if err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
assetIDs := []int64{}
|
||||
for _, c := range contexts {
|
||||
assetIDs = append(assetIDs, c.AssetIDs...)
|
||||
}
|
||||
assets := s.resolveAssetIDs(assetIDs)
|
||||
items := []map[string]any{}
|
||||
for _, row := range rows {
|
||||
v := map[string]any{"case": row.Case, "matched_ids": row.MatchedIDs}
|
||||
if row.Case != nil {
|
||||
c := contexts[row.Case.ID]
|
||||
v["task_name"], v["task_description"], v["last_found_at"], v["asset_count"] = c.TaskName, c.TaskDescription, c.LastFoundAt, c.AssetCount
|
||||
v["assets"] = append([]FindingAssetDTO{}, findingFromDB(&db.DBFinding{AssetIDs: c.AssetIDs}, assets).Assets...)
|
||||
}
|
||||
if row.FindingID > 0 {
|
||||
f, err := s.m.pg.FindingCaseSummary(row.FindingID, q.Get("original_rows") == "1")
|
||||
if err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
f.Report = ""
|
||||
v["finding"] = s.caseFindingDTO(f)
|
||||
}
|
||||
items = append(items, v)
|
||||
}
|
||||
stats, err := s.m.pg.DistinctFindingStats(q.Get("task_id"))
|
||||
if err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
matchingReports, err := s.m.pg.MatchingFindingReportCount(findingFilterFromQuery(q))
|
||||
if err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"items": items, "total": total, "matching_reports": matchingReports, "page": page, "page_size": size, "stats": stats})
|
||||
}
|
||||
func (s *Server) findingCaseAccess(w http.ResponseWriter, r *http.Request, mutation bool) (*db.FindingCase, bool) {
|
||||
id := int64(atoiDefault(r.PathValue("id"), 0))
|
||||
c, err := s.m.pg.GetFindingCase(id)
|
||||
if err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return nil, false
|
||||
}
|
||||
if c == nil {
|
||||
writeErr(w, 404, "漏洞组不存在")
|
||||
return nil, false
|
||||
}
|
||||
if ctx := r.URL.Query().Get("context_task"); ctx != "" {
|
||||
t := s.m.ResolveTask(ctx)
|
||||
_, inherited, allowed := findingProvenanceInTask(t, c.TaskID)
|
||||
if !allowed {
|
||||
writeErr(w, 404, "任务中没有该漏洞组")
|
||||
return nil, false
|
||||
}
|
||||
if mutation && inherited {
|
||||
writeErr(w, 403, "继承漏洞组只读")
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return c, true
|
||||
}
|
||||
func (s *Server) getFindingCase(w http.ResponseWriter, r *http.Request) {
|
||||
c, ok := s.findingCaseAccess(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
events, err := s.m.pg.FindingCaseEvents(c.ID)
|
||||
if err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"case": c, "events": events})
|
||||
}
|
||||
func (s *Server) getFindingCaseMembers(w http.ResponseWriter, r *http.Request) {
|
||||
c, ok := s.findingCaseAccess(w, r, false)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page := findingPaginationParam(r.URL.Query().Get("page"), 1, 0)
|
||||
size := findingPaginationParam(r.URL.Query().Get("limit"), 20, 100)
|
||||
fs, total, err := s.m.pg.FindingCaseMembers(c.ID, page, size)
|
||||
if err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
assets := s.resolveFindingAssets(fs)
|
||||
items := []FindingDTO{}
|
||||
for _, f := range fs {
|
||||
dto := findingFromDB(f, assets)
|
||||
if ctx := r.URL.Query().Get("context_task"); ctx != "" {
|
||||
dto.SourceTaskID, dto.Inherited, _ = findingProvenanceInTask(s.m.ResolveTask(ctx), f.TaskID)
|
||||
}
|
||||
items = append(items, dto)
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"items": items, "total": total, "page": page, "page_size": size})
|
||||
}
|
||||
func (s *Server) removeFindingCaseMember(w http.ResponseWriter, r *http.Request) {
|
||||
c, ok := s.findingCaseAccess(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fid := int64(atoiDefault(r.PathValue("fid"), 0))
|
||||
var body struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if !decodeCaseRequest(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(body.Reason) == "" {
|
||||
body.Reason = "人工移出归并组"
|
||||
}
|
||||
if err := s.m.pg.RemoveFindingCaseMember(r.Context(), c.ID, fid, body.Reason); err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
func (s *Server) caseSuggestions(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.m.pg.FindingCaseSuggestions(int64(atoiDefault(r.URL.Query().Get("task_id"), 0)))
|
||||
if err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, items)
|
||||
}
|
||||
func (s *Server) resolveCaseSuggestion(w http.ResponseWriter, r *http.Request) {
|
||||
var a struct {
|
||||
Accept bool `json:"accept"`
|
||||
}
|
||||
if !decodeCaseRequest(w, r, &a) {
|
||||
return
|
||||
}
|
||||
id, err := s.m.pg.ResolveFindingCaseSuggestion(r.Context(), int64(atoiDefault(r.PathValue("id"), 0)), a.Accept)
|
||||
if err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]string{"case_id": i64s(id)})
|
||||
}
|
||||
func (s *Server) mergeFindingCase(w http.ResponseWriter, r *http.Request) {
|
||||
var a findingCaseRequest
|
||||
if !decodeCaseRequest(w, r, &a) {
|
||||
return
|
||||
}
|
||||
id, err := s.m.pg.MergeFindingCase(r.Context(), parseProfileID(a.TaskID), a.ids(), a.Title, a.Reason, "human")
|
||||
if err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]string{"case_id": i64s(id)})
|
||||
}
|
||||
func (s *Server) updateCaseReport(w http.ResponseWriter, r *http.Request) {
|
||||
c, ok := s.findingCaseAccess(w, r, true)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var a findingCaseRequest
|
||||
if !decodeCaseRequest(w, r, &a) {
|
||||
return
|
||||
}
|
||||
if err := validateUnifiedFindingReport(a.Report); err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.m.pg.UpdateFindingCaseReport(r.Context(), c.ID, a.Version, a.Title, a.Report, a.Severity, a.SeverityReason); err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) findingCaseTools() []actool.CoreTool {
|
||||
props := map[string]any{"finding_id": idParam("独立 findings 记录 ID,不是探索节点 ID"), "finding_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}, "description": "独立漏洞记录 ID 列表"}, "task_id": idParam("同一来源任务 ID"), "case_id": idParam("归并组 ID"), "version": idParam("get_finding_case 实际读取的 version"), "page": idParam("分页,从1开始,每页20条"), "title": strParam("同一实际缺陷的名称"), "reason": strParam("具体证据与归并理由,不能仅类型相同"), "report": strParam("全部成员证据的完整统一 Markdown 报告"), "severity": strParam("critical|high|medium|low"), "severity_reason": strParam("基于已验证影响的评级依据,不直接取最高等级")}
|
||||
specs := []struct {
|
||||
name, desc string
|
||||
required []string
|
||||
}{
|
||||
{"search_finding_duplicates", "只读检索同任务候选,返回摘要与资产;等级不同也可能重复。支持分页,按需读取详情。", []string{"finding_id"}},
|
||||
{"get_finding_record", "读取独立漏洞完整原始证据和报告,返回探索节点编号以便追溯执行记录。", []string{"finding_id"}},
|
||||
{"merge_finding_records", "证据明确证明同一缺陷时归并;保留所有原始记录。不跨任务、不改变原评级,不可绕过人工否决。", []string{"task_id", "finding_ids", "title", "reason"}},
|
||||
{"suggest_finding_merge", "证据不足时提交疑似重复建议,待人工确认;不是自动合并。只允许两条同任务记录。", []string{"task_id", "finding_ids", "title", "reason"}},
|
||||
{"get_finding_case", "读取归并组和成员分页。先记录version,再读取成员的原报告、证据及流量;版本变化必须重新读取。", []string{"case_id"}},
|
||||
{"update_finding_case_report", "保存统一报告及评级理由,version必须来自实际读取。保留各条原报告,不新建漏洞。", []string{"case_id", "version", "title", "report", "severity", "severity_reason"}},
|
||||
}
|
||||
out := []actool.CoreTool{}
|
||||
for _, spec := range specs {
|
||||
spec := spec
|
||||
handler := func(ctx context.Context, in json.RawMessage) (actool.Result, error) {
|
||||
var a findingCaseRequest
|
||||
if err := json.Unmarshal(in, &a); err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
v, err := s.performFindingCaseTool(ctx, spec.name, a)
|
||||
if err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
raw, _ := json.Marshal(v)
|
||||
return actool.Text(string(raw)), nil
|
||||
}
|
||||
schemaProps := map[string]any{}
|
||||
for _, key := range spec.required {
|
||||
schemaProps[key] = props[key]
|
||||
}
|
||||
if spec.name == "search_finding_duplicates" || spec.name == "get_finding_case" {
|
||||
schemaProps["page"] = props["page"]
|
||||
}
|
||||
schema := objSchema(schemaProps, spec.required...)
|
||||
if strings.HasPrefix(spec.name, "get_") || strings.HasPrefix(spec.name, "search_") {
|
||||
out = append(out, roTool(spec.name, spec.desc, schema, handler))
|
||||
} else {
|
||||
out = append(out, wrTool(spec.name, spec.desc, schema, handler))
|
||||
}
|
||||
}
|
||||
out = append(out, s.toolCompleteFindingCaseReview())
|
||||
return out
|
||||
}
|
||||
func (s *Server) performFindingCaseTool(ctx context.Context, name string, a findingCaseRequest) (any, error) {
|
||||
if err := s.checkCaseReviewScope(ctx, name, a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fid := parseProfileID(a.FindingID)
|
||||
cid := parseProfileID(a.CaseID)
|
||||
page := a.Page
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
switch name {
|
||||
case "search_finding_duplicates":
|
||||
scope, err := s.historicalFindingReviewSelection(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var scopes [][]int64
|
||||
if scope != nil {
|
||||
scopes = append(scopes, scope)
|
||||
}
|
||||
items, total, err := s.m.pg.FindingCaseCandidates(fid, page, 20, scopes...)
|
||||
return map[string]any{"items": items, "total": total, "page": page, "page_size": 20}, err
|
||||
case "get_finding_record":
|
||||
f, err := s.m.pg.GetFinding(fid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if f == nil {
|
||||
return nil, errors.New("漏洞不存在")
|
||||
}
|
||||
dto := s.caseFindingDTO(f)
|
||||
cid, err := s.m.pg.FindingCaseID(f.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cid > 0 {
|
||||
dto.CaseID = i64s(cid)
|
||||
}
|
||||
if conv := intercept.ConvIDFromContext(ctx); conv > 0 {
|
||||
if _, err := s.m.pg.Exec(`UPDATE finding_case_review_runs SET reviewed_ids=CASE WHEN reviewed_ids @> jsonb_build_array($2::bigint) THEN reviewed_ids ELSE reviewed_ids || jsonb_build_array($2::bigint) END WHERE conversation_id=$1 AND finding_ids @> jsonb_build_array($2::bigint)`, conv, f.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return map[string]any{"finding": dto, "finding_node_id": f.NodeID}, nil
|
||||
case "merge_finding_records":
|
||||
id, err := s.m.pg.MergeFindingCase(ctx, parseProfileID(a.TaskID), a.ids(), a.Title, a.Reason, "reporter")
|
||||
return map[string]string{"case_id": i64s(id)}, err
|
||||
case "suggest_finding_merge":
|
||||
ids := a.ids()
|
||||
if len(ids) != 2 {
|
||||
return nil, errors.New("疑似重复建议需要两条记录")
|
||||
}
|
||||
id, err := s.m.pg.SuggestFindingCase(ctx, parseProfileID(a.TaskID), ids[0], ids[1], a.Title, a.Reason)
|
||||
return map[string]string{"suggestion_id": i64s(id)}, err
|
||||
case "get_finding_case":
|
||||
c, err := s.m.pg.GetFindingCase(cid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c == nil {
|
||||
return nil, errors.New("漏洞组不存在")
|
||||
}
|
||||
fs, total, err := s.m.pg.FindingCaseMembers(cid, page, 20)
|
||||
members := []FindingDTO{}
|
||||
for _, f := range fs {
|
||||
members = append(members, s.caseFindingDTO(f))
|
||||
}
|
||||
return map[string]any{"case": c, "members": members, "total": total, "page": page, "page_size": 20}, err
|
||||
case "update_finding_case_report":
|
||||
if err := validateUnifiedFindingReport(a.Report); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err := s.m.pg.UpdateFindingCaseReport(ctx, cid, a.Version, a.Title, a.Report, a.Severity, a.SeverityReason)
|
||||
return map[string]bool{"ok": err == nil}, err
|
||||
}
|
||||
return nil, errors.New("未知归并工具")
|
||||
}
|
||||
func idParam(desc string) map[string]any {
|
||||
return map[string]any{"type": "integer", "description": desc}
|
||||
}
|
||||
func (s *Server) seedFindingCaseTools() {
|
||||
_, _ = s.m.pg.Exec(`UPDATE finding_case_review_runs SET state='failed',error='服务重启中断,请重新提交整理' WHERE state IN ('queued','running')`)
|
||||
a, err := s.m.pg.GetAgentByKey("reporter")
|
||||
if err != nil || a == nil {
|
||||
return
|
||||
}
|
||||
const flag = "finding_case_tools_v2_review"
|
||||
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
|
||||
return
|
||||
}
|
||||
names := []string{}
|
||||
previous, _, _ := s.m.pg.GetSetting("finding_case_tools_v1")
|
||||
for _, t := range s.findingCaseTools() {
|
||||
schema, _ := json.Marshal(t.InputSchema())
|
||||
if err := s.m.pg.SeedTool(t.Name(), t.Description(), schema, json.RawMessage(`["reporter"]`)); err != nil {
|
||||
return
|
||||
}
|
||||
if previous != "true" || t.Name() == "complete_finding_case_review" {
|
||||
names = append(names, t.Name())
|
||||
}
|
||||
}
|
||||
if err := s.m.pg.AddAgentToToolBinding("reporter", names); err != nil {
|
||||
return
|
||||
}
|
||||
_ = s.m.pg.SetSetting(flag, "true")
|
||||
}
|
||||
func (s *Server) reviewFindingCases(w http.ResponseWriter, r *http.Request) {
|
||||
var a findingCaseRequest
|
||||
if !decodeCaseRequest(w, r, &a) {
|
||||
return
|
||||
}
|
||||
ids := a.ids()
|
||||
if cid := parseProfileID(a.CaseID); cid > 0 {
|
||||
c, err := s.m.pg.GetFindingCase(cid)
|
||||
if err != nil || c == nil || !c.Active {
|
||||
writeErr(w, 400, "漏洞组不可用")
|
||||
return
|
||||
}
|
||||
if ctx := r.URL.Query().Get("context_task"); ctx != "" && (c.TaskID == nil || ctx != i64s(*c.TaskID)) {
|
||||
writeErr(w, 403, "继承漏洞组只读")
|
||||
return
|
||||
}
|
||||
fs, _, err := s.m.pg.FindingCaseMembers(cid, 1, 100000)
|
||||
if err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
ids = nil
|
||||
for _, f := range fs {
|
||||
ids = append(ids, f.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 || (len(a.CaseID) == 0 && len(ids) > 200) {
|
||||
writeErr(w, 400, "请选择1至200条原始上报")
|
||||
return
|
||||
}
|
||||
reporter, err := s.m.pg.GetAgentByKey("reporter")
|
||||
if err != nil || reporter == nil || !reporter.Enabled || s.chatAgentRef() == nil {
|
||||
writeErr(w, 409, "报告 Agent 未启用或模型不可用")
|
||||
return
|
||||
}
|
||||
batches := map[int64][]int64{}
|
||||
for _, fid := range ids {
|
||||
f, err := s.m.pg.FindingCaseSummary(fid)
|
||||
if err != nil || f == nil || f.TaskID == nil {
|
||||
writeErr(w, 400, "所选漏洞来源任务不可用")
|
||||
return
|
||||
}
|
||||
batches[*f.TaskID] = append(batches[*f.TaskID], fid)
|
||||
}
|
||||
// Validate and snapshot every source before starting any batch.
|
||||
taskContexts := map[int64]triggeredRun{}
|
||||
for taskID := range batches {
|
||||
t, ok := s.m.Task(i64s(taskID))
|
||||
if !ok {
|
||||
writeErr(w, 409, "任务不可用")
|
||||
return
|
||||
}
|
||||
|
||||
taskContexts[taskID] = triggeredRun{taskDesc: t.Description, taskGoal: t.Goal}
|
||||
}
|
||||
reservations, err := s.m.pg.CreateFindingCaseReviews(r.Context(), batches)
|
||||
if err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
runs := []map[string]any{}
|
||||
for _, reservation := range reservations {
|
||||
taskID, fids := reservation.TaskID, reservation.FindingIDs
|
||||
taskContext := taskContexts[taskID]
|
||||
raw, _ := json.Marshal(fids)
|
||||
msg := fmt.Sprintf("人工选择整理(只读原始记录):本任务所选 finding_ids=%s。逐条读取已有报告,仅判断同一实际根因,明确则归并,不确定提交建议;已有组更新统一报告。禁止生成或覆盖原始报告、改评级状态、补绑证据,不归并未选记录,不探测目标。结束前调用 complete_finding_case_review 提交逐条判断并通过验收。", raw)
|
||||
item := triggeredRun{agentKey: "reporter", title: "漏洞整理 · task#" + i64s(taskID), message: msg, taskID: taskID, taskDesc: taskContext.taskDesc, taskGoal: taskContext.taskGoal, conversationID: reservation.ConversationID}
|
||||
cfg := s.readTriggerBehavior("reporter")
|
||||
s.queueMu.Lock()
|
||||
s.triggerCfg["reporter"] = cfg
|
||||
s.triggerQ["reporter"] = append(s.triggerQ["reporter"], item)
|
||||
s.pumpLocked("reporter")
|
||||
s.queueMu.Unlock()
|
||||
runs = append(runs, map[string]any{"conversation_id": reservation.ConversationID, "task_id": i64s(taskID)})
|
||||
}
|
||||
writeJSON(w, 202, map[string]any{"runs": runs})
|
||||
}
|
||||
func (s *Server) caseReviewRuns(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := s.m.pg.Query(`SELECT conversation_id,task_id,state,error FROM finding_case_review_runs ORDER BY conversation_id DESC LIMIT 50`)
|
||||
if err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var conv int64
|
||||
var task sql.NullInt64
|
||||
var state, msg string
|
||||
if err := rows.Scan(&conv, &task, &state, &msg); err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
items = append(items, map[string]any{"conversation_id": conv, "task_id": i64s(task.Int64), "state": state, "error": msg})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
caseHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, items)
|
||||
}
|
||||
|
||||
// Manual reviews cannot mutate a finding outside the explicit selection.
|
||||
func (s *Server) checkCaseReviewScope(ctx context.Context, name string, a findingCaseRequest) error {
|
||||
conv := intercept.ConvIDFromContext(ctx)
|
||||
if conv == 0 {
|
||||
return nil
|
||||
}
|
||||
var raw, conclusion json.RawMessage
|
||||
err := s.m.pg.QueryRow(`SELECT finding_ids,conclusion FROM finding_case_review_runs WHERE conversation_id=$1`, conv).Scan(&raw, &conclusion)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
var ids []int64
|
||||
if err := json.Unmarshal(raw, &ids); err != nil {
|
||||
return err
|
||||
}
|
||||
if string(conclusion) != "{}" && (name == "merge_finding_records" || name == "suggest_finding_merge" || name == "update_finding_case_report") {
|
||||
return errors.New("整理判断已提交,不能在验收后继续修改")
|
||||
}
|
||||
allowed := map[int64]bool{}
|
||||
for _, id := range ids {
|
||||
allowed[id] = true
|
||||
}
|
||||
switch name {
|
||||
case "search_finding_duplicates":
|
||||
if !allowed[parseProfileID(a.FindingID)] {
|
||||
return errors.New("历史整理只能检索所选记录的候选")
|
||||
}
|
||||
case "update_finding_report", "bind_finding_traffic", "report_finding":
|
||||
return errors.New("历史整理禁止修改原始报告、上报或证据;只能写归并关系及统一报告")
|
||||
case "merge_finding_records", "suggest_finding_merge":
|
||||
checkIDs := a.ids()
|
||||
for _, id := range checkIDs {
|
||||
if !allowed[id] {
|
||||
return errors.New("人工整理只能归并所选记录")
|
||||
}
|
||||
}
|
||||
case "update_finding_case_report":
|
||||
fs, _, err := s.m.pg.FindingCaseMembers(parseProfileID(a.CaseID), 1, 100000)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
found := false
|
||||
for _, f := range fs {
|
||||
if allowed[f.ID] {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return errors.New("该漏洞组不属于人工所选范围")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
type findingCaseExportPlan struct {
|
||||
cases map[int64]*db.FindingCase
|
||||
membership map[int64]int64
|
||||
selected map[int64]bool
|
||||
}
|
||||
|
||||
// Expand selection to complete cases before the evidence store freezes snapshots.
|
||||
func (s *Server) prepareFindingCaseExport(fs []*db.DBFinding) ([]*db.DBFinding, *findingCaseExportPlan, error) {
|
||||
plan := &findingCaseExportPlan{cases: map[int64]*db.FindingCase{}, membership: map[int64]int64{}, selected: map[int64]bool{}}
|
||||
expanded := append([]*db.DBFinding{}, fs...)
|
||||
seen := map[int64]bool{}
|
||||
for _, f := range fs {
|
||||
seen[f.ID] = true
|
||||
plan.selected[f.ID] = true
|
||||
}
|
||||
for _, f := range fs {
|
||||
cid, err := s.m.pg.FindingCaseID(f.ID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if cid == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := plan.cases[cid]; ok {
|
||||
continue
|
||||
}
|
||||
c, err := s.m.pg.GetFindingCase(cid)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if c == nil || !c.Active {
|
||||
continue
|
||||
}
|
||||
plan.cases[cid] = c
|
||||
members, _, err := s.m.pg.FindingCaseMembers(cid, 1, 1000000)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, m := range members {
|
||||
plan.membership[m.ID] = cid
|
||||
if !seen[m.ID] {
|
||||
seen[m.ID] = true
|
||||
expanded = append(expanded, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
return expanded, plan, nil
|
||||
}
|
||||
|
||||
// Composites are built AFTER staging real records, so their traffic attachments
|
||||
// remain private frozen export copies, not aliases to live or deleted evidence.
|
||||
func (s *Server) consolidateFindingCaseExport(fs []*db.DBFinding, plan *findingCaseExportPlan, includeOriginals bool) ([]*db.DBFinding, error) {
|
||||
out := []*db.DBFinding{}
|
||||
emitted := map[int64]bool{}
|
||||
for _, f := range fs {
|
||||
cid := plan.membership[f.ID]
|
||||
if cid == 0 {
|
||||
if plan.selected[f.ID] {
|
||||
out = append(out, f)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !emitted[cid] {
|
||||
emitted[cid] = true
|
||||
c := plan.cases[cid]
|
||||
latest, err := s.m.pg.GetFindingCase(cid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stale := latest == nil || latest.Version != c.Version || c.ReportVersion != c.Version
|
||||
title := c.Title
|
||||
sev := c.Severity
|
||||
if sev == "" {
|
||||
sev = "unassessed"
|
||||
}
|
||||
composite := &db.DBFinding{ID: f.ID, ExportCaseID: c.ID, TaskID: c.TaskID, TaskDescription: f.TaskDescription, Name: title, VulnClass: "归并漏洞", Severity: sev, Status: f.Status, CreatedAt: c.CreatedAt, Report: c.Report, EvidenceVersion: c.Version, ReportEvidenceVersion: c.ReportVersion}
|
||||
assets := map[int64]bool{}
|
||||
var refs []string
|
||||
var evidence strings.Builder
|
||||
for _, member := range fs {
|
||||
if plan.membership[member.ID] != cid {
|
||||
continue
|
||||
}
|
||||
composite.ExportMemberIDs = append(composite.ExportMemberIDs, member.ID)
|
||||
for _, aid := range member.AssetIDs {
|
||||
if !assets[aid] {
|
||||
assets[aid] = true
|
||||
composite.AssetIDs = append(composite.AssetIDs, aid)
|
||||
}
|
||||
}
|
||||
refs = append(refs, fmt.Sprintf("#%d [%s] %s", member.ID, member.Severity, member.Name))
|
||||
fmt.Fprintf(&evidence, "原始上报 #%d:%s\n%s\n\n", member.ID, member.Summary, member.Evidence)
|
||||
if member.Report != "" {
|
||||
fmt.Fprintf(&evidence, "原始报告 #%d:\n%s\n\n", member.ID, member.Report)
|
||||
}
|
||||
for _, b := range member.TrafficBindings {
|
||||
b.Note = fmt.Sprintf("来自上报 #%d;%s", member.ID, b.Note)
|
||||
composite.TrafficBindings = append(composite.TrafficBindings, b)
|
||||
}
|
||||
}
|
||||
composite.Summary = fmt.Sprintf("漏洞文件夹 #%d;原始记录:%s。统一评级依据:%s", cid, strings.Join(refs, ";"), c.SeverityReason)
|
||||
composite.TrafficCount = len(composite.TrafficBindings)
|
||||
if stale || c.Report == "" {
|
||||
composite.Report = "**统一报告尚未生成或已过期,以下提供全部原始证据,不能视为完整复现已验证。**\n\n" + c.Report
|
||||
composite.Evidence = evidence.String()
|
||||
composite.ReportEvidenceVersion = -1
|
||||
}
|
||||
out = append(out, composite)
|
||||
}
|
||||
if includeOriginals {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
)
|
||||
|
||||
func testCaseServer(t *testing.T) (*Server, int64, []int64) {
|
||||
t.Helper()
|
||||
dsn, _, err := db.DSN()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pg, err := db.Open(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { pg.Close() })
|
||||
task, err := pg.CreateTask("case server", "goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := []int64{}
|
||||
for _, sev := range []string{"high", "medium", "low"} {
|
||||
id, err := pg.AddFinding(task.ID, 0, "IDOR", "orders", sev, "same endpoint", "proof "+sev, "worker", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = pg.Exec(`DELETE FROM findings WHERE task_id=$1`, task.ID); _ = pg.DeleteTask(task.ID) })
|
||||
return &Server{m: &Manager{pg: pg}}, task.ID, ids
|
||||
}
|
||||
func TestFindingCaseAPIPaginationAndTools(t *testing.T) {
|
||||
s, task, ids := testCaseServer(t)
|
||||
ctx := context.Background()
|
||||
a := findingCaseRequest{TaskID: json.RawMessage(fmt.Sprint(task)), Title: "订单越权", Reason: "same check"}
|
||||
for _, id := range ids {
|
||||
a.IDs = append(a.IDs, json.RawMessage(fmt.Sprint(id)))
|
||||
}
|
||||
result, err := s.performFindingCaseTool(ctx, "merge_finding_records", a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cid := result.(map[string]string)["case_id"]
|
||||
r := httptest.NewRequest("GET", fmt.Sprintf("/api/exploration/finding-cases?task_id=%d&severity=low&page=1&limit=1", task), nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.listFindingCases(w, r)
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body)
|
||||
}
|
||||
var page struct {
|
||||
Total int `json:"total"`
|
||||
Items []struct {
|
||||
Case db.FindingCase `json:"case"`
|
||||
Matched []int64 `json:"matched_ids"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Total != 1 || page.Items[0].Case.Count != 3 || page.Items[0].Case.Report != "" || len(page.Items[0].Matched) != 1 {
|
||||
t.Fatalf("page %+v", page)
|
||||
}
|
||||
r = httptest.NewRequest("GET", "/", nil)
|
||||
r.SetPathValue("id", cid)
|
||||
w = httptest.NewRecorder()
|
||||
s.getFindingCaseMembers(w, r)
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), "proof high") {
|
||||
t.Fatal(w.Code, w.Body)
|
||||
}
|
||||
a.CaseID = json.RawMessage(cid)
|
||||
result, err = s.performFindingCaseTool(ctx, "get_finding_case", a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := result.(map[string]any)["case"].(*db.FindingCase)
|
||||
a.Version = c.Version
|
||||
a.Report = completeFindingReviewReport
|
||||
a.Severity = "high"
|
||||
a.SeverityReason = "verified data disclosure"
|
||||
if _, err := s.performFindingCaseTool(ctx, "update_finding_case_report", a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.m.pg.Exec(`UPDATE findings SET evidence='new data' WHERE id=$1`, ids[1]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.performFindingCaseTool(ctx, "update_finding_case_report", a); err != db.ErrFindingCaseConflict {
|
||||
t.Fatalf("stale report accepted %v", err)
|
||||
}
|
||||
}
|
||||
func TestFindingCaseConsolidatedExportPreservesScope(t *testing.T) {
|
||||
s, task, ids := testCaseServer(t)
|
||||
ctx := context.Background()
|
||||
cid, err := s.m.pg.MergeFindingCase(ctx, task, ids, "订单越权", "same", "reporter")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, _ := s.m.pg.GetFindingCase(cid)
|
||||
if err := s.m.pg.UpdateFindingCaseReport(ctx, cid, c.Version, "订单越权", "complete report", "high", "verified"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fs, err := s.m.pg.ListFindingsForExport(db.FindingFilter{}, ids[:1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expanded, plan, err := s.prepareFindingCaseExport(fs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(expanded) != 3 {
|
||||
t.Fatal("selected one child did not include complete group")
|
||||
}
|
||||
out, err := s.consolidateFindingCaseExport(expanded, plan, false)
|
||||
if err != nil || len(out) != 1 || out[0].Report != "complete report" || !strings.Contains(out[0].Summary, fmt.Sprint(ids[2])) {
|
||||
t.Fatalf("unified %+v %v", out, err)
|
||||
}
|
||||
out, err = s.consolidateFindingCaseExport(expanded, plan, true)
|
||||
if err != nil || len(out) != 4 {
|
||||
t.Fatalf("originals lost %d %v", len(out), err)
|
||||
}
|
||||
if _, err := s.m.pg.Exec(`UPDATE findings SET severity='critical' WHERE id=$1`, ids[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err = s.consolidateFindingCaseExport(expanded, plan, false)
|
||||
if err != nil || !strings.Contains(out[0].Report, "已过期") || !strings.Contains(out[0].Evidence, "proof low") {
|
||||
t.Fatalf("pending export omitted evidence %+v %v", out, err)
|
||||
}
|
||||
}
|
||||
func TestFindingCaseManualSelectionScope(t *testing.T) {
|
||||
s, task, ids := testCaseServer(t)
|
||||
conv, err := s.m.pg.CreateConversation("reporter", "manual", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.m.pg.Exec(`DELETE FROM conversations WHERE id=$1`, conv.ID)
|
||||
raw, _ := json.Marshal(ids[:2])
|
||||
if _, err := s.m.pg.Exec(`INSERT INTO finding_case_review_runs(conversation_id,task_id,finding_ids) VALUES($1,$2,$3)`, conv.ID, task, string(raw)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := intercept.WithConvID(context.Background(), conv.ID)
|
||||
a := findingCaseRequest{IDs: []json.RawMessage{json.RawMessage(fmt.Sprint(ids[0])), json.RawMessage(fmt.Sprint(ids[2]))}}
|
||||
if err := s.checkCaseReviewScope(ctx, "merge_finding_records", a); err == nil {
|
||||
t.Fatal("unselected mutation allowed")
|
||||
}
|
||||
a.IDs[1] = json.RawMessage(fmt.Sprint(ids[1]))
|
||||
if err := s.checkCaseReviewScope(ctx, "merge_finding_records", a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindingCaseReviewStatusSurvivesTaskDeletion(t *testing.T) {
|
||||
s, task, ids := testCaseServer(t)
|
||||
conv, err := s.m.pg.CreateConversation("reporter", "review", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.m.pg.Exec(`DELETE FROM conversations WHERE id=$1`, conv.ID)
|
||||
if _, err := s.m.pg.Exec(`INSERT INTO finding_case_review_runs(conversation_id,task_id,finding_ids) VALUES($1,$2,$3)`, conv.ID, task, fmt.Sprintf("[%d]", ids[0])); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.m.pg.DeleteTask(task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
for _, id := range ids {
|
||||
_, _ = s.m.pg.DeleteFinding(id)
|
||||
}
|
||||
}()
|
||||
w := httptest.NewRecorder()
|
||||
s.caseReviewRuns(w, httptest.NewRequest("GET", "/", nil))
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), `"task_id":"0"`) {
|
||||
t.Fatal(w.Code, w.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindingCaseSelectedReviewCannotRewriteOtherOriginal(t *testing.T) {
|
||||
s, task, ids := testCaseServer(t)
|
||||
conv, err := s.m.pg.CreateConversation("reporter", "review", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.m.pg.Exec(`DELETE FROM conversations WHERE id=$1`, conv.ID)
|
||||
raw, _ := json.Marshal(ids[:1])
|
||||
if _, err := s.m.pg.Exec(`INSERT INTO finding_case_review_runs(conversation_id,task_id,finding_ids) VALUES($1,$2,$3)`, conv.ID, task, string(raw)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := intercept.WithConvID(context.Background(), conv.ID)
|
||||
if err := s.checkCaseReviewScope(ctx, "update_finding_report", findingCaseRequest{FindingID: json.RawMessage(fmt.Sprint(ids[1]))}); err == nil {
|
||||
t.Fatal("unselected original allowed")
|
||||
}
|
||||
if err := s.checkCaseReviewScope(ctx, "update_finding_report", findingCaseRequest{FindingID: json.RawMessage(fmt.Sprint(ids[0]))}); err == nil {
|
||||
t.Fatal("selected original was allowed to be overwritten")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindingCaseGroupedTrafficExportAndInheritedAccess(t *testing.T) {
|
||||
s, recorded, req := trafficEvidenceServer(t)
|
||||
ctx := t.Context()
|
||||
f, err := s.m.pg.GetFinding(recorded.FindingID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task, err := s.m.pg.GetTask(*f.TaskID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := []byte("verified original response")
|
||||
seedServerEvidenceFlow(t, s, "case-proof", body)
|
||||
if w := req("POST", fmt.Sprintf("/api/exploration/findings/%d/traffic", f.ID), `{"traffic_refs":[{"traffic_id":"case-proof","role":"proof"}]}`); w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body)
|
||||
}
|
||||
other, err := s.evidenceStore().Record(ctx, db.RecordFindingInput{TaskID: task.ID, ExplorationID: task.ExplorationID, Summary: "same permission boundary", Severity: "low"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []int64{f.ID, other.FindingID} {
|
||||
if _, err := s.m.pg.Exec(`UPDATE findings SET report=$2 WHERE id=$1`, id, fmt.Sprintf("original script #%d", id)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cid, err := s.m.pg.MergeFindingCase(ctx, task.ID, []int64{f.ID, other.FindingID}, "orders", "same permission check", "reporter")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, _ := s.m.pg.GetFindingCase(cid)
|
||||
if err := s.m.pg.UpdateFindingCaseReport(ctx, cid, c.Version, "orders", "unified complete script", "high", "verified impact"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A binding note is evidence too: changing it makes the aggregate report stale.
|
||||
list, err := s.m.pg.GetFindingTraffic(ctx, f.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := req("PATCH", fmt.Sprintf("/api/exploration/findings/%d/traffic/%d", f.ID, list.Bindings[0].ID), fmt.Sprintf(`{"version":%d,"note":"additional verified condition"}`, list.Version))
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body)
|
||||
}
|
||||
c, _ = s.m.pg.GetFindingCase(cid)
|
||||
if c.Version == c.ReportVersion {
|
||||
t.Fatal("binding evidence did not invalidate unified report")
|
||||
}
|
||||
url := fmt.Sprintf("/api/exploration/findings/export?mode=consolidated&include_originals=true&scope=selected&ids=%d&format=md-zip", f.ID)
|
||||
w = req("GET", url, "")
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body)
|
||||
}
|
||||
z, err := zip.NewReader(bytes.NewReader(w.Body.Bytes()), int64(w.Body.Len()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mdCount, caseCount := 0, 0
|
||||
seen := map[string]bool{}
|
||||
recovered := false
|
||||
for _, file := range z.File {
|
||||
if seen[file.Name] {
|
||||
t.Fatalf("duplicate ZIP entry %s", file.Name)
|
||||
}
|
||||
seen[file.Name] = true
|
||||
reader, e := file.Open()
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
data, e := io.ReadAll(reader)
|
||||
reader.Close()
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if strings.HasSuffix(file.Name, ".md") {
|
||||
mdCount++
|
||||
if strings.HasPrefix(file.Name, "case_") {
|
||||
caseCount++
|
||||
if !strings.Contains(string(data), fmt.Sprintf("original script #%d", other.FindingID)) {
|
||||
t.Fatal("stale export lost an original report")
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.HasSuffix(file.Name, "response.bin") && bytes.Equal(data, body) {
|
||||
recovered = true
|
||||
}
|
||||
}
|
||||
if mdCount != 3 || caseCount != 1 || !recovered {
|
||||
t.Fatalf("ZIP reports=%d folders=%d evidence=%v", mdCount, caseCount, recovered)
|
||||
}
|
||||
w = req("GET", fmt.Sprintf("/api/exploration/findings/export?mode=consolidated&scope=selected&ids=%d&format=json", f.ID), "")
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), `"id": "case:`) || !strings.Contains(w.Body.String(), `"member_ids"`) {
|
||||
t.Fatal(w.Code, w.Body)
|
||||
}
|
||||
child, err := s.m.CreateTaskWithOptions("inherited folders", "fixture", db.TaskCreateOptions{SourceTaskIDs: []int64{task.ID}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
childID, _ := strconv.ParseInt(child.ID, 10, 64)
|
||||
defer s.m.pg.DeleteTask(childID)
|
||||
base := fmt.Sprintf("/api/exploration/finding-cases/%d", cid)
|
||||
suffix := "?context_task=" + child.ID
|
||||
if w = req("GET", base+suffix, ""); w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body)
|
||||
}
|
||||
if w = req("GET", base+"/members"+suffix, ""); w.Code != 200 || !strings.Contains(w.Body.String(), `"inherited":true`) {
|
||||
t.Fatal(w.Code, w.Body)
|
||||
}
|
||||
if w = req("DELETE", fmt.Sprintf("%s/members/%d%s", base, f.ID, suffix), `{}`); w.Code != 403 {
|
||||
t.Fatal(w.Code, w.Body)
|
||||
}
|
||||
if w = req("PUT", base+"/report"+suffix, `{}`); w.Code != 403 {
|
||||
t.Fatal(w.Code, w.Body)
|
||||
}
|
||||
if w = req("POST", "/api/exploration/finding-case-review"+suffix, fmt.Sprintf(`{"case_id":%d}`, cid)); w.Code != 403 {
|
||||
t.Fatal(w.Code, w.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindingCaseListIncludesContextAndFilteredCounts(t *testing.T) {
|
||||
s, task, ids := testCaseServer(t)
|
||||
cid, err := s.m.pg.MergeFindingCase(t.Context(), task, ids[:3], "same defect", "verified root cause", "human")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := httptest.NewRequest("GET", fmt.Sprintf("/api/finding-cases?task_id=%d&severity=medium", task), nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.listFindingCases(w, r)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var page struct {
|
||||
Total int `json:"total"`
|
||||
MatchingReports int `json:"matching_reports"`
|
||||
Items []struct {
|
||||
Case *db.FindingCase `json:"case"`
|
||||
TaskDescription string `json:"task_description"`
|
||||
Assets []FindingAssetDTO `json:"assets"`
|
||||
AssetCount int `json:"asset_count"`
|
||||
LastFoundAt string `json:"last_found_at"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Total != 1 || page.MatchingReports != 1 || len(page.Items) != 1 || page.Items[0].Case.ID != cid || page.Items[0].Case.Count != 3 || page.Items[0].Case.Report != "" || page.Items[0].TaskDescription == "" || page.Items[0].LastFoundAt == "" || page.Items[0].Assets == nil {
|
||||
t.Fatalf("list payload %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOriginalFindingRowsKeepEvidenceAndOmitReportBody(t *testing.T) {
|
||||
s, task, ids := testCaseServer(t)
|
||||
if _, err := s.m.pg.Exec(`UPDATE findings SET report=$2 WHERE id=$1`, ids[0], "large original report"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, original := range []bool{false, true} {
|
||||
suffix := ""
|
||||
if original {
|
||||
suffix = "&original_rows=1"
|
||||
}
|
||||
r := httptest.NewRequest("GET", fmt.Sprintf("/api/finding-cases?task_id=%d%s", task, suffix), nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.listFindingCases(w, r)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status %d %s", w.Code, w.Body)
|
||||
}
|
||||
var page struct {
|
||||
Items []struct {
|
||||
Finding FindingDTO `json:"finding"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Items) != 3 {
|
||||
t.Fatalf("lost original rows %s", w.Body)
|
||||
}
|
||||
for _, row := range page.Items {
|
||||
if row.Finding.Report != "" {
|
||||
t.Fatal("list loaded report body")
|
||||
}
|
||||
if original && !strings.HasPrefix(row.Finding.Evidence, "proof ") {
|
||||
t.Fatal("original table evidence missing")
|
||||
}
|
||||
if !original && row.Finding.Evidence != "" {
|
||||
t.Fatal("compact list should omit evidence")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ func writeFindingsEvidenceZip(out io.Writer, findings []*db.DBFinding, stage str
|
||||
func writeFindingsZipEntries(zw *zip.Writer, findings []*db.DBFinding, stage string, now time.Time) error {
|
||||
store := evidence.New(nil, nil, stage) // private export copy; not subject to GC
|
||||
used := map[string]int{}
|
||||
evidencePaths := map[string]bool{}
|
||||
for _, f := range findings {
|
||||
base := report.FindingFilename(f)
|
||||
name := base
|
||||
@@ -43,6 +44,10 @@ func writeFindingsZipEntries(zw *zip.Writer, findings []*db.DBFinding, stage str
|
||||
}
|
||||
for _, b := range f.TrafficBindings {
|
||||
prefix := fmt.Sprintf("evidence/%d/%d/", f.ID, b.ID)
|
||||
if evidencePaths[prefix] {
|
||||
continue
|
||||
}
|
||||
evidencePaths[prefix] = true
|
||||
w, err := zw.Create(prefix + "manifest.json")
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -116,6 +116,11 @@ func objectProperty(parent map[string]any, key string) map[string]any {
|
||||
}
|
||||
|
||||
func (s *Server) agentFindingTrafficAccess(ctx context.Context, id int64, write bool) error {
|
||||
if write {
|
||||
if err := s.checkCaseReviewScope(ctx, "bind_finding_traffic", findingCaseRequest{}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if id <= 0 {
|
||||
return errors.New("finding_id 必须为独立漏洞记录 ID;不是探索节点 ID")
|
||||
}
|
||||
|
||||
+13
-3
@@ -63,7 +63,7 @@ func (s *Server) hostTools() ([]actool.CoreTool, map[string][]string) {
|
||||
// orchestrationTools returns the cross-task tool set. Bound per-agent via the
|
||||
// tools table (default: no binding — opt-in for orchestration agents).
|
||||
func (s *Server) orchestrationTools() []actool.CoreTool {
|
||||
return []actool.CoreTool{
|
||||
return append([]actool.CoreTool{
|
||||
s.toolListTasks(),
|
||||
s.toolListLLMProfiles(),
|
||||
s.toolSpawnTask(),
|
||||
@@ -78,7 +78,7 @@ func (s *Server) orchestrationTools() []actool.CoreTool {
|
||||
s.toolUpdateFindingReport(),
|
||||
s.toolGetFindingTraffic(),
|
||||
s.toolBindFindingTraffic(),
|
||||
}
|
||||
}, s.findingCaseTools()...)
|
||||
}
|
||||
|
||||
// --- schema helpers ---
|
||||
@@ -442,6 +442,15 @@ func (s *Server) toolUpdateFindingReport() actool.CoreTool {
|
||||
if nodeID <= 0 {
|
||||
return actool.Errorf("finding_id 无效"), nil
|
||||
}
|
||||
fid, err := s.m.pg.FindingIDByNodeID(nodeID)
|
||||
if err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
if fid > 0 {
|
||||
if err := s.checkCaseReviewScope(ctx, "update_finding_report", findingCaseRequest{FindingID: json.RawMessage(i64s(fid))}); err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
}
|
||||
n, err := s.m.pg.SetFindingReportVersionByNodeID(ctx, nodeID, a.Report, a.EvidenceVersion)
|
||||
if err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
@@ -477,7 +486,7 @@ func (s *Server) seedOrchestrationTools() {
|
||||
for _, t := range s.orchestrationTools() {
|
||||
schema, _ := json.Marshal(t.InputSchema())
|
||||
bindings := autoAgents
|
||||
if t.Name() == "bind_finding_traffic" {
|
||||
if t.Name() == "bind_finding_traffic" || strings.Contains(t.Name(), "finding_case") || t.Name() == "search_finding_duplicates" || t.Name() == "get_finding_record" || t.Name() == "merge_finding_records" || t.Name() == "suggest_finding_merge" {
|
||||
bindings = json.RawMessage(`["reporter"]`)
|
||||
}
|
||||
_ = s.m.PG().SeedTool(t.Name(), t.Description(), schema, bindings)
|
||||
@@ -503,6 +512,7 @@ func (s *Server) seedOrchestrationTools() {
|
||||
s.upgradeReporterTriggerMessage() // 老库补迁移:让 reporter 回传 evidence_version(一次性)
|
||||
s.seedFindingTrafficTools() // 增加可选证据参数及只读证据工具,保留用户配置
|
||||
s.seedFindingWorkflowTools()
|
||||
s.seedFindingCaseTools()
|
||||
// 注:pentest 的默认工具绑定无需迁移——BuiltinToolSeeds 在全新初始化时就把
|
||||
// list_assets/insert_assets/report_finding/list_findings/list_companies 连同
|
||||
// pentest 一起 seed 好了(项目尚无旧库,不做迁移)。
|
||||
|
||||
+73
-5
@@ -43,8 +43,9 @@ type Server struct {
|
||||
engine *Engine
|
||||
ctx context.Context
|
||||
|
||||
skillDir string // root directory for skill subdirectories
|
||||
jwtKey []byte // HS256 signing key loaded from / generated into dataDir/jwt.key
|
||||
skillDir string // root directory for skill subdirectories
|
||||
jwtKey []byte // HS256 signing key loaded from / generated into dataDir/jwt.key
|
||||
basicAuth basicAuthGate
|
||||
|
||||
// concMu serializes concurrency-cap decisions (admission + reconcile) so a
|
||||
// scheduler tick and an HTTP settings change / task creation can't both count
|
||||
@@ -131,6 +132,8 @@ type provEntry struct {
|
||||
// taskID + mergeable let the drainer coalesce several event triggers (finding/goal)
|
||||
// from the SAME task into one conversation before it starts (interval fires don't merge).
|
||||
type triggeredRun struct {
|
||||
conversationID int64 // pre-created manual finding review; never coalesced
|
||||
|
||||
agentKey string
|
||||
title string
|
||||
message string // 事件正文(触发语 + 工具/入参/返回等);不含任务描述/目标头
|
||||
@@ -152,6 +155,11 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
|
||||
provByProfile: map[int64]*provEntry{}, llmHealth: newLLMHealthRegistry(m.pg),
|
||||
taskAgents: map[string]*taskAgentBundle{}, archiveWake: make(chan struct{}, 1)}
|
||||
s.initSideQuestions()
|
||||
if m.pg != nil {
|
||||
if err := s.basicAuth.load(m.pg); err != nil {
|
||||
log.Fatalf("[auth] load HTTP Basic Auth: %v", err)
|
||||
}
|
||||
}
|
||||
// 熔断阈值/冷却是失败路径上的热参数,启动时把全局重试策略推给 Registry 一次;
|
||||
// 之后每次保存策略再推一次(saveLLMRetryPolicy)。
|
||||
s.applyRetryPolicy()
|
||||
@@ -656,6 +664,12 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /api/auth/init", s.authInit)
|
||||
mux.HandleFunc("POST /api/auth/login", s.authLogin)
|
||||
mux.HandleFunc("POST /api/auth/change-password", s.authChangePassword)
|
||||
mux.HandleFunc("GET /api/basic-auth/check", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
mux.HandleFunc("GET /api/settings/basic-auth", s.getBasicAuthSettings)
|
||||
mux.HandleFunc("PUT /api/settings/basic-auth", s.saveBasicAuthSettings)
|
||||
|
||||
mux.HandleFunc("GET /api/health", s.health)
|
||||
mux.HandleFunc("GET /api/stats", s.stats)
|
||||
@@ -755,6 +769,16 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/exploration/frontier", s.frontier)
|
||||
mux.HandleFunc("GET /api/exploration/findings", s.findings)
|
||||
mux.HandleFunc("GET /api/exploration/findings/groups", s.findingGroups)
|
||||
mux.HandleFunc("GET /api/exploration/finding-cases", s.listFindingCases)
|
||||
mux.HandleFunc("POST /api/exploration/finding-cases", s.mergeFindingCase)
|
||||
mux.HandleFunc("GET /api/exploration/finding-cases/{id}", s.getFindingCase)
|
||||
mux.HandleFunc("GET /api/exploration/finding-cases/{id}/members", s.getFindingCaseMembers)
|
||||
mux.HandleFunc("DELETE /api/exploration/finding-cases/{id}/members/{fid}", s.removeFindingCaseMember)
|
||||
mux.HandleFunc("PUT /api/exploration/finding-cases/{id}/report", s.updateCaseReport)
|
||||
mux.HandleFunc("GET /api/exploration/finding-case-suggestions", s.caseSuggestions)
|
||||
mux.HandleFunc("POST /api/exploration/finding-case-suggestions/{id}", s.resolveCaseSuggestion)
|
||||
mux.HandleFunc("POST /api/exploration/finding-case-review", s.reviewFindingCases)
|
||||
mux.HandleFunc("GET /api/exploration/finding-case-review", s.caseReviewRuns)
|
||||
mux.HandleFunc("GET /api/exploration/findings/asset-tree", s.findingAssetTree)
|
||||
mux.HandleFunc("GET /api/exploration/findings/stats", s.findingStats)
|
||||
mux.HandleFunc("GET /api/exploration/findings/export", s.findingsExport)
|
||||
@@ -933,13 +957,13 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/intercept/judge/usage", s.interceptJudgeUsage) // 兜底审批累计 token 用量
|
||||
|
||||
// /api/* goes through CORS + JWT; everything else is served by the embedded
|
||||
// frontend (public — auth is enforced client-side and on the API). With the
|
||||
// frontend (no JWT required; the optional Basic Auth gate wraps both). With the
|
||||
// no-embed build the webui handler just 404s (run `next dev` separately).
|
||||
api := cors(s.requireAuth(mux))
|
||||
root := http.NewServeMux()
|
||||
root.Handle("/api/", api)
|
||||
root.Handle("/", s.webuiHandler())
|
||||
return root
|
||||
return s.requireBasicAuth(root)
|
||||
}
|
||||
|
||||
// --- handlers ---
|
||||
@@ -1040,6 +1064,7 @@ func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
list := s.m.List()
|
||||
metrics, _ := s.m.PG().TaskListMetricsAll()
|
||||
distinct, _ := s.m.pg.DistinctFindingStatsByTask()
|
||||
archiveBlockers, _ := s.m.PG().TaskArchiveBlockers()
|
||||
dtos := make([]TaskDTO, 0, len(list))
|
||||
for _, t := range list {
|
||||
@@ -1056,6 +1081,7 @@ func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
|
||||
dto.GoalsTotal = metric.Goals.Total
|
||||
dto.GoalsMet = metric.Goals.Met
|
||||
dto.InFlight = metric.RunningIntents
|
||||
dto.DistinctFindings = distinct[t.ID]
|
||||
dto.Findings = FindingSeverityDTO{
|
||||
Critical: metric.Findings.Critical,
|
||||
High: metric.Findings.High,
|
||||
@@ -2052,6 +2078,11 @@ func (s *Server) findingStats(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
st.Distinct, err = s.m.pg.DistinctFindingStats("")
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, st)
|
||||
}
|
||||
|
||||
@@ -2074,6 +2105,14 @@ func (s *Server) getFinding(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
assets := s.resolveAssetIDs(f.AssetIDs)
|
||||
dto := findingFromDB(f, assets)
|
||||
cid, err := s.m.pg.FindingCaseID(f.ID)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if cid > 0 {
|
||||
dto.CaseID = i64s(cid)
|
||||
}
|
||||
if contextTaskID := strings.TrimSpace(r.URL.Query().Get("context_task")); contextTaskID != "" {
|
||||
contextTask := s.m.ResolveTask(contextTaskID)
|
||||
if contextTask == nil {
|
||||
@@ -2138,6 +2177,14 @@ func (s *Server) findingsExport(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var casePlan *findingCaseExportPlan
|
||||
if q.Get("mode") == "consolidated" {
|
||||
fs, casePlan, err = s.prepareFindingCaseExport(fs)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
stage, err := os.MkdirTemp("", "artex-finding-export-")
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
@@ -2148,6 +2195,13 @@ func (s *Server) findingsExport(w http.ResponseWriter, r *http.Request) {
|
||||
evidenceError(w, err)
|
||||
return
|
||||
}
|
||||
if casePlan != nil {
|
||||
fs, err = s.consolidateFindingCaseExport(fs, casePlan, q.Get("include_originals") == "true")
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
stamp := now.Format("20060102-150405")
|
||||
setDownload := func(contentType, filename string) {
|
||||
@@ -3880,8 +3934,22 @@ func (s *Server) gc(w http.ResponseWriter, r *http.Request) {
|
||||
func cors(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
// Next dev and the Go SSE endpoint use different ports on the same
|
||||
// loopback host. Allow the gate cookie only for that local pairing.
|
||||
origin := r.Header.Get("Origin")
|
||||
u, err := url.Parse(origin)
|
||||
host, _, hostErr := net.SplitHostPort(r.Host)
|
||||
if hostErr != nil {
|
||||
host = r.Host
|
||||
}
|
||||
if err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() == host &&
|
||||
(host == "localhost" || host == "127.0.0.1" || host == "::1") {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
w.Header().Add("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(204)
|
||||
return
|
||||
|
||||
@@ -9,12 +9,15 @@ import {
|
||||
ChevronRightIcon,
|
||||
FileTextIcon,
|
||||
FlaskConicalIcon,
|
||||
FolderIcon,
|
||||
FolderOpenIcon,
|
||||
RotateCcwIcon,
|
||||
ShieldAlertIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { FindingCaseMembers, FindingSeverityCounts } from "@/components/finding-case-list";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import {
|
||||
@@ -37,7 +40,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { statusMeta } from "@/lib/status";
|
||||
import type { ActiveFindingRetest, Finding, FindingStatus, Severity } from "@/lib/types";
|
||||
import type { ActiveFindingRetest, Finding, FindingCaseListRow, FindingStatus, Severity } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const SEVERITIES: Severity[] = ["critical", "high", "medium", "low"];
|
||||
@@ -90,6 +93,10 @@ const COLUMN_COUNT = 9;
|
||||
|
||||
interface FindingsTableProps {
|
||||
items: Finding[];
|
||||
caseRows?: FindingCaseListRow[];
|
||||
hideHeader?: boolean;
|
||||
matchedIds?: number[];
|
||||
presentation?: "records" | "task" | "asset";
|
||||
selectedIds: Set<string>;
|
||||
onToggleSelected: (id: string, checked: boolean) => void;
|
||||
onToggleSelectedPage: (ids: string[], checked: boolean) => void;
|
||||
@@ -114,6 +121,10 @@ interface FindingsTableProps {
|
||||
// (勾选 / 行内展开 / 行内改名与改状态 / 复测 / 深入 / 删除),差异只在外层容器与分页。
|
||||
export function FindingsTable({
|
||||
items,
|
||||
caseRows,
|
||||
hideHeader = false,
|
||||
matchedIds,
|
||||
presentation = "records",
|
||||
selectedIds,
|
||||
onToggleSelected,
|
||||
onToggleSelectedPage,
|
||||
@@ -140,37 +151,79 @@ export function FindingsTable({
|
||||
headerChecked = "indeterminate";
|
||||
}
|
||||
|
||||
const rows: FindingCaseListRow[] = caseRows ?? items.map((finding) => ({ finding, matched_ids: [] }));
|
||||
|
||||
const shared: Omit<FindingsTableProps, "items" | "caseRows"> = {
|
||||
presentation,
|
||||
selectedIds,
|
||||
onToggleSelected,
|
||||
onToggleSelectedPage,
|
||||
expandedKey,
|
||||
onToggleRow,
|
||||
reports,
|
||||
edit,
|
||||
onEditChange,
|
||||
saving,
|
||||
onSave,
|
||||
onStatusChange,
|
||||
onRetest,
|
||||
activeRetests,
|
||||
onDeepen,
|
||||
onDelete,
|
||||
};
|
||||
|
||||
return (
|
||||
/* 固定列宽保证展开内容不撑开表格;窄屏只在表格内部横向滚动。 */
|
||||
<Table className="min-w-[60rem] table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8">
|
||||
<Checkbox
|
||||
checked={headerChecked}
|
||||
onCheckedChange={(checked) => onToggleSelectedPage(selectableIds, checked === true)}
|
||||
aria-label={selectAllLabel}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-8" />
|
||||
<TableHead className="w-20">严重度</TableHead>
|
||||
<TableHead>漏洞名称</TableHead>
|
||||
<TableHead className="w-44">资产</TableHead>
|
||||
<TableHead className="w-28">状态</TableHead>
|
||||
<TableHead className="w-32">所属任务</TableHead>
|
||||
<TableHead className="w-24">时间</TableHead>
|
||||
<TableHead className="w-48">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
{hideHeader ? (
|
||||
<colgroup>
|
||||
<col className="w-8" />
|
||||
<col className="w-8" />
|
||||
<col className="w-20" />
|
||||
<col />
|
||||
<col className="w-44" />
|
||||
<col className="w-28" />
|
||||
<col className="w-32" />
|
||||
<col className="w-24" />
|
||||
<col className="w-48" />
|
||||
</colgroup>
|
||||
) : null}
|
||||
{!hideHeader ? (
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8">
|
||||
<Checkbox
|
||||
checked={headerChecked}
|
||||
onCheckedChange={(checked) => onToggleSelectedPage(selectableIds, checked === true)}
|
||||
aria-label={selectAllLabel}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-8" />
|
||||
<TableHead className="w-20">严重度</TableHead>
|
||||
<TableHead>漏洞名称</TableHead>
|
||||
<TableHead className="w-44">资产</TableHead>
|
||||
<TableHead className="w-28">状态</TableHead>
|
||||
<TableHead className="w-32">所属任务</TableHead>
|
||||
<TableHead className="w-24">时间</TableHead>
|
||||
<TableHead className="w-48">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
) : null}
|
||||
<TableBody>
|
||||
{items.map((f) => {
|
||||
{rows.map((row) => {
|
||||
if (row.case) return <FindingFolderTableRows key={`case:${row.case.id}`} row={row} shared={shared} />;
|
||||
const f = row.finding;
|
||||
if (!f) return null;
|
||||
const rowKey = findingRowKey(f);
|
||||
const open = expandedKey === rowKey;
|
||||
const retest = f.finding_id ? activeRetests[f.finding_id] : undefined;
|
||||
return (
|
||||
<React.Fragment key={rowKey}>
|
||||
<TableRow
|
||||
className="cursor-pointer"
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
matchedIds && !matchedIds.includes(Number(f.finding_id)) && "bg-muted/30",
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={open}
|
||||
@@ -213,6 +266,11 @@ export function FindingsTable({
|
||||
<span className="truncate font-medium">{f.name || f.vulnclass || "未分类"}</span>
|
||||
)}
|
||||
<span className="truncate text-xs text-muted-foreground">{f.summary}</span>
|
||||
{matchedIds && !matchedIds.includes(Number(f.finding_id)) ? (
|
||||
<Badge variant="outline" className="w-fit">
|
||||
未命中当前筛选
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge variant="outline">流量证据 {f.traffic_count ?? 0} 条</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -441,7 +499,7 @@ export function FindingsTable({
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{items.length === 0 && (
|
||||
{(caseRows ? caseRows.length : items.length) === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={COLUMN_COUNT} className="py-12 text-center text-sm text-muted-foreground">
|
||||
没有匹配的发现。
|
||||
@@ -452,3 +510,121 @@ export function FindingsTable({
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
// Folder summaries occupy the same columns as the current view's ordinary rows.
|
||||
// Expanded members reuse those rows without a second heading or column header.
|
||||
function FindingFolderTableRows({
|
||||
row,
|
||||
shared,
|
||||
}: {
|
||||
row: FindingCaseListRow;
|
||||
shared: Omit<FindingsTableProps, "items" | "caseRows">;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const group = row.case;
|
||||
if (!group) return null;
|
||||
// Match the current table's ordinary rows, which display the task description.
|
||||
const taskLabel = row.task_description;
|
||||
const assets = row.assets ?? [];
|
||||
const assetCount = row.asset_count ?? assets.length;
|
||||
return (
|
||||
<>
|
||||
<TableRow className={cn(open && "bg-muted/60")}>
|
||||
<TableCell />
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-controls={`finding-table-members-${group.id}`}
|
||||
aria-label={`${open ? "收起" : "展开"}${group.title}`}
|
||||
>
|
||||
<ChevronRightIcon className={cn("size-4 transition-transform", open && "rotate-90")} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FindingSeverityCounts counts={group} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
{open ? (
|
||||
<FolderOpenIcon className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||
) : (
|
||||
<FolderIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<Link
|
||||
className="truncate font-medium hover:text-primary hover:underline"
|
||||
title={group.title}
|
||||
href={`/function/findings/case?id=${group.id}`}
|
||||
>
|
||||
{group.title}
|
||||
</Link>
|
||||
<span className="truncate text-muted-foreground text-xs">
|
||||
{group.count} 条上报 · {group.report_version !== group.version ? "统一报告待更新" : "统一报告已生成"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{assets.length ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{assets.slice(0, 3).map((a) => (
|
||||
<code key={a.id} className="max-w-full truncate text-xs" title={`${a.type} · ${a.label}`}>
|
||||
{a.label}
|
||||
</code>
|
||||
))}
|
||||
{assetCount > 3 ? <span className="text-muted-foreground text-xs">+{assetCount - 3}</span> : null}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-muted-foreground" title="请查看成员各自的处置状态">
|
||||
—
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{group.task_id ? (
|
||||
<Link
|
||||
className="inline-flex max-w-full items-center gap-1 text-primary hover:underline"
|
||||
title={taskLabel}
|
||||
href={`/function/tasks/detail?id=${group.task_id}`}
|
||||
>
|
||||
<span className="truncate">{taskLabel}</span>
|
||||
<ArrowUpRightIcon className="size-3 shrink-0" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs tabular-nums">
|
||||
{row.last_found_at ? fmtTime(row.last_found_at) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link href={`/function/findings/case?id=${group.id}`}>统一报告</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{open ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={COLUMN_COUNT} className="whitespace-normal bg-muted/20 px-4 py-4">
|
||||
<section id={`finding-table-members-${group.id}`} aria-label={`${group.title}的原始上报`}>
|
||||
<FindingCaseMembers
|
||||
caseId={group.id}
|
||||
version={group.version}
|
||||
nested
|
||||
renderRecords={(members) => (
|
||||
<FindingsTable {...shared} items={members} matchedIds={row.matched_ids} hideHeader />
|
||||
)}
|
||||
/>
|
||||
</section>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { FindingCaseMembers, FindingCaseReviewPanel, FindingSeverityCounts } from "@/components/finding-case-list";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { api } from "@/lib/api";
|
||||
import type { FindingCaseDetail } from "@/lib/types";
|
||||
|
||||
function CaseDetail() {
|
||||
const params = useSearchParams();
|
||||
const id = params.get("id") ?? "";
|
||||
const contextTask = params.get("context_task") ?? undefined;
|
||||
const [data, setData] = React.useState<FindingCaseDetail | null>(null);
|
||||
const [error, setError] = React.useState("");
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [selected, setSelected] = React.useState<Set<string>>(() => new Set());
|
||||
const [refresh, setRefresh] = React.useState(0);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: refresh explicitly reloads group after a detach.
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
async function load() {
|
||||
try {
|
||||
const v = await api.getFindingCase(id, contextTask);
|
||||
if (active) {
|
||||
setData(v);
|
||||
setError("");
|
||||
}
|
||||
} catch (e) {
|
||||
if (active) setError((e as Error).message);
|
||||
} finally {
|
||||
if (active) timer = setTimeout(load, 5000);
|
||||
}
|
||||
}
|
||||
if (id) void load();
|
||||
return () => {
|
||||
active = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [id, contextTask, refresh]);
|
||||
const readOnly = !!contextTask && contextTask !== data?.case.task_id;
|
||||
const select = (fid: string, on: boolean) =>
|
||||
setSelected((v) => {
|
||||
const n = new Set(v);
|
||||
if (on) n.add(fid);
|
||||
else n.delete(fid);
|
||||
return n;
|
||||
});
|
||||
async function regenerate() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.regenerateFindingCase(id, contextTask);
|
||||
toast.success("已提交重新整理,可查看执行状态");
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function detach() {
|
||||
setBusy(true);
|
||||
try {
|
||||
for (const fid of selected) await api.removeFindingCaseMember(id, fid, "人工撤销归并");
|
||||
setSelected(new Set());
|
||||
setRefresh((v) => v + 1);
|
||||
toast.success("已移出,原始记录保留");
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
if (!data)
|
||||
return error ? (
|
||||
<Alert>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Spinner />
|
||||
);
|
||||
const c = data.case;
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/function/findings">返回漏洞列表</Link>
|
||||
</Button>
|
||||
<h1 className="font-semibold text-xl">{c.title}</h1>
|
||||
<FindingSeverityCounts counts={c} />
|
||||
</div>
|
||||
{error ? (
|
||||
<Alert>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{!c.active ? (
|
||||
<Alert>
|
||||
<AlertDescription>此文件夹已解散或并入其他组,原始记录仍可从漏洞列表查看。</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{readOnly ? (
|
||||
<Alert>
|
||||
<AlertDescription>继承任务的漏洞组,只读。</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<FindingCaseReviewPanel taskId={c.task_id ?? undefined} />
|
||||
)}
|
||||
<Tabs defaultValue="report">
|
||||
<TabsList>
|
||||
<TabsTrigger value="report">统一报告</TabsTrigger>
|
||||
<TabsTrigger value="members">原始上报({c.count})</TabsTrigger>
|
||||
<TabsTrigger value="history">归并历史</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="report">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>完整复现与修复报告</CardTitle>
|
||||
<CardDescription>集中查看所有成员的复现步骤、证据与修复建议。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex min-w-0 flex-col gap-4">
|
||||
{c.report_version !== c.version ? (
|
||||
<Alert>
|
||||
<AlertDescription>成员或证据已变化,统一报告待更新。</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{!readOnly && c.active ? (
|
||||
<Button variant="outline" onClick={regenerate} disabled={busy}>
|
||||
{busy ? <Spinner /> : null}重新生成统一报告
|
||||
</Button>
|
||||
) : null}
|
||||
{c.report ? (
|
||||
<Markdown text={c.report} />
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">统一报告尚未生成,原始报告可在“原始上报”中查看。</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="members">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>原始报告和证据完整保留</CardTitle>
|
||||
<CardDescription>各条原始上报保留各自的等级、报告和证据。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!readOnly && selected.size > 0 ? (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" disabled={busy}>
|
||||
移出所选({selected.size})
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>移出归并组?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
原报告和证据保留,Agent 不会自动把这些关系再次归并。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={detach}>移出</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : null}
|
||||
<FindingCaseMembers
|
||||
caseId={id}
|
||||
version={c.version}
|
||||
selectedIds={selected}
|
||||
onSelect={readOnly ? undefined : select}
|
||||
contextTask={contextTask}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="history">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>判断依据与操作记录</CardTitle>
|
||||
<CardDescription>{c.reason}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{data.events.map((e) => (
|
||||
<div key={e.id} className="border-b pb-3 text-sm">
|
||||
<p>
|
||||
{({ reporter: "报告 Agent", human: "人工", system: "系统" } as Record<string, string>)[e.actor] ??
|
||||
e.actor}{" "}
|
||||
·{" "}
|
||||
{(
|
||||
{
|
||||
merge: "归并",
|
||||
detach: "移出成员",
|
||||
member_deleted: "成员移出或删除",
|
||||
report_updated: "更新统一报告",
|
||||
} as Record<string, string>
|
||||
)[e.action] ?? e.action}{" "}
|
||||
· {new Date(e.created_at).toLocaleString("zh-CN")}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{e.reason}
|
||||
{e.finding_ids.length > 0 ? ` · #${e.finding_ids.join(" / #")}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default function Page() {
|
||||
return (
|
||||
<React.Suspense fallback={<Spinner />}>
|
||||
<CaseDetail />
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
@@ -140,6 +140,15 @@ function FindingDetailInner() {
|
||||
</Link>
|
||||
</Button>
|
||||
<ShieldAlertIcon className="size-4 text-muted-foreground" />
|
||||
{finding.case_id ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link
|
||||
href={`/function/findings/case?id=${finding.case_id}${contextTaskId ? `&context_task=${contextTaskId}` : ""}`}
|
||||
>
|
||||
所属漏洞文件夹
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<h1 className="max-w-md truncate text-sm font-semibold" title={title}>
|
||||
{title}
|
||||
</h1>
|
||||
@@ -172,7 +181,7 @@ function FindingDetailInner() {
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">{finding.summary || "(无摘要)"}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FindingRetestPanel key={id} findingId={id} readOnly={finding.inherited} onCompleted={load} />
|
||||
<FindingRetestPanel key={`retest:${id}`} findingId={id} readOnly={finding.inherited} onCompleted={load} />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">证据 / PoC</CardTitle>
|
||||
@@ -188,7 +197,7 @@ function FindingDetailInner() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FindingTrafficPanel
|
||||
key={id}
|
||||
key={`traffic:${id}`}
|
||||
findingId={id}
|
||||
contextTask={contextTaskId || undefined}
|
||||
readOnly={finding.inherited}
|
||||
|
||||
@@ -17,11 +17,13 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { FindingCaseList } from "@/components/finding-case-list";
|
||||
import { FindingRetestDialog } from "@/components/finding-retest-dialog";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { TablePagination } from "@/components/table-pagination";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -45,6 +47,7 @@ import type {
|
||||
ActiveFindingRetest,
|
||||
Finding,
|
||||
FindingAssetNode,
|
||||
FindingCaseListRow,
|
||||
FindingGroup,
|
||||
FindingStats,
|
||||
FindingStatus,
|
||||
@@ -69,9 +72,9 @@ const FINDING_LIST_PREFERENCE_KEY = "artex_finding_list_preferences";
|
||||
|
||||
// 列表视图:flat = 跨任务平铺大表(默认);grouped = 按任务分组折叠;
|
||||
// asset = 左侧资产树 + 右侧该子树下的发现。
|
||||
type FindingView = "flat" | "grouped" | "asset";
|
||||
type FindingView = "cases" | "flat" | "grouped" | "asset";
|
||||
|
||||
const FINDING_VIEWS: FindingView[] = ["flat", "grouped", "asset"];
|
||||
const FINDING_VIEWS: FindingView[] = ["cases", "flat", "grouped", "asset"];
|
||||
|
||||
// 资产树的一次性快照。与另外两个视图不同,资产视图不轮询:进入视图、改筛选、
|
||||
// 或本页改动了发现之后才重新查询。
|
||||
@@ -95,6 +98,7 @@ const EMPTY_ASSET_TREE: AssetTreeState = {
|
||||
|
||||
// 分组视图里每个已展开任务组自带一份分页状态,彼此独立。
|
||||
interface GroupFindingsState {
|
||||
caseRows: FindingCaseListRow[];
|
||||
items: Finding[];
|
||||
total: number;
|
||||
page: number;
|
||||
@@ -105,13 +109,14 @@ interface GroupFindingsState {
|
||||
|
||||
// 平铺视图的页码单独放 state(而非塞进快照),筛选一变就能连带重置并触发重新加载。
|
||||
interface FlatFindingsState {
|
||||
caseRows: FindingCaseListRow[];
|
||||
items: Finding[];
|
||||
total: number;
|
||||
loaded: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_FLAT_STATE: FlatFindingsState = { items: [], total: 0, loaded: false, loading: false };
|
||||
const EMPTY_FLAT_STATE: FlatFindingsState = { caseRows: [], items: [], total: 0, loaded: false, loading: false };
|
||||
|
||||
function findingGroupKey(group: FindingGroup) {
|
||||
return group.task_id === null ? UNASSIGNED_TASK : String(group.task_id);
|
||||
@@ -129,7 +134,7 @@ const EMPTY_STATS: FindingStats = {
|
||||
};
|
||||
|
||||
export default function FindingsPage() {
|
||||
const [view, setView] = React.useState<FindingView>("flat");
|
||||
const [view, setView] = React.useState<FindingView>("cases");
|
||||
const [severity, setSeverity] = React.useState<"all" | Severity>("all");
|
||||
const [status, setStatus] = React.useState<"all" | FindingStatus>("all");
|
||||
const [vulnclass, setVulnclass] = React.useState<string>("all");
|
||||
@@ -236,11 +241,21 @@ export default function FindingsPage() {
|
||||
|
||||
// setFindings 同时改写两个视图缓存里的同一条发现,切换视图不会看到过期状态。
|
||||
const setFindings = React.useCallback((update: (current: Finding[]) => Finding[]) => {
|
||||
setFlat((current) => ({ ...current, items: update(current.items) }));
|
||||
function applyUpdate<T extends { items: Finding[]; caseRows: FindingCaseListRow[] }>(current: T): T {
|
||||
const items = update(current.items);
|
||||
const byId = new Map(items.map((f) => [findingRowKey(f), f]));
|
||||
const caseRows = current.caseRows.flatMap((row) => {
|
||||
if (row.case) return [row];
|
||||
const finding = row.finding ? byId.get(findingRowKey(row.finding)) : undefined;
|
||||
return finding ? [{ ...row, finding }] : [];
|
||||
});
|
||||
return { ...current, items, caseRows };
|
||||
}
|
||||
setFlat(applyUpdate);
|
||||
setGroupFindings((current) => {
|
||||
const next: Record<string, GroupFindingsState> = {};
|
||||
for (const [key, state] of Object.entries(current)) {
|
||||
next[key] = { ...state, items: update(state.items) };
|
||||
next[key] = applyUpdate(state);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
@@ -252,6 +267,8 @@ export default function FindingsPage() {
|
||||
const [exportOpen, setExportOpen] = React.useState(false);
|
||||
const [exportScope, setExportScope] = React.useState<"filtered" | "all" | "selected">("filtered");
|
||||
const [exportFormat, setExportFormat] = React.useState<"md-single" | "md-zip" | "csv" | "json">("md-single");
|
||||
const [includeOriginals, setIncludeOriginals] = React.useState(false);
|
||||
const [reviewing, setReviewing] = React.useState(false);
|
||||
const [exporting, setExporting] = React.useState(false);
|
||||
|
||||
const toggleSelected = React.useCallback((id: string, checked: boolean) => {
|
||||
@@ -286,8 +303,17 @@ export default function FindingsPage() {
|
||||
await api.exportFindings({
|
||||
format: exportFormat,
|
||||
scope: exportScope,
|
||||
filters: { severity, status, vulnclass, task, query, sort },
|
||||
filters: {
|
||||
severity,
|
||||
status,
|
||||
vulnclass,
|
||||
task,
|
||||
query,
|
||||
sort,
|
||||
assetScope: view === "asset" ? (assetScope ?? undefined) : undefined,
|
||||
},
|
||||
ids: [...selectedIds],
|
||||
includeOriginals,
|
||||
});
|
||||
setExportOpen(false);
|
||||
toast.success("已开始下载导出文件");
|
||||
@@ -321,19 +347,28 @@ export default function FindingsPage() {
|
||||
const request = ++flatRequest.current;
|
||||
setFlat((current) => ({ ...current, loading: true }));
|
||||
try {
|
||||
const result = await api.findingsPage({
|
||||
page: flatPage,
|
||||
pageSize: flatPageSize,
|
||||
severity,
|
||||
status,
|
||||
vulnclass,
|
||||
task,
|
||||
query,
|
||||
sort,
|
||||
assetScope: activeAssetScope ?? undefined,
|
||||
});
|
||||
const result = await api.findingCases(
|
||||
{
|
||||
page: flatPage,
|
||||
pageSize: flatPageSize,
|
||||
severity,
|
||||
status,
|
||||
vulnclass,
|
||||
task,
|
||||
query,
|
||||
sort,
|
||||
assetScope: activeAssetScope ?? undefined,
|
||||
},
|
||||
true,
|
||||
);
|
||||
if (request !== flatRequest.current || activeFilterFingerprint.current !== requestFilter) return;
|
||||
setFlat({ items: result.items, total: result.total, loaded: true, loading: false });
|
||||
setFlat({
|
||||
caseRows: result.items,
|
||||
items: result.items.flatMap((row) => (row.finding ? [row.finding] : [])),
|
||||
total: result.total,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
});
|
||||
} catch {
|
||||
if (request !== flatRequest.current || activeFilterFingerprint.current !== requestFilter) return;
|
||||
// Polling keeps the last successful snapshot visible.
|
||||
@@ -399,6 +434,7 @@ export default function FindingsPage() {
|
||||
setGroupFindings((current) => ({
|
||||
...current,
|
||||
[key]: {
|
||||
caseRows: current[key]?.caseRows ?? [],
|
||||
items: current[key]?.items ?? [],
|
||||
total: current[key]?.total ?? 0,
|
||||
page: groupPage,
|
||||
@@ -408,21 +444,25 @@ export default function FindingsPage() {
|
||||
},
|
||||
}));
|
||||
try {
|
||||
const result = await api.findingsPage({
|
||||
page: groupPage,
|
||||
pageSize: groupPageSize,
|
||||
severity,
|
||||
status,
|
||||
vulnclass,
|
||||
task: key,
|
||||
query,
|
||||
sort,
|
||||
});
|
||||
const result = await api.findingCases(
|
||||
{
|
||||
page: groupPage,
|
||||
pageSize: groupPageSize,
|
||||
severity,
|
||||
status,
|
||||
vulnclass,
|
||||
task: key,
|
||||
query,
|
||||
sort,
|
||||
},
|
||||
true,
|
||||
);
|
||||
if (groupRequests.current[key] !== request || activeFilterFingerprint.current !== requestFilter) return;
|
||||
setGroupFindings((current) => ({
|
||||
...current,
|
||||
[key]: {
|
||||
items: result.items,
|
||||
caseRows: result.items,
|
||||
items: result.items.flatMap((row) => (row.finding ? [row.finding] : [])),
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
pageSize: result.page_size,
|
||||
@@ -436,6 +476,7 @@ export default function FindingsPage() {
|
||||
...current,
|
||||
[key]: {
|
||||
...(current[key] ?? {
|
||||
caseRows: [],
|
||||
items: [],
|
||||
total: 0,
|
||||
page: groupPage,
|
||||
@@ -752,17 +793,18 @@ export default function FindingsPage() {
|
||||
}
|
||||
|
||||
const statCards = [
|
||||
{ label: "发现总数", value: stats.total, icon: BugIcon },
|
||||
{ label: "待处理", value: stats.pending, tone: "text-amber-500", icon: ClockIcon },
|
||||
{ label: "严重", value: stats.critical, tone: "text-rose-600", icon: ShieldAlertIcon },
|
||||
{ label: "高危", value: stats.high, tone: "text-red-500", icon: TriangleAlertIcon },
|
||||
{ label: "中危", value: stats.medium, tone: "text-amber-500", icon: TriangleAlertIcon },
|
||||
{ label: "低危", value: stats.low, tone: "text-slate-500", icon: InfoIcon },
|
||||
{ label: "独立漏洞", value: stats.distinct?.total ?? stats.total, icon: BugIcon },
|
||||
{ label: "上报记录", value: stats.total, tone: "text-amber-500", icon: ClockIcon },
|
||||
{ label: "严重", value: stats.distinct?.critical ?? stats.critical, tone: "text-rose-600", icon: ShieldAlertIcon },
|
||||
{ label: "高危", value: stats.distinct?.high ?? stats.high, tone: "text-red-500", icon: TriangleAlertIcon },
|
||||
{ label: "中危", value: stats.distinct?.medium ?? stats.medium, tone: "text-amber-500", icon: TriangleAlertIcon },
|
||||
{ label: "低危", value: stats.distinct?.low ?? stats.low, tone: "text-slate-500", icon: InfoIcon },
|
||||
];
|
||||
|
||||
// 导出弹窗里「当前筛选」的条数:两个视图的筛选一致,只是统计口径来源不同。
|
||||
// 平铺与资产视图共用 flat 列表状态,分组视图的口径来自组接口的 finding_total。
|
||||
const filteredTotal = view === "grouped" ? total : flat.total;
|
||||
const [caseTotal, setCaseTotal] = React.useState(0);
|
||||
const filteredTotal = view === "cases" ? caseTotal : view === "grouped" ? total : flat.total;
|
||||
const assetPath = React.useMemo(
|
||||
() => (view === "asset" ? assetPathOf(assetTree.nodes, assetScope) : []),
|
||||
[assetScope, assetTree.nodes, view],
|
||||
@@ -796,7 +838,13 @@ export default function FindingsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<FindingsTable items={flat.items} selectAllLabel="选择当前页全部" {...rowProps} />
|
||||
<FindingsTable
|
||||
items={flat.items}
|
||||
caseRows={flat.caseRows}
|
||||
presentation={view === "asset" ? "asset" : "records"}
|
||||
selectAllLabel="选择当前页全部"
|
||||
{...rowProps}
|
||||
/>
|
||||
<TablePagination
|
||||
page={flatPage}
|
||||
pageSize={flatPageSize}
|
||||
@@ -823,7 +871,8 @@ export default function FindingsPage() {
|
||||
</div>
|
||||
<Tabs value={view} onValueChange={(v) => setView(v as FindingView)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="flat">全部发现</TabsTrigger>
|
||||
<TabsTrigger value="cases">按漏洞</TabsTrigger>
|
||||
<TabsTrigger value="flat">原始上报</TabsTrigger>
|
||||
<TabsTrigger value="grouped">按任务分组</TabsTrigger>
|
||||
<TabsTrigger value="asset">按资产</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -953,6 +1002,24 @@ export default function FindingsPage() {
|
||||
{selectedIds.size > 0 && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">已选 {selectedIds.size} 条</span>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={reviewing || selectedIds.size === 0}
|
||||
onClick={async () => {
|
||||
setReviewing(true);
|
||||
try {
|
||||
await api.reviewFindingCases([...selectedIds]);
|
||||
toast.success("已提交整理,按任务分别执行");
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setReviewing(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{reviewing ? <Spinner /> : null}整理所选({selectedIds.size})
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={openExport}>
|
||||
<DownloadIcon /> 导出
|
||||
</Button>
|
||||
@@ -1015,12 +1082,21 @@ export default function FindingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === "cases" && (
|
||||
<FindingCaseList
|
||||
onTotal={setCaseTotal}
|
||||
query={{ severity, status, vulnclass, task, query, sort }}
|
||||
selectedIds={selectedIds}
|
||||
onSelect={toggleSelected}
|
||||
/>
|
||||
)}
|
||||
{view === "grouped" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{groups.map((group) => {
|
||||
const key = findingGroupKey(group);
|
||||
const groupOpen = expandedGroups.has(key);
|
||||
const state = groupFindings[key] ?? {
|
||||
caseRows: [],
|
||||
items: [],
|
||||
total: group.count,
|
||||
page: 1,
|
||||
@@ -1093,7 +1169,13 @@ export default function FindingsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<FindingsTable items={state.items} selectAllLabel="选择本组当前页全部" {...rowProps} />
|
||||
<FindingsTable
|
||||
items={state.items}
|
||||
caseRows={state.caseRows}
|
||||
presentation="task"
|
||||
selectAllLabel="选择本组当前页全部"
|
||||
{...rowProps}
|
||||
/>
|
||||
<TablePagination
|
||||
page={state.page}
|
||||
pageSize={state.pageSize}
|
||||
@@ -1231,6 +1313,16 @@ export default function FindingsPage() {
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs text-muted-foreground">导出格式</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="include-original-reports"
|
||||
checked={includeOriginals}
|
||||
onCheckedChange={(v) => setIncludeOriginals(v === true)}
|
||||
/>
|
||||
<label htmlFor="include-original-reports" className="text-sm">
|
||||
包含原始子报告(默认每个文件夹一份统一报告)
|
||||
</label>
|
||||
</div>
|
||||
<RadioGroup value={exportFormat} onValueChange={(v) => setExportFormat(v as typeof exportFormat)}>
|
||||
<label htmlFor="export-format-md-single" className="flex items-center gap-2 text-sm">
|
||||
<RadioGroupItem id="export-format-md-single" value="md-single" /> Markdown 汇总报告(单个 .md 文件)
|
||||
|
||||
@@ -7,6 +7,7 @@ import Link from "next/link";
|
||||
import { ArrowDownIcon, ArrowUpIcon, ArrowUpRightIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { FindingCaseList } from "@/components/finding-case-list";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -188,36 +189,55 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden py-0">
|
||||
<CardContent className="px-0">
|
||||
<div className="flex items-center border-b px-4 py-2 text-xs text-muted-foreground">
|
||||
<span className="min-w-0 flex-1">漏洞</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 outline-none focus-visible:underline"
|
||||
aria-label={`发现时间当前${sortPreference.direction === "asc" ? "正序" : "倒序"},点击切换排序方向`}
|
||||
onClick={() =>
|
||||
setSortPreference((current) => ({
|
||||
field: "time",
|
||||
direction: current.direction === "asc" ? "desc" : "asc",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<span>发现时间</span>
|
||||
{sortPreference.direction === "asc" ? (
|
||||
<ArrowUpIcon className="size-3.5" />
|
||||
) : (
|
||||
<ArrowDownIcon className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex flex-col gap-4">
|
||||
<FindingCaseList query={{ task: taskId, sort: "time" }} />
|
||||
{[
|
||||
...new Set(
|
||||
items
|
||||
.filter((f) => f.inherited)
|
||||
.map((f) => f.source_task_id)
|
||||
.filter((id): id is string => !!id),
|
||||
),
|
||||
].map((source) => (
|
||||
<div key={source}>
|
||||
<p className="mb-2 text-sm text-muted-foreground">继承任务 #{source} · 只读</p>
|
||||
<FindingCaseList query={{ task: source, sort: "time" }} contextTask={taskId} readOnly />
|
||||
</div>
|
||||
{items.map((f) => (
|
||||
<Row key={f.id} f={f} contextTaskId={taskId} onStatus={onStatus} />
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<p className="px-4 py-8 text-center text-sm text-muted-foreground">本任务及直接关联任务暂无确认发现。</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm text-muted-foreground">查看原始上报列表</summary>
|
||||
<Card className="overflow-hidden py-0">
|
||||
<CardContent className="px-0">
|
||||
<div className="flex items-center border-b px-4 py-2 text-xs text-muted-foreground">
|
||||
<span className="min-w-0 flex-1">漏洞</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 outline-none focus-visible:underline"
|
||||
aria-label={`发现时间当前${sortPreference.direction === "asc" ? "正序" : "倒序"},点击切换排序方向`}
|
||||
onClick={() =>
|
||||
setSortPreference((current) => ({
|
||||
field: "time",
|
||||
direction: current.direction === "asc" ? "desc" : "asc",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<span>发现时间</span>
|
||||
{sortPreference.direction === "asc" ? (
|
||||
<ArrowUpIcon className="size-3.5" />
|
||||
) : (
|
||||
<ArrowDownIcon className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{items.map((f) => (
|
||||
<Row key={f.id} f={f} contextTaskId={taskId} onStatus={onStatus} />
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<p className="px-4 py-8 text-center text-sm text-muted-foreground">本任务及直接关联任务暂无确认发现。</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,7 +50,7 @@ import { Spinner } from "@/components/ui/spinner";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useSideQuestions } from "@/hooks/use-side-questions";
|
||||
import { api, sseUrl } from "@/lib/api";
|
||||
import { api, openSSE } from "@/lib/api";
|
||||
import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode";
|
||||
import { MOCK } from "@/lib/mock/enabled";
|
||||
import { isBtwCommand } from "@/lib/side-questions";
|
||||
@@ -969,9 +969,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
|
||||
};
|
||||
});
|
||||
// Open the single task SSE from the snapshot cursor.
|
||||
const es = new EventSource(
|
||||
sseUrl(`/api/exploration/activity/stream?task=${encodeURIComponent(taskId)}&since=${snapshotRef.current}`),
|
||||
);
|
||||
const es = openSSE(`/api/exploration/activity/stream?task=${encodeURIComponent(taskId)}&since=${snapshotRef.current}`);
|
||||
esRef.current = es;
|
||||
es.onopen = () => setSseLive(true);
|
||||
es.onerror = () => setSseLive(false); // EventSource auto-reconnects; DB compensates the gap
|
||||
|
||||
@@ -950,7 +950,7 @@ export default function TasksPage() {
|
||||
onSort={sortTasksBy}
|
||||
/>
|
||||
<TableHead className="text-center">目标进度</TableHead>
|
||||
<TableHead className="text-center" title="严重 / 高 / 中 / 低">
|
||||
<TableHead className="text-center" title="独立漏洞:严重 / 高 / 中 / 低">
|
||||
漏洞 <span className="text-muted-foreground font-normal">严/高/中/低</span>
|
||||
</TableHead>
|
||||
<TableHead className="text-center">运行中 Worker</TableHead>
|
||||
@@ -1220,14 +1220,16 @@ const TaskRow = React.memo(function TaskRow({
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs tabular-nums">
|
||||
{(() => {
|
||||
const f = task.findings;
|
||||
const f = task.distinct_findings ?? task.findings;
|
||||
const total = f ? f.critical + f.high + f.medium + f.low : 0;
|
||||
if (!f || total === 0) return <span className="text-muted-foreground">0</span>;
|
||||
const seg = (n: number, cls: string) => (
|
||||
<span className={n > 0 ? cls : "text-muted-foreground"}>{n}</span>
|
||||
);
|
||||
if (!f || (total === 0 && !task.distinct_findings?.total))
|
||||
return <span className="text-muted-foreground">0</span>;
|
||||
const seg = (n: number, cls: string) => <span className={n > 0 ? cls : "text-muted-foreground"}>{n}</span>;
|
||||
return (
|
||||
<span className="font-medium whitespace-nowrap" title="严重 / 高 / 中 / 低">
|
||||
<span
|
||||
className="font-medium whitespace-nowrap"
|
||||
title={`独立漏洞:严重 / 高 / 中 / 低;上报 ${task.distinct_findings?.reports ?? total} 条;待评估 ${task.distinct_findings?.unassessed ?? 0} 个`}
|
||||
>
|
||||
{seg(f.critical, "text-rose-600 dark:text-rose-400")}
|
||||
<span className="text-muted-foreground">/</span>
|
||||
{seg(f.high, "text-red-600 dark:text-red-400")}
|
||||
@@ -1235,6 +1237,12 @@ const TaskRow = React.memo(function TaskRow({
|
||||
{seg(f.medium, "text-amber-600 dark:text-amber-400")}
|
||||
<span className="text-muted-foreground">/</span>
|
||||
{seg(f.low, "text-slate-600 dark:text-slate-400")}
|
||||
{task.distinct_findings ? (
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
· {task.distinct_findings.total} 漏洞 / {task.distinct_findings.reports} 上报
|
||||
{task.distinct_findings.unassessed > 0 ? ` · 待评估 ${task.distinct_findings.unassessed}` : ""}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { sseUrl } from "@/lib/api";
|
||||
import { openSSE } from "@/lib/api";
|
||||
import { MOCK } from "@/lib/mock/enabled";
|
||||
import type { LogLine } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -64,7 +64,7 @@ export default function LogsPage() {
|
||||
setLines(MOCK_LOGS);
|
||||
return;
|
||||
}
|
||||
const es = new EventSource(sseUrl("/api/logs/stream?since=0"));
|
||||
const es = openSSE("/api/logs/stream?since=0");
|
||||
es.onmessage = (e) => {
|
||||
if (pausedRef.current) return;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { ShieldCheckIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
export function BasicAuthCard() {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordSet, setPasswordSet] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError("");
|
||||
try {
|
||||
const settings = await api.basicAuthSettings();
|
||||
setEnabled(settings.enabled);
|
||||
setUsername(settings.username);
|
||||
setPasswordSet(settings.password_set);
|
||||
setLoaded(true);
|
||||
} catch (e) {
|
||||
setError(`读取失败:${(e as Error).message}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const save = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const settings = await api.setBasicAuthSettings({ enabled, username: username.trim(), password });
|
||||
setEnabled(settings.enabled);
|
||||
setUsername(settings.username);
|
||||
setPasswordSet(settings.password_set);
|
||||
setPassword("");
|
||||
toast.success(settings.enabled ? "HTTP Basic Auth 已开启,当前浏览器保持访问" : "HTTP Basic Auth 已关闭");
|
||||
} catch (e) {
|
||||
setError(`保存失败:${(e as Error).message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ShieldCheckIcon className="size-4" />
|
||||
HTTP Basic Auth
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
开启后,访客需先通过浏览器原生账号密码验证,再进入 ARTEX 登录页。默认关闭,保存后立即生效。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={save}>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<Field orientation="horizontal">
|
||||
<FieldLabel htmlFor="basic-auth-enabled">启用访问验证</FieldLabel>
|
||||
<Switch
|
||||
id="basic-auth-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
disabled={!loaded || saving}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="basic-auth-username">验证用户名</FieldLabel>
|
||||
<Input
|
||||
id="basic-auth-username"
|
||||
autoComplete="off"
|
||||
value={username}
|
||||
required={enabled}
|
||||
disabled={!loaded || saving}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="独立于 ARTEX 登录账号"
|
||||
/>
|
||||
<FieldDescription>不能包含冒号,最长 128 字节。</FieldDescription>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="basic-auth-password">验证密码</FieldLabel>
|
||||
<Input
|
||||
id="basic-auth-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
required={enabled && !passwordSet}
|
||||
disabled={!loaded || saving}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={passwordSet ? "已设置,留空保持原密码" : "至少 8 位,最多 72 字节"}
|
||||
/>
|
||||
<FieldDescription>与 ARTEX 登录密码独立。公网访问请使用 HTTPS。</FieldDescription>
|
||||
</Field>
|
||||
<FieldDescription>
|
||||
保存不会中断当前浏览器访问。可用无痕窗口检查验证弹窗;浏览器可能缓存凭据。关闭后保留账号密码,便于再次开启。
|
||||
</FieldDescription>
|
||||
{error ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
<CardFooter className="mt-4 gap-2">
|
||||
<Button type="submit" disabled={!loaded || saving}>
|
||||
{saving ? "保存中…" : "保存访问验证"}
|
||||
</Button>
|
||||
{!loaded && error ? (
|
||||
<Button type="button" variant="outline" onClick={load}>
|
||||
重新加载
|
||||
</Button>
|
||||
) : null}
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { api, sseUrl } from "@/lib/api";
|
||||
import { api, openSSE } from "@/lib/api";
|
||||
import type { UpdateCheck, UpdateProgress } from "@/lib/types";
|
||||
|
||||
/** 等待新版本上线的最长时间。一次升级要经过三次进程启动(暂存 → 换装 → 新版),
|
||||
@@ -100,7 +100,7 @@ export function UpdateCard() {
|
||||
// 订阅更新进度。SSE 不走 Next 的 /api 重写(那层会缓冲,事件推不出来)。
|
||||
const openStream = React.useCallback(
|
||||
(fromVersion: string) => {
|
||||
const es = new EventSource(sseUrl("/api/update/stream"));
|
||||
const es = openSSE("/api/update/stream");
|
||||
es.onmessage = (ev) => {
|
||||
let p: UpdateProgress;
|
||||
try {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { CHAT_SEND_MODE_OPTIONS, type ChatSendMode, setChatSendMode, useChatSend
|
||||
import type { Settings } from "@/lib/types";
|
||||
|
||||
import { UpdateCard } from "./_components/update-card";
|
||||
import { BasicAuthCard } from "./_components/basic-auth-card";
|
||||
|
||||
export default function SystemSettingsPage() {
|
||||
const [trafficCapture, setTrafficCapture] = React.useState(false);
|
||||
@@ -267,6 +268,7 @@ export default function SystemSettingsPage() {
|
||||
column-gap 只管列间距,行间距要由子元素自己给。 */}
|
||||
<div className="columns-1 gap-4 md:gap-6 lg:columns-2">
|
||||
<UpdateCard />
|
||||
<BasicAuthCard />
|
||||
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-severity-critical: var(--severity-critical);
|
||||
--color-severity-high: var(--severity-high);
|
||||
--color-severity-medium: var(--severity-medium);
|
||||
--color-severity-low: var(--severity-low);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
@@ -55,6 +59,10 @@
|
||||
/* Default theme styles (used when no data-theme-preset is set or when 'default' is selected).
|
||||
These serve as the fallback; there is no separate default.css file. */
|
||||
:root {
|
||||
--severity-critical: #e11d48;
|
||||
--severity-high: #dc2626;
|
||||
--severity-medium: #d97706;
|
||||
--severity-low: #475569;
|
||||
--radius: 0.625rem;
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
@@ -94,6 +102,10 @@ These serve as the fallback; there is no separate default.css file. */
|
||||
}
|
||||
|
||||
.dark {
|
||||
--severity-critical: #fb7185;
|
||||
--severity-high: #f87171;
|
||||
--severity-medium: #fbbf24;
|
||||
--severity-low: #94a3b8;
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { ChevronRightIcon, FolderIcon, FolderOpenIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { TablePagination } from "@/components/table-pagination";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { api } from "@/lib/api";
|
||||
import type {
|
||||
Finding,
|
||||
FindingCase,
|
||||
FindingCaseListRow,
|
||||
FindingCasePage,
|
||||
FindingCaseReviewRun,
|
||||
FindingCaseSuggestion,
|
||||
FindingQuery,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function FindingSeverityCounts({
|
||||
counts,
|
||||
}: {
|
||||
counts: Pick<FindingCase, "critical" | "high" | "medium" | "low">;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-0.5 text-sm tabular-nums"
|
||||
title="原始上报记录:严重 / 高 / 中 / 低"
|
||||
role="img"
|
||||
aria-label={`严重${counts.critical},高危${counts.high},中危${counts.medium},低危${counts.low}`}
|
||||
>
|
||||
<span className={counts.critical ? "text-severity-critical" : "text-muted-foreground"}>{counts.critical}</span>
|
||||
<span>/</span>
|
||||
<span className={counts.high ? "text-severity-high" : "text-muted-foreground"}>{counts.high}</span>
|
||||
<span>/</span>
|
||||
<span className={counts.medium ? "text-severity-medium" : "text-muted-foreground"}>{counts.medium}</span>
|
||||
<span>/</span>
|
||||
<span className={counts.low ? "text-severity-low" : "text-muted-foreground"}>{counts.low}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function FindingCaseMemberRow({
|
||||
finding: f,
|
||||
selected,
|
||||
onSelect,
|
||||
contextTask,
|
||||
matched = true,
|
||||
nested = false,
|
||||
}: {
|
||||
finding: Finding;
|
||||
selected?: boolean;
|
||||
onSelect?: (id: string, checked: boolean) => void;
|
||||
contextTask?: string;
|
||||
matched?: boolean;
|
||||
nested?: boolean;
|
||||
}) {
|
||||
const id = f.finding_id ?? f.id;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-wrap items-center gap-3 py-3 text-sm",
|
||||
nested
|
||||
? "relative rounded-lg border bg-background px-3 before:absolute before:top-1/2 before:-left-4 before:w-4 before:border-border before:border-t sm:before:-left-5 sm:before:w-5"
|
||||
: "border-b px-4 last:border-b-0",
|
||||
!matched && "bg-muted/30",
|
||||
)}
|
||||
>
|
||||
{onSelect && !f.inherited ? (
|
||||
<Checkbox checked={selected} onCheckedChange={(v) => onSelect(id, v === true)} aria-label={`选择上报 #${id}`} />
|
||||
) : null}
|
||||
<StatusBadge domain="severity" value={f.severity} />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<Link
|
||||
className="truncate font-medium hover:underline"
|
||||
href={`/function/findings/detail?id=${id}${contextTask ? `&context_task=${contextTask}` : ""}`}
|
||||
>
|
||||
#{id} {[f.name, f.vulnclass].find((v) => v?.trim()) ?? "未分类"}
|
||||
</Link>
|
||||
<span className="truncate text-muted-foreground text-xs">{f.summary}</span>
|
||||
</div>
|
||||
{!matched ? <Badge variant="outline">未命中当前筛选</Badge> : null}
|
||||
{f.inherited ? <Badge variant="outline">继承 · 只读</Badge> : null}
|
||||
<StatusBadge domain="finding" value={f.status} />
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={`/function/findings/detail?id=${id}${contextTask ? `&context_task=${contextTask}` : ""}`}>
|
||||
查看报告
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FindingCaseMembers({
|
||||
caseId,
|
||||
version,
|
||||
matchedIds,
|
||||
selectedIds,
|
||||
onSelect,
|
||||
contextTask,
|
||||
nested = false,
|
||||
renderRecords,
|
||||
}: {
|
||||
caseId: string;
|
||||
version?: number;
|
||||
matchedIds?: number[];
|
||||
selectedIds?: Set<string>;
|
||||
onSelect?: (id: string, checked: boolean) => void;
|
||||
contextTask?: string;
|
||||
nested?: boolean;
|
||||
renderRecords?: (items: Finding[]) => React.ReactNode;
|
||||
}) {
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [data, setData] = React.useState<{ items: Finding[]; total: number } | null>(null);
|
||||
const [error, setError] = React.useState("");
|
||||
const [retry, setRetry] = React.useState(0);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: version and retry deliberately reload changed evidence.
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
setError("");
|
||||
api
|
||||
.findingCaseMembers(caseId, page, contextTask)
|
||||
.then((v) => {
|
||||
if (active) setData(v);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (active) setError((e as Error).message);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [caseId, page, version, contextTask, retry]);
|
||||
if (error)
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
成员加载失败:{error}
|
||||
<Button variant="link" onClick={() => setRetry((v) => v + 1)}>
|
||||
重试
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
if (!data)
|
||||
return (
|
||||
<div className="flex justify-center p-5">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={cn("flex min-w-0 flex-col", nested && "gap-2 border-primary/25 border-l-2 pl-4 sm:pl-5")}>
|
||||
{renderRecords
|
||||
? renderRecords(data.items)
|
||||
: data.items.map((f) => (
|
||||
<FindingCaseMemberRow
|
||||
key={f.finding_id}
|
||||
finding={f}
|
||||
nested={nested}
|
||||
selected={selectedIds?.has(f.finding_id ?? f.id)}
|
||||
onSelect={onSelect}
|
||||
contextTask={contextTask}
|
||||
matched={!matchedIds || matchedIds.includes(Number(f.finding_id))}
|
||||
/>
|
||||
))}
|
||||
{data.total > 20 ? (
|
||||
<TablePagination
|
||||
page={page}
|
||||
pageSize={20}
|
||||
total={data.total}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={() => setPage(1)}
|
||||
pageSizeOptions={[20]}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FindingMergeSuggestionCard({
|
||||
suggestion: s,
|
||||
onResolve,
|
||||
}: {
|
||||
suggestion: FindingCaseSuggestion;
|
||||
onResolve: (s: FindingCaseSuggestion, accept: boolean) => Promise<void>;
|
||||
}) {
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
async function resolve(accept: boolean) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await onResolve(s, accept);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Card size="sm" className="min-w-0">
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">疑似重复 · 待确认</Badge>
|
||||
<CardDescription>任务 #{s.task_id}</CardDescription>
|
||||
</div>
|
||||
<CardTitle className="break-words">{s.title}</CardTitle>
|
||||
<CardDescription className="flex flex-wrap gap-3">
|
||||
<Link className="underline underline-offset-4" href={`/function/findings/detail/?id=${s.left_id}`}>
|
||||
查看原报告 #{s.left_id}
|
||||
</Link>
|
||||
<Link className="underline underline-offset-4" href={`/function/findings/detail/?id=${s.right_id}`}>
|
||||
查看原报告 #{s.right_id}
|
||||
</Link>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Collapsible open={expanded} onOpenChange={setExpanded}>
|
||||
{!expanded ? (
|
||||
<p className="line-clamp-2 whitespace-pre-wrap break-words text-muted-foreground text-sm">{s.reason}</p>
|
||||
) : null}
|
||||
<CollapsibleContent className="whitespace-pre-wrap break-words text-muted-foreground text-sm">
|
||||
{s.reason}
|
||||
</CollapsibleContent>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="link" size="sm">
|
||||
{expanded ? "收起判断依据" : "展开判断依据"}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</Collapsible>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" disabled={busy} onClick={() => resolve(true)}>
|
||||
{busy ? <Spinner /> : null}确认归并
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => resolve(false)}>
|
||||
不是同一漏洞
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function FindingReviewRunRow({ run: r }: { run: FindingCaseReviewRun }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 text-sm">
|
||||
{r.state === "running" ? <Spinner /> : null}
|
||||
<span>
|
||||
任务 #{r.task_id} · 整理 #{r.conversation_id}
|
||||
</span>
|
||||
<Badge variant={r.state === "failed" ? "destructive" : "secondary"}>
|
||||
{{ queued: "排队中", running: "运行中", done: "已完成", failed: "失败" }[r.state]}
|
||||
</Badge>
|
||||
<Link className="underline underline-offset-4" href={`/chat?c=${r.conversation_id}`}>
|
||||
查看执行记录
|
||||
</Link>
|
||||
{r.error ? <p className="w-full break-words text-destructive">{r.error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FindingCaseReviewPanel({ taskId, onChange }: { taskId?: string; onChange?: () => void }) {
|
||||
const [suggestions, setSuggestions] = React.useState<FindingCaseSuggestion[]>([]);
|
||||
const [runs, setRuns] = React.useState<FindingCaseReviewRun[]>([]);
|
||||
const [error, setError] = React.useState("");
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
async function load() {
|
||||
try {
|
||||
const [s, r] = await Promise.all([api.findingCaseSuggestions(taskId), api.findingCaseReviewRuns()]);
|
||||
if (active) {
|
||||
setSuggestions(s);
|
||||
setRuns(r.filter((x) => !taskId || x.task_id === taskId));
|
||||
setError("");
|
||||
}
|
||||
} catch (e) {
|
||||
if (active) setError((e as Error).message);
|
||||
} finally {
|
||||
if (active) timer = setTimeout(load, 5000);
|
||||
}
|
||||
}
|
||||
void load();
|
||||
return () => {
|
||||
active = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [taskId]);
|
||||
async function resolve(s: FindingCaseSuggestion, accept: boolean) {
|
||||
try {
|
||||
const result = await api.resolveFindingCaseSuggestion(s.id, accept);
|
||||
setSuggestions((v) => v.filter((x) => x.id !== s.id));
|
||||
if (accept && result.case_id !== "0") {
|
||||
toast.success("已归并,请进入文件夹生成统一报告");
|
||||
} else {
|
||||
toast.success("已否决,不会自动再次归并");
|
||||
}
|
||||
onChange?.();
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{error ? (
|
||||
<Alert>
|
||||
<AlertDescription>整理状态加载失败:{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{suggestions.length > 0 ? (
|
||||
<p className="text-muted-foreground text-sm">待确认归并建议({suggestions.length})· 确认前原始记录分别保留</p>
|
||||
) : null}
|
||||
{suggestions.map((s) => (
|
||||
<FindingMergeSuggestionCard key={s.id} suggestion={s} onResolve={resolve} />
|
||||
))}
|
||||
{runs.some((r) => r.state === "queued" || r.state === "running") ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{runs
|
||||
.filter((r) => r.state === "queued" || r.state === "running")
|
||||
.map((r) => (
|
||||
<FindingReviewRunRow key={r.conversation_id} run={r} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FindingCaseList({
|
||||
query,
|
||||
selectedIds,
|
||||
onSelect,
|
||||
contextTask,
|
||||
readOnly = false,
|
||||
onTotal,
|
||||
}: {
|
||||
query: Omit<FindingQuery, "page" | "pageSize">;
|
||||
selectedIds?: Set<string>;
|
||||
onSelect?: (id: string, checked: boolean) => void;
|
||||
contextTask?: string;
|
||||
readOnly?: boolean;
|
||||
onTotal?: (total: number) => void;
|
||||
}) {
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [data, setData] = React.useState<FindingCasePage | null>(null);
|
||||
const [error, setError] = React.useState("");
|
||||
const [refresh, setRefresh] = React.useState(0);
|
||||
const key = JSON.stringify(query);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reset pagination when the filter fingerprint changes.
|
||||
React.useEffect(() => {
|
||||
setPage(1);
|
||||
}, [key]);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: refresh is an explicit reload requested after grouping/retry.
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
async function load() {
|
||||
try {
|
||||
const v = await api.findingCases({ ...JSON.parse(key), page, pageSize: 20 });
|
||||
if (active) {
|
||||
setData(v);
|
||||
setError("");
|
||||
}
|
||||
} catch (e) {
|
||||
if (active) setError((e as Error).message);
|
||||
} finally {
|
||||
if (active) timer = setTimeout(load, 5000);
|
||||
}
|
||||
}
|
||||
void load();
|
||||
return () => {
|
||||
active = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [key, page, refresh]);
|
||||
React.useEffect(() => {
|
||||
if (data) onTotal?.(data.total);
|
||||
}, [data, onTotal]);
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
{!readOnly ? (
|
||||
<FindingCaseReviewPanel
|
||||
taskId={query.task === "all" ? undefined : query.task}
|
||||
onChange={() => setRefresh((v) => v + 1)}
|
||||
/>
|
||||
) : null}
|
||||
{error ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
加载失败:{error}
|
||||
<Button variant="link" onClick={() => setRefresh((v) => v + 1)}>
|
||||
重试
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{!data && !error ? (
|
||||
<div className="flex justify-center p-6">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : null}
|
||||
{data ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{data.stats.total} 个独立漏洞 · {data.stats.reports} 条上报
|
||||
</p>
|
||||
) : null}
|
||||
{data?.items.map((row) => {
|
||||
if (row.case) {
|
||||
return (
|
||||
<FindingCaseFolder
|
||||
key={`case:${row.case.id}`}
|
||||
row={row}
|
||||
selectedIds={selectedIds}
|
||||
onSelect={onSelect}
|
||||
contextTask={contextTask}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (row.finding)
|
||||
return (
|
||||
<Card key={`finding:${row.finding.finding_id}`} className="gap-0 py-0">
|
||||
<CardContent className="px-0">
|
||||
<FindingCaseMemberRow
|
||||
finding={row.finding}
|
||||
selected={selectedIds?.has(row.finding.finding_id ?? row.finding.id)}
|
||||
onSelect={readOnly ? undefined : onSelect}
|
||||
contextTask={contextTask}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
return null;
|
||||
})}
|
||||
{data?.total === 0 ? <p className="p-6 text-center text-muted-foreground text-sm">暂无匹配漏洞</p> : null}
|
||||
{data ? (
|
||||
<TablePagination
|
||||
page={page}
|
||||
pageSize={20}
|
||||
total={data.total}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={() => setPage(1)}
|
||||
pageSizeOptions={[20]}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Only consolidated findings use this folder surface. Ordinary findings keep
|
||||
// the original table rows and controls in each surrounding view.
|
||||
export function FindingCaseFolder({
|
||||
row,
|
||||
selectedIds,
|
||||
onSelect,
|
||||
contextTask,
|
||||
readOnly = false,
|
||||
presentation = "compact",
|
||||
className,
|
||||
renderRecords,
|
||||
}: {
|
||||
row: FindingCaseListRow;
|
||||
selectedIds?: Set<string>;
|
||||
onSelect?: (id: string, checked: boolean) => void;
|
||||
contextTask?: string;
|
||||
readOnly?: boolean;
|
||||
presentation?: "compact" | "records" | "task" | "asset";
|
||||
className?: string;
|
||||
renderRecords?: (items: Finding[]) => React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const group = row.case;
|
||||
const taskLabel = row.task_name?.trim() ? row.task_name : row.task_description;
|
||||
if (!group) return null;
|
||||
return (
|
||||
<Card className={cn("gap-0 overflow-hidden py-0", className)}>
|
||||
<CardHeader className={cn("px-4 py-3", open && "bg-muted/60")}>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`${open ? "收起" : "展开"}${group.title}`}
|
||||
aria-expanded={open}
|
||||
aria-controls={`finding-case-members-${group.id}`}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<ChevronRightIcon className={cn(open && "rotate-90")} />
|
||||
</Button>
|
||||
{open ? (
|
||||
<FolderOpenIcon className="size-5 shrink-0 text-primary" />
|
||||
) : (
|
||||
<FolderIcon className="size-5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 basis-2/3 flex-col gap-1 sm:basis-auto">
|
||||
<CardTitle className="truncate text-sm">
|
||||
<Link
|
||||
className="hover:underline"
|
||||
href={`/function/findings/case?id=${group.id}${contextTask ? `&context_task=${contextTask}` : ""}`}
|
||||
>
|
||||
{group.title}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
漏洞文件夹 · {group.count} 条原始上报 ·{" "}
|
||||
{group.report_version !== group.version ? "统一报告待更新" : "统一报告已生成"}
|
||||
</CardDescription>
|
||||
{presentation !== "compact" ? (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-muted-foreground text-xs">
|
||||
{presentation !== "task" && group.task_id ? (
|
||||
<Link
|
||||
className="max-w-full truncate hover:underline"
|
||||
href={`/function/tasks/detail?id=${group.task_id}`}
|
||||
title={row.task_description}
|
||||
>
|
||||
所属任务 #{group.task_id}
|
||||
{taskLabel ? ` · ${taskLabel}` : ""}
|
||||
</Link>
|
||||
) : null}
|
||||
{(row.assets ?? []).length ? (
|
||||
<span className="inline-flex min-w-0 flex-wrap items-center gap-1">
|
||||
资产:
|
||||
{row.assets?.map((a) => (
|
||||
<code
|
||||
key={a.id}
|
||||
className="max-w-48 truncate rounded bg-muted px-1"
|
||||
title={`${a.type} · ${a.label}`}
|
||||
>
|
||||
{a.label}
|
||||
</code>
|
||||
))}
|
||||
{(row.asset_count ?? 0) > 4 ? <span>+{(row.asset_count ?? 0) - 4}</span> : null}
|
||||
</span>
|
||||
) : (
|
||||
<span>未关联资产</span>
|
||||
)}
|
||||
{row.last_found_at ? (
|
||||
<time dateTime={row.last_found_at}>
|
||||
最近上报{" "}
|
||||
{new Date(row.last_found_at).toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</time>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">严 / 高 / 中 / 低</span>
|
||||
<FindingSeverityCounts counts={group} />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{open ? (
|
||||
<CardContent
|
||||
id={`finding-case-members-${group.id}`}
|
||||
role="region"
|
||||
aria-label={`${group.title}的原始子报告`}
|
||||
className="border-t bg-muted/20 px-4 py-4 sm:px-6"
|
||||
>
|
||||
<FindingCaseMembers
|
||||
nested
|
||||
renderRecords={renderRecords}
|
||||
caseId={group.id}
|
||||
version={group.version}
|
||||
matchedIds={row.matched_ids}
|
||||
selectedIds={selectedIds}
|
||||
onSelect={readOnly ? undefined : onSelect}
|
||||
contextTask={contextTask}
|
||||
/>
|
||||
</CardContent>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { sseUrl } from "@/lib/api";
|
||||
import { openSSE } from "@/lib/api";
|
||||
import { isBtwCommand, type SideExchange, type SideHistory, sideAPI } from "@/lib/side-questions";
|
||||
|
||||
// crypto.randomUUID 仅在安全上下文可用(https/localhost);经 IP+http 访问时降级。
|
||||
@@ -128,7 +128,7 @@ export function useSideQuestions(parent: string | null) {
|
||||
useEffect(() => {
|
||||
if (!runningID) return;
|
||||
const version = epoch.current;
|
||||
const stream = new EventSource(sseUrl(`/api/side-questions/${runningID}/events`));
|
||||
const stream = openSSE(`/api/side-questions/${runningID}/events`);
|
||||
stream.addEventListener("snapshot", (event) => {
|
||||
if (version !== epoch.current) return;
|
||||
try {
|
||||
|
||||
+58
-2
@@ -37,6 +37,10 @@ import type {
|
||||
ExplorationNodeQuery,
|
||||
Finding,
|
||||
FindingAssetTree,
|
||||
FindingCaseDetail,
|
||||
FindingCasePage,
|
||||
FindingCaseReviewRun,
|
||||
FindingCaseSuggestion,
|
||||
FindingDeepenResponse,
|
||||
FindingGroupsPage,
|
||||
FindingQuery,
|
||||
@@ -197,6 +201,15 @@ export function sseUrl(path: string): string {
|
||||
return token ? `${base}${path}${sep}token=${encodeURIComponent(token)}` : `${base}${path}`;
|
||||
}
|
||||
|
||||
export function openSSE(path: string): EventSource {
|
||||
const url = sseUrl(path);
|
||||
const target = new URL(url, window.location.href);
|
||||
// Same-origin streams already send cookies. Only opt into cross-port cookies
|
||||
// for the local Next/Go dev pair; keep custom remote SSE endpoints unchanged.
|
||||
const local = ["localhost", "127.0.0.1", "[::1]"].includes(target.hostname);
|
||||
return new EventSource(url, { withCredentials: local && target.hostname === window.location.hostname });
|
||||
}
|
||||
|
||||
const get = <T>(p: string) => http<T>(p);
|
||||
const post = <T>(p: string, body?: unknown) =>
|
||||
http<T>(p, { method: "POST", body: body ? JSON.stringify(body) : undefined });
|
||||
@@ -555,6 +568,43 @@ export const api = {
|
||||
// 节点带子树聚合计数。不分页——树是导航结构,一次取完。
|
||||
findingAssetTree: (q: Omit<FindingQuery, "page" | "pageSize">) =>
|
||||
get<FindingAssetTree>(`/exploration/findings/asset-tree?${findingFilterParams(q).toString()}`),
|
||||
|
||||
findingCases: (q: FindingQuery, originalRows = false) => {
|
||||
const p = findingFilterParams(q);
|
||||
p.set("page", String(q.page));
|
||||
p.set("limit", String(q.pageSize));
|
||||
if (originalRows) p.set("original_rows", "1");
|
||||
return get<FindingCasePage>(`/exploration/finding-cases?${p}`);
|
||||
},
|
||||
getFindingCase: (id: string, contextTask?: string) =>
|
||||
get<FindingCaseDetail>(
|
||||
`/exploration/finding-cases/${id}${contextTask ? `?context_task=${encodeURIComponent(contextTask)}` : ""}`,
|
||||
),
|
||||
findingCaseMembers: (id: string, page = 1, contextTask?: string) =>
|
||||
get<FindingsPage>(
|
||||
`/exploration/finding-cases/${id}/members?page=${page}&limit=20${contextTask ? `&context_task=${encodeURIComponent(contextTask)}` : ""}`,
|
||||
),
|
||||
removeFindingCaseMember: (id: string, fid: string, reason: string) =>
|
||||
http<{ ok: boolean }>(`/exploration/finding-cases/${id}/members/${fid}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ reason }),
|
||||
}),
|
||||
findingCaseSuggestions: (task?: string) =>
|
||||
get<FindingCaseSuggestion[]>(
|
||||
`/exploration/finding-case-suggestions${task ? `?task_id=${encodeURIComponent(task)}` : ""}`,
|
||||
),
|
||||
resolveFindingCaseSuggestion: (id: string, accept: boolean) =>
|
||||
post<{ case_id: string }>(`/exploration/finding-case-suggestions/${id}`, { accept }),
|
||||
reviewFindingCases: (ids: string[]) =>
|
||||
post<{ runs: { conversation_id: number; task_id: string }[] }>("/exploration/finding-case-review", {
|
||||
finding_ids: ids,
|
||||
}),
|
||||
regenerateFindingCase: (id: string, contextTask?: string) =>
|
||||
post<{ runs: { conversation_id: number; task_id: string }[] }>(
|
||||
`/exploration/finding-case-review${contextTask ? `?context_task=${encodeURIComponent(contextTask)}` : ""}`,
|
||||
{ case_id: id },
|
||||
),
|
||||
findingCaseReviewRuns: () => get<FindingCaseReviewRun[]>("/exploration/finding-case-review"),
|
||||
findingStats: () => get<FindingStats>("/exploration/findings/stats"),
|
||||
// exportFindings 触发发现页导出并下载文件。scope=selected 时传 ids(finding_id 列表);
|
||||
// scope=filtered 时传当前筛选(沿用 FindingQuery 的筛选字段);scope=all 忽略筛选。
|
||||
@@ -563,8 +613,12 @@ export const api = {
|
||||
scope: "filtered" | "all" | "selected";
|
||||
filters?: Omit<FindingQuery, "page" | "pageSize">;
|
||||
ids?: string[];
|
||||
includeOriginals?: boolean;
|
||||
consolidated?: boolean;
|
||||
}) => {
|
||||
const p = new URLSearchParams({ format: opts.format, scope: opts.scope });
|
||||
p.set("mode", opts.consolidated === false ? "raw" : "consolidated");
|
||||
p.set("include_originals", String(opts.includeOriginals ?? false));
|
||||
if (opts.scope === "selected") {
|
||||
p.set("ids", (opts.ids ?? []).join(","));
|
||||
} else if (opts.scope === "filtered" && opts.filters) {
|
||||
@@ -812,6 +866,9 @@ export const api = {
|
||||
|
||||
// ---- app settings (runtime toggles) ----
|
||||
settings: () => get<Settings>(`/settings`),
|
||||
basicAuthSettings: () => get<{ enabled: boolean; username: string; password_set: boolean }>("/settings/basic-auth"),
|
||||
setBasicAuthSettings: (settings: { enabled: boolean; username: string; password: string }) =>
|
||||
put<{ enabled: boolean; username: string; password_set: boolean }>("/settings/basic-auth", settings),
|
||||
setSettings: (patch: Partial<Settings>) => put<Settings>(`/settings`, patch),
|
||||
// Run a real "test" search with the given (or saved) config to verify it works.
|
||||
testWebSearch: (patch: {
|
||||
@@ -825,8 +882,7 @@ export const api = {
|
||||
// 渠道是多实例资源(同一类型可配多个机器人、各有过滤规则),因此独立成组,
|
||||
// 不塞进扁平的 settings 键值里。
|
||||
notifyMeta: () => get<NotificationMeta>(`/notify/meta`),
|
||||
notifyChannels: () =>
|
||||
get<{ channels: NotificationChannel[] }>(`/notify/channels`).then((r) => arr(r.channels)),
|
||||
notifyChannels: () => get<{ channels: NotificationChannel[] }>(`/notify/channels`).then((r) => arr(r.channels)),
|
||||
notifyCreateChannel: (payload: {
|
||||
name: string;
|
||||
kind: string;
|
||||
|
||||
@@ -1843,6 +1843,35 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
return { deleted: 1, assets_deleted: assetsDeleted };
|
||||
}
|
||||
|
||||
// Demo mode keeps raw observations as independent findings, without executing a model.
|
||||
if (path === "/exploration/finding-case-suggestions" || (path === "/exploration/finding-case-review" && m === "GET"))
|
||||
return [];
|
||||
if (path === "/exploration/finding-case-review" && m === "POST")
|
||||
throw new Error("演示模式不执行 Reporter 整理,请在已配置模型的实例中使用");
|
||||
if (path === "/exploration/finding-cases" && m === "GET") {
|
||||
const list = mockApplyAssetScope(mockFilterFindings(q), q.get("asset_scope"));
|
||||
const ranks = { critical: 4, high: 3, medium: 2, low: 1 } as const;
|
||||
list.sort(
|
||||
(a, b) =>
|
||||
(q.get("sort") === "severity" ? ranks[b.severity] - ranks[a.severity] : 0) || +new Date(b.ts) - +new Date(a.ts),
|
||||
);
|
||||
const page = Math.max(1, Number(q.get("page")) || 1);
|
||||
const size = Math.min(100, Math.max(1, Number(q.get("limit")) || 20));
|
||||
const scoped = mockFindings.filter((f) => !q.get("task_id") || f.task_id === q.get("task_id"));
|
||||
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
|
||||
for (const f of scoped) counts[f.severity]++;
|
||||
return {
|
||||
items: list.slice((page - 1) * size, page * size).map((f) => ({
|
||||
finding: { ...f, report: undefined, finding_id: f.id, traffic_count: mockTrafficCount(f.id) },
|
||||
matched_ids: [],
|
||||
})),
|
||||
total: list.length,
|
||||
page,
|
||||
page_size: size,
|
||||
stats: { total: scoped.length, reports: scoped.length, unassessed: 0, ...counts },
|
||||
};
|
||||
}
|
||||
|
||||
// ── exploration ──
|
||||
if (path === "/exploration/frontier") return D.frontier;
|
||||
if (path === "/exploration/findings/stats") {
|
||||
@@ -2220,6 +2249,8 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (path === "/traffic/exchange") return D.trafficDetail;
|
||||
if (path === "/settings" && m === "GET") return D.settings;
|
||||
if (path === "/settings" && m === "PUT") return { ...D.settings, ...b };
|
||||
if (path === "/settings/basic-auth" && m === "GET") return { enabled: false, username: "", password_set: false };
|
||||
if (path === "/settings/basic-auth" && m === "PUT") throw new Error("演示模式不支持修改 HTTP Basic Auth,请在实际部署中配置");
|
||||
if (path === "/settings/web-search/test") return { ok: true, count: 5, backend: D.settings.web_search_backend };
|
||||
if (path === "/settings/python/detect") return { python_interpreter: "/usr/bin/python3" };
|
||||
if (path === "/chat")
|
||||
|
||||
@@ -5,6 +5,7 @@ export type TaskStatus = "created" | "queued" | "running" | "paused" | "done" |
|
||||
export type EngineMode = "exploring" | "paused" | "stalled" | "idle";
|
||||
|
||||
export interface Task {
|
||||
distinct_findings?: FindingDistinctStats;
|
||||
id: string;
|
||||
name?: string; // 可选任务名称;空/缺省=未命名,展示时回退到描述
|
||||
category_id?: number;
|
||||
@@ -489,6 +490,7 @@ export interface FindingAsset {
|
||||
}
|
||||
|
||||
export interface Finding {
|
||||
case_id?: string;
|
||||
traffic_count?: number;
|
||||
evidence_version?: number;
|
||||
report_evidence_version?: number;
|
||||
@@ -550,6 +552,7 @@ export interface FindingDeepenResponse {
|
||||
|
||||
// FindingStats 是发现全表聚合(统计卡 + 漏洞类型下拉),服务端计算,不受分页影响。
|
||||
export interface FindingStats {
|
||||
distinct?: FindingDistinctStats;
|
||||
total: number;
|
||||
pending: number;
|
||||
critical: number;
|
||||
@@ -1610,3 +1613,76 @@ export interface InterceptExecution {
|
||||
seq: number;
|
||||
items: Activity[];
|
||||
}
|
||||
|
||||
export interface FindingDistinctStats {
|
||||
total: number;
|
||||
reports: number;
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
unassessed: number;
|
||||
}
|
||||
export interface FindingCase {
|
||||
id: string;
|
||||
task_id: string | null;
|
||||
title: string;
|
||||
reason: string;
|
||||
severity: Severity | "";
|
||||
severity_reason: string;
|
||||
report?: string;
|
||||
version: number;
|
||||
report_version: number;
|
||||
active: boolean;
|
||||
count: number;
|
||||
critical: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
created_at: string;
|
||||
}
|
||||
export interface FindingCaseListRow {
|
||||
task_name?: string;
|
||||
task_description?: string;
|
||||
assets?: FindingAsset[];
|
||||
asset_count?: number;
|
||||
last_found_at?: string;
|
||||
case?: FindingCase | null;
|
||||
finding?: Finding;
|
||||
matched_ids: number[];
|
||||
}
|
||||
export interface FindingCasePage {
|
||||
matching_reports?: number;
|
||||
items: FindingCaseListRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
stats: FindingDistinctStats;
|
||||
}
|
||||
export interface FindingCaseEvent {
|
||||
id: string;
|
||||
action: string;
|
||||
actor: string;
|
||||
reason: string;
|
||||
finding_ids: number[];
|
||||
created_at: string;
|
||||
}
|
||||
export interface FindingCaseDetail {
|
||||
case: FindingCase;
|
||||
events: FindingCaseEvent[];
|
||||
}
|
||||
export interface FindingCaseSuggestion {
|
||||
id: string;
|
||||
task_id: string;
|
||||
left_id: string;
|
||||
right_id: string;
|
||||
title: string;
|
||||
reason: string;
|
||||
state: string;
|
||||
}
|
||||
export interface FindingCaseReviewRun {
|
||||
conversation_id: number;
|
||||
task_id: string;
|
||||
state: "queued" | "running" | "done" | "failed";
|
||||
error: string;
|
||||
}
|
||||
+53
-6
@@ -1,30 +1,77 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const AUTH_PAGES = ["/login", "/setup"];
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
export async function proxy(request: NextRequest) {
|
||||
// Mock demo:无真实登录,放行所有页面(客户端 auth 守卫也会放行)。
|
||||
if (process.env.NEXT_PUBLIC_MOCK === "1") return NextResponse.next();
|
||||
|
||||
// Pages served by Next dev must pass the same gate as the embedded UI.
|
||||
// Only send credentials to a loopback backend; never follow redirects.
|
||||
// Remote dev backends remain usable when their optional gate is disabled.
|
||||
let gate: Response;
|
||||
try {
|
||||
const backend = new URL(process.env.AUTOPENTEST_API ?? "http://localhost:8787");
|
||||
if (!["http:", "https:"].includes(backend.protocol) || backend.username || backend.password) {
|
||||
return new NextResponse("认证后端地址配置无效", { status: 503 });
|
||||
}
|
||||
const local = ["localhost", "127.0.0.1", "[::1]"].includes(backend.hostname);
|
||||
gate = await fetch(new URL("/api/basic-auth/check", backend), {
|
||||
headers: local
|
||||
? {
|
||||
cookie: request.headers.get("cookie") ?? "",
|
||||
authorization: request.headers.get("authorization") ?? "",
|
||||
"x-forwarded-proto": request.nextUrl.protocol.replace(":", ""),
|
||||
}
|
||||
: {},
|
||||
credentials: "omit",
|
||||
cache: "no-store",
|
||||
redirect: "error",
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!local && gate.status === 401) {
|
||||
return new NextResponse("远程后端已启用 HTTP Basic Auth,请通过内嵌前端访问该服务", { status: 503 });
|
||||
}
|
||||
} catch {
|
||||
return new NextResponse("认证服务暂时不可用,请稍后重试", { status: 503 });
|
||||
}
|
||||
if (!gate.ok) {
|
||||
const headers = new Headers({ "Cache-Control": "no-store", "Content-Type": "text/plain; charset=utf-8" });
|
||||
const challenge = gate.headers.get("www-authenticate");
|
||||
if (challenge) headers.set("WWW-Authenticate", challenge);
|
||||
return new NextResponse(gate.status === 401 ? "请完成 HTTP Basic Auth 验证后刷新页面" : "认证服务暂时不可用", {
|
||||
status: gate.status,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
const finish = (response: NextResponse) => {
|
||||
const cookie = gate.headers.get("set-cookie");
|
||||
if (cookie) response.headers.append("Set-Cookie", cookie);
|
||||
response.headers.set("Cache-Control", "no-store");
|
||||
return response;
|
||||
};
|
||||
|
||||
const { pathname } = request.nextUrl;
|
||||
const token = request.cookies.get("artex_token")?.value;
|
||||
const isAuthPage = AUTH_PAGES.some((p) => pathname === p || pathname.startsWith(`${p}/`));
|
||||
|
||||
// 未登录 → 跳转登录页
|
||||
if (!token && !isAuthPage) {
|
||||
return NextResponse.redirect(new URL("/login", request.url));
|
||||
return finish(NextResponse.redirect(new URL("/login", request.url)));
|
||||
}
|
||||
|
||||
// 已登录时访问登录/初始化页 → 跳转主界面
|
||||
if (token && isAuthPage) {
|
||||
return NextResponse.redirect(new URL("/function/tasks", request.url));
|
||||
return finish(NextResponse.redirect(new URL("/function/tasks", request.url)));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
return finish(NextResponse.next());
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// 跳过 Next.js 内部路由、API 路由、favicon 及 public/ 下的静态文件(含图片、字体等)
|
||||
matcher: ["/((?!_next/static|_next/image|favicon\\.ico|api/|.*\\.(?:png|jpg|jpeg|gif|webp|svg|ico|woff2?|ttf|otf)$).*)"],
|
||||
matcher: [
|
||||
"/((?!_next/static|_next/image|favicon\\.ico|api/|.*\\.(?:png|jpg|jpeg|gif|webp|svg|ico|woff2?|ttf|otf)$).*)",
|
||||
],
|
||||
};
|
||||
Reference in new issue
Block a user