From a8321c4bff3a76fe0dfc3b576ed116e9a4d4101e Mon Sep 17 00:00:00 2001 From: Ed1s0nZ Date: Sat, 10 Oct 2026 16:03:42 +0800 Subject: [PATCH] fix(vulnerability): allow standalone manual findings and validate links --- docs/fixes/manual-vulnerability-create.md | 33 ++++++ internal/handler/vulnerability.go | 26 ++++- internal/handler/vulnerability_create_test.go | 102 ++++++++++++++++++ web/static/i18n/en-US.json | 4 +- web/static/i18n/ru-RU.json | 4 +- web/static/i18n/zh-CN.json | 4 +- web/static/js/vulnerability-create.test.cjs | 48 +++++++++ web/static/js/vulnerability.js | 2 +- web/templates/index.html | 4 +- 9 files changed, 217 insertions(+), 10 deletions(-) create mode 100644 docs/fixes/manual-vulnerability-create.md create mode 100644 internal/handler/vulnerability_create_test.go create mode 100644 web/static/js/vulnerability-create.test.cjs diff --git a/docs/fixes/manual-vulnerability-create.md b/docs/fixes/manual-vulnerability-create.md new file mode 100644 index 00000000..6041f188 --- /dev/null +++ b/docs/fixes/manual-vulnerability-create.md @@ -0,0 +1,33 @@ +# 手工添加漏洞失败:会话关联校验修复 + +日期:2026-10-10(Asia/Shanghai)。 + +## 工作流检查 + +- 用户请求:手工添加漏洞保存失败,截图报 `FOREIGN KEY constraint failed`。 +- 阶段/类型:P10 反馈迭代,现有功能缺陷修复。 +- 所需及已有依据:添加表单、POST 请求结构、创建 handler、数据库外键和可空字段均已核对。 +- 缺失/假设:没有线上请求体,无法确认截图实际填写的会话 ID;代码可确认不存在的会话 ID 会触发该错误。手工漏洞可独立创建的行为沿用数据库现有可空设计。 +- 允许实施:是;无需新的架构或产品决策。 +- 范围:会话字段可选、关联存在性校验、三种语言提示、前后端回归。 +- 验收:省略/留空会话可保存;有效会话可关联并继承项目;非法关联返回明确的 400;无权限关联继续返回 403;独立漏洞仍归属创建人。 + +## 维护检查 + +- handler 608 行、database 漏洞模块 548 行、页面脚本 2584 行。页面脚本承担表单、筛选、卡片、复测等多个职责,文件大小属于 blocked 级别的广泛扩展风险。 +- 允许类型:narrow_fix。本次仅从现有前端必填表达式去掉会话字段,不扩展该大文件行为;新测试放在独立文件,无需先做整体重构。 +- 数据库代码不变,保留外键及现有 NULL 写入行为。 +- 验证:Go handler/database 包测试、Node 添加/复测测试、JSON 解析、git diff --check。 + +## 原因与修复 + +数据库允许 `vulnerabilities.conversation_id` 为 NULL,删除会话时也保留漏洞。但前端和 POST 请求强制会话必填,并允许用户自由输入未经存在性校验的 ID。填入不存在的 ID 后 INSERT 触发外键错误,接口返回 500。 + +会话改为可选并给出填写提示;接口修剪关联 ID 的首尾空白,在保留权限校验后检查显式会话和项目是否存在。无效关联返回 400,数据库查询异常仍返回 500。已存在的会话继续由原数据库逻辑自动关联项目。原有创建人归属及授权逻辑保留。 + +## 验证记录 + +- Node 添加与复测回归:13 项通过。 +- `go test ./internal/handler ./internal/database`:两个包全部通过,新建漏洞回归覆盖 9 个场景。 +- 三种语言 JSON 解析及 `git diff --check`:通过。 +- 线上部署和真实浏览器保存尚未验证。 diff --git a/internal/handler/vulnerability.go b/internal/handler/vulnerability.go index 1436820d..fe7ef833 100644 --- a/internal/handler/vulnerability.go +++ b/internal/handler/vulnerability.go @@ -37,7 +37,7 @@ func NewVulnerabilityHandler(db *database.DB, logger *zap.Logger) *Vulnerability // CreateVulnerabilityRequest 创建漏洞请求 type CreateVulnerabilityRequest struct { - ConversationID string `json:"conversation_id" binding:"required"` + ConversationID string `json:"conversation_id"` ProjectID string `json:"project_id"` ConversationTag string `json:"conversation_tag"` TaskTag string `json:"task_tag"` @@ -62,6 +62,8 @@ func (h *VulnerabilityHandler) CreateVulnerability(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + req.ConversationID = strings.TrimSpace(req.ConversationID) + req.ProjectID = strings.TrimSpace(req.ProjectID) if session, ok := security.CurrentSession(c); ok && session.Scope != database.RBACScopeAll { if strings.TrimSpace(req.ConversationID) != "" && !h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", strings.TrimSpace(req.ConversationID)) { c.JSON(http.StatusForbidden, gin.H{"error": "无权在该对话下创建漏洞"}) @@ -73,6 +75,28 @@ func (h *VulnerabilityHandler) CreateVulnerability(c *gin.Context) { } } + // Manual findings may be standalone; supplied links must reference real records. + for _, link := range []struct { + id, query, message string + }{ + {req.ConversationID, "SELECT EXISTS(SELECT 1 FROM conversations WHERE id = ?)", "会话不存在,请选择已有会话或留空"}, + {req.ProjectID, "SELECT EXISTS(SELECT 1 FROM projects WHERE id = ?)", "项目不存在,请选择已有项目或留空"}, + } { + if link.id == "" { + continue + } + var exists bool + if err := h.db.QueryRow(link.query, link.id).Scan(&exists); err != nil { + h.logger.Error("校验漏洞关联失败", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "校验漏洞关联失败"}) + return + } + if !exists { + c.JSON(http.StatusBadRequest, gin.H{"error": link.message}) + return + } + } + vuln := &database.Vulnerability{ ConversationID: req.ConversationID, ProjectID: strings.TrimSpace(req.ProjectID), diff --git a/internal/handler/vulnerability_create_test.go b/internal/handler/vulnerability_create_test.go new file mode 100644 index 00000000..c5780dbb --- /dev/null +++ b/internal/handler/vulnerability_create_test.go @@ -0,0 +1,102 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "cyberstrike-ai/internal/database" + "cyberstrike-ai/internal/security" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +func TestManualCreateVulnerability(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := database.NewDB(filepath.Join(t.TempDir(), "manual.db"), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + project, err := db.CreateProject(&database.Project{Name: "manual project"}) + if err != nil { + t.Fatal(err) + } + conversation, err := db.CreateConversation("source", database.ConversationCreateMeta{ProjectID: project.ID}) + if err != nil { + t.Fatal(err) + } + user, err := db.CreateRBACUser("manual-reporter", "Reporter", "hash", true, nil) + if err != nil { + t.Fatal(err) + } + h := NewVulnerabilityHandler(db, zap.NewNop()) + for _, tc := range []struct { + name, conversationID, projectID, scope string + code int + wantConversation, wantProject, errorText string + }{ + {name: "standalone", code: 200}, + {name: "whitespace standalone", conversationID: " ", projectID: " ", code: 200}, + {name: "project only", projectID: project.ID, code: 200, wantProject: project.ID}, + {name: "existing conversation inherits project", conversationID: " " + conversation.ID + " ", code: 200, wantConversation: conversation.ID, wantProject: project.ID}, + {name: "missing conversation", conversationID: "missing", code: 400, errorText: "会话不存在"}, + {name: "missing project", projectID: "missing", code: 400, errorText: "项目不存在"}, + {name: "assigned standalone", scope: database.RBACScopeAssigned, code: 200}, + {name: "inaccessible conversation", conversationID: conversation.ID, scope: database.RBACScopeAssigned, code: 403}, + {name: "inaccessible project", projectID: project.ID, scope: database.RBACScopeAssigned, code: 403}, + } { + t.Run(tc.name, func(t *testing.T) { + payload := map[string]string{"title": "manual finding", "severity": "high", "conversation_id": tc.conversationID, "project_id": tc.projectID} + if tc.name == "standalone" { + delete(payload, "conversation_id") + } + body, _ := json.Marshal(payload) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/api/vulnerabilities", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + scope := tc.scope + if scope == "" { + scope = database.RBACScopeAll + } + c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: scope}) + var before int + if err := db.QueryRow("SELECT COUNT(*) FROM vulnerabilities").Scan(&before); err != nil { + t.Fatal(err) + } + h.CreateVulnerability(c) + if w.Code != tc.code || !strings.Contains(w.Body.String(), tc.errorText) { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + if tc.code != http.StatusOK { + var after int + if err := db.QueryRow("SELECT COUNT(*) FROM vulnerabilities").Scan(&after); err != nil { + t.Fatal(err) + } + if before != after { + t.Fatal("invalid finding persisted") + } + return + } + var created database.Vulnerability + if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil { + t.Fatal(err) + } + stored, err := db.GetVulnerability(created.ID) + if err != nil { + t.Fatal(err) + } + if stored.ConversationID != tc.wantConversation || stored.ProjectID != tc.wantProject { + t.Fatalf("unexpected links: %+v", stored) + } + if !db.UserCanAccessResource(user.ID, database.RBACScopeAssigned, "vulnerability", created.ID) { + t.Fatal("creator cannot access standalone finding") + } + }) + } +} diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 9a7e7f86..da7465bd 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -2879,7 +2879,7 @@ "exportNoResults": "No vulnerabilities match the current filters", "exportStarted": "Started downloading {{count}} file(s)", "exportFailed": "Export failed", - "saveRequiredFields": "Please fill in conversation ID, title, description, severity, type, target, reproduction steps, evidence/POC, impact, and remediation", + "saveRequiredFields": "Please fill in title, description, severity, type, target, reproduction steps, evidence/POC, impact, and remediation", "saveFailed": "Save failed", "fetchFailed": "Failed to fetch vulnerability", "deleteFailed": "Delete failed", @@ -3812,7 +3812,7 @@ "projectNone": "(Unbound)", "projectHint": "Bound findings appear in list_vulnerabilities for that project; leave empty to infer from the conversation when possible.", "conversationId": "Conversation ID", - "conversationIdPlaceholder": "Enter conversation ID", + "conversationIdPlaceholder": "Optional: enter an existing conversation ID, or leave blank for a standalone finding", "conversationTag": "Conversation tag", "conversationTagPlaceholder": "e.g. engagement A, weekly report", "taskTag": "Task tag", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index f7bd5272..e244f0c1 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -2892,7 +2892,7 @@ "exportNoResults": "Нет уязвимостей по текущим фильтрам", "exportStarted": "Начата загрузка файлов: {{count}}", "exportFailed": "Экспорт не удался", - "saveRequiredFields": "Заполните ID диалога, название, описание, серьёзность, тип, цель, шаги воспроизведения, доказательства/POC, влияние и меры устранения", + "saveRequiredFields": "Заполните название, описание, серьёзность, тип, цель, шаги воспроизведения, доказательства/POC, влияние и меры устранения", "saveFailed": "Не удалось сохранить", "fetchFailed": "Не удалось получить уязвимость", "deleteFailed": "Не удалось удалить", @@ -3824,7 +3824,7 @@ "projectNone": "(Не привязан)", "projectHint": "Привязанные находки попадают в list_vulnerabilities этого проекта; оставьте пустым, чтобы по возможности определить проект по диалогу.", "conversationId": "ID диалога", - "conversationIdPlaceholder": "Введите ID диалога", + "conversationIdPlaceholder": "Необязательно: ID существующего диалога; оставьте пустым для независимой уязвимости", "conversationTag": "Метка диалога", "conversationTagPlaceholder": "например, проверка A, еженедельный отчёт", "taskTag": "Метка задачи", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 22acd141..a668de79 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -2867,7 +2867,7 @@ "exportNoResults": "当前筛选条件下无可导出漏洞", "exportStarted": "已开始下载 {{count}} 份报告", "exportFailed": "导出失败", - "saveRequiredFields": "请填写必填字段:会话ID、标题、描述、严重程度、漏洞类型、目标、复现步骤、证据/POC、影响和修复建议", + "saveRequiredFields": "请填写必填字段:标题、描述、严重程度、漏洞类型、目标、复现步骤、证据/POC、影响和修复建议", "saveFailed": "保存失败", "fetchFailed": "获取漏洞失败", "deleteFailed": "删除失败", @@ -3800,7 +3800,7 @@ "projectNone": "(未绑定)", "projectHint": "绑定后 Agent 在项目范围内可通过 list_vulnerabilities 看到本条记录;留空则尝试从会话自动关联。", "conversationId": "会话ID", - "conversationIdPlaceholder": "输入会话ID", + "conversationIdPlaceholder": "可选:输入已有会话ID,留空可独立添加漏洞", "conversationTag": "对话标签", "conversationTagPlaceholder": "如:红队演练A、客户A周报", "taskTag": "任务标签", diff --git a/web/static/js/vulnerability-create.test.cjs b/web/static/js/vulnerability-create.test.cjs new file mode 100644 index 00000000..018b45de --- /dev/null +++ b/web/static/js/vulnerability-create.test.cjs @@ -0,0 +1,48 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const test = require('node:test'); + +function setup(conversationID = '') { + const fields = ['conversation-id', 'title', 'description', 'severity', 'type', 'target', 'reproduction-steps', 'evidence', 'impact', 'recommendation', 'project-id', 'conversation-tag', 'task-tag', 'status', 'preconditions', 'retest-notes']; + const elements = Object.fromEntries(fields.map(name => ['vulnerability-' + name, { value: 'value' }])); + elements['vulnerability-conversation-id'].value = conversationID; + elements['vulnerability-project-id'].value = ''; + const requests = [], alerts = []; + let reloaded = false; + const ctx = vm.createContext({ + document: { getElementById: id => elements[id] }, currentVulnerabilityId: null, + vulnerabilityPagination: { currentPage: 3 }, vulnT: key => key, + alert: message => alerts.push(message), console, + apiFetch: async (url, options) => { requests.push({ url, ...options }); return { ok: true, json: async () => ({ id: 'created' }) }; }, + closeVulnerabilityModal() {}, loadVulnerabilityStats() {}, loadVulnerabilities() { reloaded = true; }, + }); + const source = fs.readFileSync('web/static/js/vulnerability.js', 'utf8'); + vm.runInContext(source.slice(source.indexOf('async function saveVulnerability()'), source.indexOf('// 删除漏洞')), ctx); + return { ctx, elements, requests, alerts, reloaded: () => reloaded }; +} + +test('manual finding without a conversation submits and reloads', async () => { + const s = setup(' '); + await s.ctx.saveVulnerability(); + assert.equal(s.alerts.length, 0); + assert.equal(s.requests.length, 1); + assert.equal(s.requests[0].method, 'POST'); + assert.equal(JSON.parse(s.requests[0].body).conversation_id, ''); + assert.equal(s.ctx.vulnerabilityPagination.currentPage, 1); + assert.equal(s.reloaded(), true); +}); + +test('an existing conversation ID is trimmed and sent', async () => { + const s = setup(' existing '); + await s.ctx.saveVulnerability(); + assert.equal(JSON.parse(s.requests[0].body).conversation_id, 'existing'); +}); + +test('other required fields remain required', async () => { + const s = setup(); + s.elements['vulnerability-title'].value = ' '; + await s.ctx.saveVulnerability(); + assert.equal(s.requests.length, 0); + assert.equal(s.alerts[0], 'vulnerabilityPage.saveRequiredFields'); +}); diff --git a/web/static/js/vulnerability.js b/web/static/js/vulnerability.js index 5ce07554..02d832af 100644 --- a/web/static/js/vulnerability.js +++ b/web/static/js/vulnerability.js @@ -1676,7 +1676,7 @@ async function saveVulnerability() { const impact = document.getElementById('vulnerability-impact').value.trim(); const recommendation = document.getElementById('vulnerability-recommendation').value.trim(); - if (!conversationId || !title || !description || !severity || !type || !target || !reproductionSteps || !evidence || !impact || !recommendation) { + if (!title || !description || !severity || !type || !target || !reproductionSteps || !evidence || !impact || !recommendation) { alert(vulnT('vulnerabilityPage.saveRequiredFields')); return; } diff --git a/web/templates/index.html b/web/templates/index.html index 288f7fde..bbbb0409 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -6425,8 +6425,8 @@

绑定后 Agent 在项目范围内可通过 list_vulnerabilities 看到本条记录;留空则尝试从会话自动关联。

- - + +