mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-18 05:31:19 +02:00
fix(vulnerability): allow standalone manual findings and validate links
This commit is contained in:
1 parent
088d6da3c1
commit
a8321c4bff
9 files changed
+217
-10
No files matched your search
@@ -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`:通过。
|
||||
- 线上部署和真实浏览器保存尚未验证。
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Метка задачи",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user