fix(vulnerability): allow standalone manual findings and validate links

This commit is contained in:
Ed1s0nZ committed 2026-10-10 16:03:42 +08:00
1 parent 088d6da3c1
commit a8321c4bff
9 files changed
+217 -10

No files matched your search

+33
View File
@@ -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`:通过。
- 线上部署和真实浏览器保存尚未验证。
+25 -1
View File
@@ -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),
@@ -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")
}
})
}
}
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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": "Метка задачи",
+2 -2
View File
@@ -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": "任务标签",
@@ -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');
});
+1 -1
View File
@@ -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;
}
+2 -2
View File
@@ -6425,8 +6425,8 @@
<p class="form-hint" data-i18n="vulnerabilityModal.projectHint">绑定后 Agent 在项目范围内可通过 list_vulnerabilities 看到本条记录;留空则尝试从会话自动关联。</p>
</div>
<div class="form-group">
<label for="vulnerability-conversation-id"><span data-i18n="vulnerabilityModal.conversationId">会话ID</span> <span style="color: red;">*</span></label>
<input type="text" id="vulnerability-conversation-id" data-i18n="vulnerabilityModal.conversationIdPlaceholder" data-i18n-attr="placeholder" placeholder="输入会话ID" required />
<label for="vulnerability-conversation-id"><span data-i18n="vulnerabilityModal.conversationId">会话ID</span></label>
<input type="text" id="vulnerability-conversation-id" data-i18n="vulnerabilityModal.conversationIdPlaceholder" data-i18n-attr="placeholder" placeholder="可选:输入已有会话ID,留空可独立添加漏洞" />
</div>
<div class="form-group">
<label for="vulnerability-conversation-tag" data-i18n="vulnerabilityModal.conversationTag">对话标签</label>