From cab10bfcc482eb736cb6de279a4cd4b3f55883ab Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Fri, 13 Nov 2026 17:04:41 +0800 Subject: [PATCH] feat(vulnerabilities): add grouped browsing and review workspace --- internal/app/app.go | 3 + internal/database/database.go | 3 + internal/database/rbac_access_test.go | 11 +- internal/database/schemas/application.sql | 37 +- internal/database/vulnerability.go | 315 ++++++++-- internal/database/vulnerability_retest.go | 28 +- internal/database/vulnerability_workspace.go | 318 ++++++++++ .../database/vulnerability_workspace_test.go | 183 ++++++ internal/handler/vulnerability.go | 37 +- internal/handler/vulnerability_group_test.go | 146 +++++ internal/handler/vulnerability_workspace.go | 79 +++ .../handler/vulnerability_workspace_test.go | 78 +++ web/static/css/style.css | 420 ++++++++++++- web/static/css/vulnerability-workspace.css | 242 ++++++++ web/static/i18n/ar-SA.json | 63 ++ web/static/i18n/en-US.json | 63 ++ web/static/i18n/ru-RU.json | 63 ++ web/static/i18n/zh-CN.json | 63 ++ .../js/vulnerability-host-view.test.cjs | 41 ++ web/static/js/vulnerability-workspace.js | 217 +++++++ web/static/js/vulnerability.js | 560 ++++++++++++------ web/templates/index.html | 59 +- 22 files changed, 2782 insertions(+), 247 deletions(-) create mode 100644 internal/database/vulnerability_workspace.go create mode 100644 internal/database/vulnerability_workspace_test.go create mode 100644 internal/handler/vulnerability_group_test.go create mode 100644 internal/handler/vulnerability_workspace.go create mode 100644 internal/handler/vulnerability_workspace_test.go create mode 100644 web/static/css/vulnerability-workspace.css create mode 100644 web/static/js/vulnerability-host-view.test.cjs create mode 100644 web/static/js/vulnerability-workspace.js diff --git a/internal/app/app.go b/internal/app/app.go index 77dd0f91..91ec1c1b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1353,9 +1353,12 @@ func setupRoutes( protected.DELETE("/vulnerabilities/batch", vulnerabilityHandler.BatchDeleteVulnerabilities) protected.GET("/vulnerabilities/filter-options", vulnerabilityHandler.GetVulnerabilityFilterOptions) protected.GET("/vulnerabilities/stats", vulnerabilityHandler.GetVulnerabilityStats) + protected.POST("/vulnerabilities/cases", vulnerabilityHandler.MergeVulnerabilities) + protected.POST("/vulnerabilities/cases/:caseID/split", vulnerabilityHandler.DissolveVulnerabilityCase) protected.GET("/vulnerability-alerts/subscription", vulnerabilityHandler.GetMyAlertSubscription) protected.PUT("/vulnerability-alerts/subscription", vulnerabilityHandler.UpdateMyAlertSubscription) protected.GET("/vulnerabilities/:id", vulnerabilityHandler.GetVulnerability) + protected.GET("/vulnerabilities/:id/context", vulnerabilityHandler.VulnerabilityContext) protected.GET("/vulnerabilities/:id/download", vulnerabilityHandler.DownloadVulnerability) protected.POST("/vulnerabilities", vulnerabilityHandler.CreateVulnerability) protected.POST("/vulnerabilities/:id/retest", vulnerabilityHandler.CreateRetest) diff --git a/internal/database/database.go b/internal/database/database.go index 62fcaa1e..7430bcb3 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -212,6 +212,9 @@ func (db *DB) initTables() error { if err := db.migrateVulnerabilitiesConversationFK(); err != nil { return fmt.Errorf("迁移漏洞会话外键失败: %w", err) } + if err := db.backfillVulnerabilityTargets(); err != nil { + return fmt.Errorf("回填漏洞目标失败: %w", err) + } if err := db.BackfillModelTokenUsageFromProcessDetails(); err != nil { return fmt.Errorf("回填模型Token用量失败: %w", err) } diff --git a/internal/database/rbac_access_test.go b/internal/database/rbac_access_test.go index 4d71953d..8734bfc8 100644 --- a/internal/database/rbac_access_test.go +++ b/internal/database/rbac_access_test.go @@ -220,8 +220,8 @@ func TestRBACVulnerabilityAccessInheritsProject(t *testing.T) { if err := db.AssignResourceToUser(user.ID, "project", p1.ID); err != nil { t.Fatal(err) } - v1, _ := db.CreateVulnerability(&Vulnerability{ProjectID: p1.ID, Title: "v1", Severity: "high"}) - v2, _ := db.CreateVulnerability(&Vulnerability{ProjectID: p2.ID, Title: "v2", Severity: "high"}) + v1, _ := db.CreateVulnerability(&Vulnerability{ProjectID: p1.ID, Title: "v1", Severity: "high", Target: "https://example.com/visible"}) + v2, _ := db.CreateVulnerability(&Vulnerability{ProjectID: p2.ID, Title: "v2", Severity: "high", Target: "https://example.com/hidden"}) items, err := db.ListVulnerabilitiesForAccess(50, 0, VulnerabilityListFilter{}, RBACListAccess{UserID: user.ID, Scope: RBACScopeAssigned}) if err != nil { @@ -230,6 +230,13 @@ func TestRBACVulnerabilityAccessInheritsProject(t *testing.T) { if len(items) != 1 || items[0].ID != v1.ID { t.Fatalf("vulnerabilities = %#v, want only %s; hidden %s", items, v1.ID, v2.ID) } + for _, group := range []string{"host", "task"} { + access := RBACListAccess{UserID: user.ID, Scope: RBACScopeAssigned} + items, err := db.ListVulnerabilitiesByGroupForAccess(1, 0, VulnerabilityListFilter{}, access, group, "severity") + if err != nil || len(items) != 1 || items[0].ID != v1.ID { + t.Fatalf("group %s returned %#v, %v; want only %s", group, items, err, v1.ID) + } + } if !db.UserCanAccessResource(user.ID, RBACScopeAssigned, "vulnerability", v1.ID) { t.Fatalf("expected project assignment to allow vulnerability detail") } diff --git a/internal/database/schemas/application.sql b/internal/database/schemas/application.sql index 225b41fc..64452bf8 100644 --- a/internal/database/schemas/application.sql +++ b/internal/database/schemas/application.sql @@ -528,6 +528,9 @@ CREATE TABLE vulnerabilities ( status TEXT NOT NULL DEFAULT 'open', vulnerability_type TEXT, target TEXT, + targets_json TEXT NOT NULL DEFAULT '[]', + targets_version INTEGER NOT NULL DEFAULT 0, + case_id TEXT REFERENCES vulnerability_cases(id) ON DELETE SET NULL, preconditions TEXT, reproduction_steps TEXT, evidence TEXT, @@ -573,9 +576,41 @@ CREATE TABLE vulnerability_retests ( source_conversation_id TEXT NOT NULL, vulnerability_id TEXT NOT NULL, source_project_id TEXT NOT NULL DEFAULT '', - pending_prompt TEXT NOT NULL DEFAULT '' + pending_prompt TEXT NOT NULL DEFAULT '', + snapshot_json TEXT NOT NULL DEFAULT '' ); +CREATE TABLE vulnerability_cases ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL, + reason TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE vulnerability_case_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + case_id TEXT NOT NULL, + action TEXT NOT NULL, + actor TEXT NOT NULL, + reason TEXT NOT NULL, + finding_ids TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE vulnerability_retest_results ( + id TEXT PRIMARY KEY, + vulnerability_id TEXT NOT NULL REFERENCES vulnerabilities(id) ON DELETE CASCADE, + conversation_id TEXT NOT NULL, + status TEXT NOT NULL, + evidence TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_vulnerability_case ON vulnerabilities(case_id); +CREATE INDEX idx_vulnerability_case_events ON vulnerability_case_events(case_id); +CREATE INDEX idx_vulnerability_retest_results ON vulnerability_retest_results(vulnerability_id, created_at); + CREATE TABLE webshell_connection_states ( connection_id TEXT PRIMARY KEY, state_json TEXT NOT NULL DEFAULT '{}', diff --git a/internal/database/vulnerability.go b/internal/database/vulnerability.go index 8c7e827f..45cdee27 100644 --- a/internal/database/vulnerability.go +++ b/internal/database/vulnerability.go @@ -3,17 +3,26 @@ package database import ( "database/sql" "fmt" + "net/netip" + "net/url" "strings" "time" + "unicode" "github.com/google/uuid" "go.uber.org/zap" + "golang.org/x/net/idna" + "golang.org/x/net/publicsuffix" ) // VulnerabilityListFilter 列表/统计/导出共用的筛选条件 type VulnerabilityListFilter struct { ID string Search string // 关键词模糊匹配(标题、描述、类型、目标等) + HostQuery string + TaskQuery string + CaseID string + SummaryOnly bool ConversationID string ProjectID string Severity string @@ -36,8 +45,24 @@ func escapeVulnerabilityLikePattern(s string) string { } func (f VulnerabilityListFilter) appendWhere(query string, args []interface{}) (string, []interface{}) { + if host := strings.TrimSpace(f.HostQuery); host != "" { + query += ` AND EXISTS (SELECT 1 FROM json_each(targets_json) jt WHERE json_extract(jt.value,'$.host') LIKE ? ESCAPE '\')` + args = append(args, escapeVulnerabilityLikePattern(strings.ToLower(host))) + } + if f.CaseID != "" { + query += " AND case_id=?" + args = append(args, f.CaseID) + } + if q := strings.TrimSpace(f.TaskQuery); q != "" { + query += ` AND (COALESCE(task_tag,'') LIKE ? ESCAPE '\' OR EXISTS ( + SELECT 1 FROM batch_tasks bt LEFT JOIN batch_task_queues bq ON bq.id=bt.queue_id + WHERE bt.conversation_id=vulnerabilities.conversation_id + AND (bt.id LIKE ? ESCAPE '\' OR bt.queue_id LIKE ? ESCAPE '\' OR COALESCE(bq.title,'') LIKE ? ESCAPE '\')))` + pattern := escapeVulnerabilityLikePattern(q) + args = append(args, pattern, pattern, pattern, pattern) + } if f.ID != "" { - query += " AND id = ?" + query += " AND vulnerabilities.id = ?" args = append(args, f.ID) } if f.ConversationID != "" { @@ -72,7 +97,7 @@ func (f VulnerabilityListFilter) appendWhere(query string, args []interface{}) ( if search != "" { pattern := escapeVulnerabilityLikePattern(search) query += ` AND ( - LOWER(id) LIKE LOWER(?) OR + LOWER(vulnerabilities.id) LIKE LOWER(?) OR LOWER(title) LIKE LOWER(?) OR LOWER(COALESCE(description, '')) LIKE LOWER(?) OR LOWER(COALESCE(vulnerability_type, '')) LIKE LOWER(?) OR @@ -130,28 +155,35 @@ func appendVulnerabilityAccessFilter(query string, args []interface{}, access RB // Vulnerability 漏洞 type Vulnerability struct { - ID string `json:"id"` - ConversationID string `json:"conversation_id"` - ProjectID string `json:"project_id,omitempty"` - ConversationTag string `json:"conversation_tag,omitempty"` - TaskTag string `json:"task_tag,omitempty"` - TaskID string `json:"task_id,omitempty"` - TaskQueueID string `json:"task_queue_id,omitempty"` - Title string `json:"title"` - Description string `json:"description"` - Severity string `json:"severity"` // critical, high, medium, low, info - Status string `json:"status"` // open, confirmed, fixed, false_positive, ignored - Type string `json:"type"` - Target string `json:"target"` - Preconditions string `json:"preconditions"` - ReproSteps string `json:"reproduction_steps"` - Evidence string `json:"evidence"` - Impact string `json:"impact"` - Recommendation string `json:"recommendation"` - RetestNotes string `json:"retest_notes"` - RetestLog string `json:"retest_log"` // append-only retest evidence; kept out of retest_notes because the edit form rewrites that field wholesale - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + ConversationID string `json:"conversation_id"` + ProjectID string `json:"project_id,omitempty"` + ConversationTag string `json:"conversation_tag,omitempty"` + TaskTag string `json:"task_tag,omitempty"` + TaskID string `json:"task_id,omitempty"` + TaskQueueID string `json:"task_queue_id,omitempty"` + TaskQueueTitle string `json:"task_queue_title,omitempty"` + Title string `json:"title"` + Description string `json:"description"` + Severity string `json:"severity"` // critical, high, medium, low, info + Status string `json:"status"` // open, confirmed, fixed, false_positive, ignored + Type string `json:"type"` + Target string `json:"target"` + TargetHost *string `json:"target_host,omitempty"` + TargetDomain string `json:"target_domain,omitempty"` + Targets []VulnerabilityTarget `json:"targets"` + CaseID string `json:"case_id,omitempty"` + CaseTitle string `json:"case_title,omitempty"` + SummaryOnly bool `json:"summary_only,omitempty"` + Preconditions string `json:"preconditions"` + ReproSteps string `json:"reproduction_steps"` + Evidence string `json:"evidence"` + Impact string `json:"impact"` + Recommendation string `json:"recommendation"` + RetestNotes string `json:"retest_notes"` + RetestLog string `json:"retest_log"` // append-only retest evidence; kept out of retest_notes because the edit form rewrites that field wholesale + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // CreateVulnerability 创建漏洞 @@ -178,8 +210,8 @@ func (db *DB) CreateVulnerability(vuln *Vulnerability) (*Vulnerability, error) { INSERT INTO vulnerabilities ( id, conversation_id, project_id, conversation_tag, task_tag, title, description, severity, status, vulnerability_type, target, preconditions, reproduction_steps, evidence, impact, recommendation, retest_notes, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + created_at, updated_at, targets_json, targets_version + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` _, err := db.Exec( @@ -187,26 +219,28 @@ func (db *DB) CreateVulnerability(vuln *Vulnerability) (*Vulnerability, error) { vuln.ID, nullIfEmpty(vuln.ConversationID), nullIfEmpty(vuln.ProjectID), vuln.ConversationTag, vuln.TaskTag, vuln.Title, vuln.Description, vuln.Severity, vuln.Status, vuln.Type, vuln.Target, vuln.Preconditions, vuln.ReproSteps, vuln.Evidence, vuln.Impact, vuln.Recommendation, vuln.RetestNotes, - vuln.CreatedAt, vuln.UpdatedAt, + vuln.CreatedAt, vuln.UpdatedAt, vulnerabilityTargetsJSON(vuln.Target), vulnerabilityTargetsVersion, ) if err != nil { return nil, fmt.Errorf("创建漏洞失败: %w", err) } db.refreshAssetRiskCacheForConversationsBestEffort(vuln.ConversationID) - return vuln, nil + return db.GetVulnerability(vuln.ID) } // GetVulnerability 获取漏洞 func (db *DB) GetVulnerability(id string) (*Vulnerability, error) { var vuln Vulnerability + var targets string query := ` - SELECT id, COALESCE(conversation_id,''), COALESCE(project_id,''), title, description, severity, status, - conversation_tag, task_tag, vulnerability_type, target, + SELECT id, COALESCE(conversation_id,''), COALESCE(project_id,''), title, COALESCE(description,''), severity, status, + COALESCE(conversation_tag,''), COALESCE(task_tag,''), COALESCE(vulnerability_type,''), COALESCE(target,''), COALESCE(preconditions,''), COALESCE(reproduction_steps,''), COALESCE(evidence,''), - impact, recommendation, COALESCE(retest_notes,''), COALESCE(retest_log,''), + COALESCE(impact,''), COALESCE(recommendation,''), COALESCE(retest_notes,''), COALESCE(retest_log,''), COALESCE((SELECT bt.id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_id, COALESCE((SELECT bt.queue_id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_queue_id, - created_at, updated_at + created_at, updated_at, targets_json, COALESCE(case_id,''), + COALESCE((SELECT title FROM vulnerability_cases vc WHERE vc.id=vulnerabilities.case_id),'') FROM vulnerabilities WHERE id = ? ` @@ -216,7 +250,7 @@ func (db *DB) GetVulnerability(id string) (*Vulnerability, error) { &vuln.Severity, &vuln.Status, &vuln.ConversationTag, &vuln.TaskTag, &vuln.Type, &vuln.Target, &vuln.Preconditions, &vuln.ReproSteps, &vuln.Evidence, &vuln.Impact, &vuln.Recommendation, &vuln.RetestNotes, &vuln.RetestLog, &vuln.TaskID, &vuln.TaskQueueID, - &vuln.CreatedAt, &vuln.UpdatedAt, + &vuln.CreatedAt, &vuln.UpdatedAt, &targets, &vuln.CaseID, &vuln.CaseTitle, ) if err != nil { if err == sql.ErrNoRows { @@ -225,7 +259,7 @@ func (db *DB) GetVulnerability(id string) (*Vulnerability, error) { return nil, fmt.Errorf("获取漏洞失败: %w", err) } - return &vuln, nil + return &vuln, scanVulnerabilityTargets(targets, &vuln) } // ListVulnerabilities 列出漏洞 @@ -234,23 +268,165 @@ func (db *DB) ListVulnerabilities(limit, offset int, filter VulnerabilityListFil } func (db *DB) ListVulnerabilitiesForAccess(limit, offset int, filter VulnerabilityListFilter, access RBACListAccess) ([]*Vulnerability, error) { - query := ` - SELECT id, COALESCE(conversation_id,''), COALESCE(project_id,''), title, description, severity, status, conversation_tag, task_tag, - vulnerability_type, target, - COALESCE(preconditions,''), COALESCE(reproduction_steps,''), COALESCE(evidence,''), - impact, recommendation, COALESCE(retest_notes,''), COALESCE(retest_log,''), - COALESCE((SELECT bt.id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_id, - COALESCE((SELECT bt.queue_id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_queue_id, - created_at, updated_at - FROM vulnerabilities + return db.ListVulnerabilitiesByGroupForAccess(limit, offset, filter, access, "", "") +} + +func vulnerabilityTargetHost(target string) string { + target = strings.TrimSpace(target) + // Historical findings can append endpoint notes or secondary targets to + // the primary URL or host. Use the leading target for a single group. + if end := strings.IndexFunc(target, func(r rune) bool { + return unicode.IsSpace(r) || strings.ContainsRune("(),;、(,;", r) + }); end >= 0 { + target = target[:end] + } + if ip, err := netip.ParseAddr(target); err == nil { + return ip.String() + } + scheme, _, hasScheme := strings.Cut(target, "://") + explicitURL := strings.HasPrefix(target, "//") || (hasScheme && !strings.ContainsAny(scheme, "/?#")) + if explicitURL { + if strings.HasPrefix(target, "//") { + target = strings.TrimPrefix(target, "//") + } else { + target = target[len(scheme)+3:] + } + } + // Paths and queries can contain raw request fragments or malformed escape + // sequences. Host extraction only needs the authority. + if end := strings.IndexAny(target, "/?#"); end >= 0 { + target = target[:end] + } + parsed, err := url.Parse("//" + target) + if err != nil || parsed.Host == "" { + return "" + } + host := parsed.Hostname() + if ip, err := netip.ParseAddr(host); err == nil { + return ip.String() + } + host, err = idna.Lookup.ToASCII(host) + host = strings.ToLower(strings.TrimSuffix(host, ".")) + if err != nil || !validAssetDomain(host) { + return "" + } + // A relative endpoint such as "api/login" does not identify a host. + if !explicitURL && parsed.Port() == "" && host != "localhost" && !strings.Contains(host, ".") { + return "" + } + return host +} + +const vulnerabilityTaskIDExpr = `COALESCE((SELECT bt.id FROM batch_tasks bt + WHERE bt.conversation_id = vulnerabilities.conversation_id ORDER BY bt.id LIMIT 1), '')` + +const vulnerabilitySeverityRankExpr = `CASE severity + WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 + WHEN 'low' THEN 3 WHEN 'info' THEN 4 ELSE 5 END` + +func vulnerabilityGroupQuery(filter VulnerabilityListFilter, access RBACListAccess, groupBy string) (string, []interface{}) { + var key string + join := "" + isIP := "0" + switch groupBy { + case "host": + key = "COALESCE(json_extract(jt.value,'$.host'),'')" + join = " LEFT JOIN json_each(targets_json) jt ON json_extract(jt.value,'$.group_host')=1" + isIP = "COALESCE(json_extract(jt.value,'$.kind')='ip',0)" + case "case": + key = "CASE WHEN case_id IS NOT NULL THEN 'case:' || case_id ELSE 'row:' || vulnerabilities.id END" + case "task": + key = `CASE + WHEN ` + vulnerabilityTaskIDExpr + ` <> '' THEN 'id:' || ` + vulnerabilityTaskIDExpr + ` + WHEN TRIM(task_tag) <> '' THEN 'tag:' || TRIM(task_tag) + ELSE '' END` + default: + return "", nil + } + query := "SELECT DISTINCT vulnerabilities.id, created_at, " + isIP + " AS group_is_ip, " + vulnerabilitySeverityRankExpr + " AS severity_rank, " + + key + " AS group_key FROM vulnerabilities" + join + " WHERE 1=1" + query, args := filter.appendWhere(query, nil) + if groupBy == "host" && strings.TrimSpace(filter.HostQuery) != "" { + query += ` AND json_extract(jt.value,'$.host') LIKE ? ESCAPE '\'` + args = append(args, escapeVulnerabilityLikePattern(strings.ToLower(strings.TrimSpace(filter.HostQuery)))) + } + return appendVulnerabilityAccessFilter(query, args, access) +} + +func (db *DB) CountVulnerabilityGroupsForAccess(filter VulnerabilityListFilter, access RBACListAccess, groupBy string) (int, error) { + query, args := vulnerabilityGroupQuery(filter, access, groupBy) + if query == "" { + return db.CountVulnerabilitiesForAccess(filter, access) + } + var count int + err := db.QueryRow("SELECT COUNT(DISTINCT group_key) FROM ("+query+")", args...).Scan(&count) + return count, err +} + +func (db *DB) ListVulnerabilitiesByGroupForAccess(limit, offset int, filter VulnerabilityListFilter, access RBACListAccess, groupBy, sortBy string) ([]*Vulnerability, error) { + hostExpr, order := "NULL", "created_at DESC" + itemOrder := "created_at DESC, id" + groupOrder := "group_key = '', group_key" + if groupBy == "host" { + groupOrder = "group_key = '', group_is_ip, group_key" + } + switch sortBy { + case "severity": + itemOrder = vulnerabilitySeverityRankExpr + ", created_at DESC, id" + groupOrder = "group_severity, group_latest DESC, " + groupOrder + case "latest": + groupOrder = "group_latest DESC, " + groupOrder + } + groupQuery, args := vulnerabilityGroupQuery(filter, access, groupBy) + var prefix, join string + if groupQuery != "" { + prefix = `WITH scoped AS (` + groupQuery + `), + page_groups AS ( + SELECT group_key, MIN(group_is_ip) AS group_is_ip, MIN(severity_rank) AS group_severity, MAX(created_at) AS group_latest + FROM scoped GROUP BY group_key + ORDER BY ` + groupOrder + ` LIMIT ? OFFSET ? + ), + page_items AS ( + SELECT scoped.id, scoped.group_key, page_groups.group_is_ip, page_groups.group_severity, page_groups.group_latest + FROM scoped JOIN page_groups USING (group_key) + )` + args = append(args, limit, offset) + join = " JOIN page_items USING (id)" + order = groupOrder + ", " + itemOrder + if groupBy == "host" { + hostExpr = "group_key" + } + } else if groupBy == "none" || sortBy != "" { + order = itemOrder + } + description := "COALESCE(description,'')" + details := `COALESCE(preconditions,''), COALESCE(reproduction_steps,''), COALESCE(evidence,''), + COALESCE(impact,''), COALESCE(recommendation,''), COALESCE(retest_notes,''), COALESCE(retest_log,'')` + if filter.SummaryOnly { + description = "SUBSTR(COALESCE(description,''),1,240)" + details = "'','','','','','',''" + } + query := prefix + ` + SELECT vulnerabilities.id, COALESCE(conversation_id,''), COALESCE(project_id,''), title, ` + description + `, severity, status, COALESCE(conversation_tag,''), COALESCE(task_tag,''), + COALESCE(vulnerability_type,''), COALESCE(target,''), + ` + details + `, + ` + vulnerabilityTaskIDExpr + ` AS task_id, + COALESCE((SELECT bt.queue_id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id ORDER BY bt.id LIMIT 1), '') AS task_queue_id, + COALESCE((SELECT bq.title FROM batch_tasks bt JOIN batch_task_queues bq ON bq.id=bt.queue_id WHERE bt.conversation_id=vulnerabilities.conversation_id ORDER BY bt.id LIMIT 1),''), + created_at, updated_at, ` + hostExpr + ` AS target_host, targets_json, COALESCE(case_id,''), + COALESCE((SELECT title FROM vulnerability_cases vc WHERE vc.id=vulnerabilities.case_id),'') + FROM vulnerabilities` + join + ` WHERE 1=1 ` - args := []interface{}{} - query, args = filter.appendWhere(query, args) - query, args = appendVulnerabilityAccessFilter(query, args, access) - - query += " ORDER BY created_at DESC LIMIT ? OFFSET ?" - args = append(args, limit, offset) + if groupQuery == "" { + query, args = filter.appendWhere(query, args) + query, args = appendVulnerabilityAccessFilter(query, args, access) + } + query += " ORDER BY " + order + if groupQuery == "" { + query += " LIMIT ? OFFSET ?" + args = append(args, limit, offset) + } rows, err := db.Query(query, args...) if err != nil { @@ -261,21 +437,34 @@ func (db *DB) ListVulnerabilitiesForAccess(limit, offset int, filter Vulnerabili vulnerabilities := make([]*Vulnerability, 0) for rows.Next() { var vuln Vulnerability + var targets string err := rows.Scan( &vuln.ID, &vuln.ConversationID, &vuln.ProjectID, &vuln.Title, &vuln.Description, &vuln.Severity, &vuln.Status, &vuln.ConversationTag, &vuln.TaskTag, &vuln.Type, &vuln.Target, &vuln.Preconditions, &vuln.ReproSteps, &vuln.Evidence, &vuln.Impact, &vuln.Recommendation, &vuln.RetestNotes, - &vuln.RetestLog, &vuln.TaskID, &vuln.TaskQueueID, - &vuln.CreatedAt, &vuln.UpdatedAt, + &vuln.RetestLog, &vuln.TaskID, &vuln.TaskQueueID, &vuln.TaskQueueTitle, + &vuln.CreatedAt, &vuln.UpdatedAt, &vuln.TargetHost, &targets, &vuln.CaseID, &vuln.CaseTitle, ) if err != nil { db.logger.Warn("扫描漏洞记录失败", zap.Error(err)) continue } + if vuln.TargetHost != nil { + vuln.TargetDomain = *vuln.TargetHost + if _, err := netip.ParseAddr(*vuln.TargetHost); err != nil { + if domain, err := publicsuffix.EffectiveTLDPlusOne(*vuln.TargetHost); err == nil { + vuln.TargetDomain = domain + } + } + } + if err := scanVulnerabilityTargets(targets, &vuln); err != nil { + return nil, err + } + vuln.SummaryOnly = filter.SummaryOnly vulnerabilities = append(vulnerabilities, &vuln) } - return vulnerabilities, nil + return vulnerabilities, rows.Err() } // CountVulnerabilities 统计漏洞总数(支持筛选条件) @@ -308,19 +497,30 @@ func (db *DB) UpdateVulnerability(id string, vuln *Vulnerability) error { UPDATE vulnerabilities SET project_id = ?, conversation_tag = ?, task_tag = ?, title = ?, description = ?, severity = ?, status = ?, vulnerability_type = ?, target = ?, preconditions = ?, reproduction_steps = ?, evidence = ?, impact = ?, - recommendation = ?, retest_notes = ?, updated_at = ? + recommendation = ?, retest_notes = ?, updated_at = ?, targets_json = ?, targets_version=? WHERE id = ? ` - _, err := db.Exec( + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if err := detachVulnerabilityCaseOnProjectChange(tx, id, vuln.ProjectID); err != nil { + return err + } + _, err = tx.Exec( query, nullIfEmpty(vuln.ProjectID), vuln.ConversationTag, vuln.TaskTag, vuln.Title, vuln.Description, vuln.Severity, vuln.Status, vuln.Type, vuln.Target, vuln.Preconditions, vuln.ReproSteps, vuln.Evidence, vuln.Impact, - vuln.Recommendation, vuln.RetestNotes, vuln.UpdatedAt, id, + vuln.Recommendation, vuln.RetestNotes, vuln.UpdatedAt, vulnerabilityTargetsJSON(vuln.Target), vulnerabilityTargetsVersion, id, ) if err != nil { return fmt.Errorf("更新漏洞失败: %w", err) } + if err := tx.Commit(); err != nil { + return err + } db.refreshAssetRiskCacheForConversationsBestEffort(oldConversationID, vuln.ConversationID) return nil @@ -433,6 +633,11 @@ func (db *DB) GetVulnerabilityStatsForAccess(filter VulnerabilityListFilter, acc return nil, fmt.Errorf("获取总漏洞数失败: %w", err) } stats["total"] = totalCount + var distinctCount int + if err := db.QueryRow("SELECT COUNT(DISTINCT CASE WHEN case_id IS NULL THEN 'row:'||id ELSE 'case:'||case_id END) FROM vulnerabilities "+where, args...).Scan(&distinctCount); err != nil { + return nil, err + } + stats["distinct"] = distinctCount // 按严重程度统计 severityQuery := "SELECT severity, COUNT(*) FROM vulnerabilities " + where + " GROUP BY severity" diff --git a/internal/database/vulnerability_retest.go b/internal/database/vulnerability_retest.go index a219d3ad..539fea9e 100644 --- a/internal/database/vulnerability_retest.go +++ b/internal/database/vulnerability_retest.go @@ -1,7 +1,9 @@ package database import ( + "crypto/sha256" "database/sql" + "encoding/json" "fmt" "strings" "time" @@ -106,7 +108,11 @@ func (db *DB) CreateVulnerabilityRetest(v *Vulnerability, meta ConversationCreat return nil, err } } - if _, err = tx.Exec(`INSERT INTO vulnerability_retests (conversation_id, source_conversation_id, vulnerability_id, source_project_id) VALUES (?,?,?,?)`, id, v.ConversationID, v.ID, conv.ProjectID); err != nil { + snapshot, err := json.Marshal(v) + if err != nil { + return nil, err + } + if _, err = tx.Exec(`INSERT INTO vulnerability_retests (conversation_id, source_conversation_id, vulnerability_id, source_project_id, snapshot_json) VALUES (?,?,?,?,?)`, id, v.ConversationID, v.ID, conv.ProjectID, string(snapshot)); err != nil { return nil, err } // Preserve the source's approval policy without copying live interrupts. @@ -131,8 +137,23 @@ func (db *DB) CreateVulnerabilityRetest(v *Vulnerability, meta ConversationCreat // UpdateVulnerabilityRetestResult appends evidence to retest_log; retest_notes stays operator-owned. func (db *DB) UpdateVulnerabilityRetestResult(id, status, notes, conversationID string) error { + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + key := fmt.Sprintf("%x", sha256.Sum256([]byte(id+"\x00"+conversationID+"\x00"+status+"\x00"+notes))) + insert, err := tx.Exec("INSERT OR IGNORE INTO vulnerability_retest_results(id,vulnerability_id,conversation_id,status,evidence) VALUES(?,?,?,?,?)", key, id, conversationID, status, notes) + if err != nil { + return err + } + if n, err := insert.RowsAffected(); err != nil { + return err + } else if n == 0 { + return nil + } entry := fmt.Sprintf("\n\n[复测 %s | 对话 %s | 状态 %s]\n%s", time.Now().Format(time.RFC3339), conversationID, status, notes) - result, err := db.Exec(`UPDATE vulnerabilities SET status=?, retest_log=COALESCE(retest_log,'') || ?, updated_at=? WHERE id=?`, status, entry, time.Now(), id) + result, err := tx.Exec(`UPDATE vulnerabilities SET status=?, retest_log=COALESCE(retest_log,'') || ?, updated_at=? WHERE id=?`, status, entry, time.Now(), id) if err != nil { return err } @@ -143,6 +164,9 @@ func (db *DB) UpdateVulnerabilityRetestResult(id, status, notes, conversationID if n != 1 { return fmt.Errorf("漏洞不存在") } + if err := tx.Commit(); err != nil { + return err + } if v, err := db.GetVulnerability(id); err == nil { db.refreshAssetRiskCacheForConversationsBestEffort(v.ConversationID) } diff --git a/internal/database/vulnerability_workspace.go b/internal/database/vulnerability_workspace.go new file mode 100644 index 00000000..1286288c --- /dev/null +++ b/internal/database/vulnerability_workspace.go @@ -0,0 +1,318 @@ +package database + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/netip" + "net/url" + "regexp" + "strings" + "time" + + "github.com/google/uuid" + "golang.org/x/net/publicsuffix" +) + +type VulnerabilityTarget struct { + Raw string `json:"raw"` + Host string `json:"host"` + Domain string `json:"domain"` + Scheme string `json:"scheme,omitempty"` + Port string `json:"port,omitempty"` + Path string `json:"path,omitempty"` + Kind string `json:"kind"` + GroupHost bool `json:"group_host"` +} + +const vulnerabilityTargetsVersion = 2 + +var vulnerabilityTargetPattern = regexp.MustCompile(`(?i)(?:[a-z][a-z0-9+.-]*://|//)[^\s<>"',;,;、()]+|(?:\[[0-9a-f:]+\]|(?:[\p{L}\p{N}][\p{L}\p{N}.-]*\.)+[\p{L}\p{N}-]+|localhost)(?::[0-9]+)?(?:/[^\s<>"',;,;、()]*)?`) +var vulnerabilityVersionContext = regexp.MustCompile(`(?i)(?:版本(?:号)?|version|ver|Citrix Gateway|NetScaler(?: ADC)?|Keycloak|Ivanti Connect Secure|nginx|Apache|OpenSSL|Tomcat)\s*[:=]?\s*$`) +var vulnerabilityVersionToken = regexp.MustCompile(`(?i)^\d+(?:\.\d+)*\.(?:x|final|release|snapshot|alpha\d*|beta\d*|rc\d*)$`) + +func vulnerabilityTargetsJSON(raw string) string { + targets := []VulnerabilityTarget{} + seen := map[string]bool{} + matches := vulnerabilityTargetPattern.FindAllStringIndex(raw, -1) + if len(matches) == 0 { + matches = [][]int{{0, len(raw)}} + } + for _, match := range matches { + candidate := strings.TrimSpace(raw[match[0]:match[1]]) + if match[0] > 0 && strings.ContainsRune("/@", rune(raw[match[0]-1])) && !strings.Contains(candidate, "://") { + continue + } + candidate = strings.TrimRight(candidate, ".,;:!?)\x60") + host := vulnerabilityTargetHost(candidate) + if host == "" { + continue + } + _, ipErr := netip.ParseAddr(host) + explicitHost := strings.Contains(candidate, "://") || strings.HasPrefix(candidate, "//") + if !explicitHost && vulnerabilityVersionToken.MatchString(host) { + continue + } + if ipErr == nil && !explicitHost && vulnerabilityVersionContext.MatchString(raw[:match[0]]) { + continue + } + if ipErr != nil && !explicitHost { + _, icann := publicsuffix.PublicSuffix(host) + localHost := host == "localhost" || strings.HasSuffix(host, ".test") || strings.HasSuffix(host, ".local") || strings.HasSuffix(host, ".internal") || strings.HasSuffix(host, ".lan") + if !icann && !localHost { + continue + } + } + target := VulnerabilityTarget{Raw: candidate, Host: host, Domain: host, Kind: "domain"} + if ipErr == nil { + target.Kind = "ip" + } else { + if domain, err := publicsuffix.EffectiveTLDPlusOne(host); err == nil { + target.Domain = domain + } + } + value := candidate + if !strings.Contains(value, "://") && !strings.HasPrefix(value, "//") { + value = "//" + value + } + if parsed, err := url.Parse(value); err == nil { + target.Scheme = strings.ToLower(parsed.Scheme) + target.Port = parsed.Port() + target.Path = parsed.EscapedPath() + } + key := target.Host + "|" + target.Scheme + "|" + target.Port + "|" + target.Path + if seen[key] { + continue + } + seen[key] = true + targets = append(targets, target) + } + hasDomain := false + for _, target := range targets { + if target.Kind == "domain" { + hasDomain = true + break + } + } + for i := range targets { + targets[i].GroupHost = targets[i].Kind == "domain" || !hasDomain + } + data, _ := json.Marshal(targets) + return string(data) +} + +func (db *DB) backfillVulnerabilityTargets() error { + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + rows, err := tx.Query("SELECT id, COALESCE(target,'') FROM vulnerabilities WHERE targets_version", vulnerabilityTargetsVersion) + if err != nil { + return err + } + targets := map[string]string{} + for rows.Next() { + var id, raw string + if err := rows.Scan(&id, &raw); err != nil { + rows.Close() + return err + } + targets[id] = vulnerabilityTargetsJSON(raw) + } + err = rows.Err() + rows.Close() + if err != nil { + return err + } + for id, raw := range targets { + if _, err := tx.Exec("UPDATE vulnerabilities SET targets_json=?,targets_version=? WHERE id=?", raw, vulnerabilityTargetsVersion, id); err != nil { + return err + } + } + return tx.Commit() +} + +type VulnerabilityLinkedAsset struct { + ID string `json:"id"` + Host string `json:"host"` + Port int `json:"port"` +} + +func (db *DB) VulnerabilityLinkedAssets(v *Vulnerability, access RBACListAccess) ([]VulnerabilityLinkedAsset, error) { + query, args := appendAssetAccess(`SELECT assets.id, assets.host, COALESCE(assets.port,0) + FROM assets WHERE COALESCE(assets.project_id,'')=? AND EXISTS ( + SELECT 1 FROM json_each(?) jt WHERE + json_extract(jt.value,'$.host')=LOWER(COALESCE(NULLIF(assets.domain,''),NULLIF(assets.ip,''),assets.host)) + AND (COALESCE(assets.port,0)=0 OR CAST(json_extract(jt.value,'$.port') AS INTEGER)=assets.port + OR (COALESCE(json_extract(jt.value,'$.port'),'')='' AND + ((json_extract(jt.value,'$.scheme')='https' AND assets.port=443) + OR (json_extract(jt.value,'$.scheme')='http' AND assets.port=80)))) + )`, []interface{}{v.ProjectID, vulnerabilityTargetsJSON(v.Target)}, access, "assets") + rows, err := db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + assets := []VulnerabilityLinkedAsset{} + for rows.Next() { + var a VulnerabilityLinkedAsset + if err := rows.Scan(&a.ID, &a.Host, &a.Port); err != nil { + return nil, err + } + assets = append(assets, a) + } + return assets, rows.Err() +} + +// MergeVulnerabilities preserves original reports. The transaction reserves the +// writer before checking membership, ownership and project boundaries. +func (db *DB) MergeVulnerabilities(ids []string, title, reason, actor string, access RBACListAccess) (string, error) { + if len(ids) < 2 || len(ids) > 100 || strings.TrimSpace(title) == "" || strings.TrimSpace(reason) == "" { + return "", errors.New("请选择 2–100 条记录,并填写漏洞名称和归并理由") + } + tx, err := db.Begin() + if err != nil { + return "", err + } + defer tx.Rollback() + seen := map[string]bool{} + project := "" + for i, id := range ids { + if seen[id] { + return "", errors.New("重复的漏洞 ID") + } + seen[id] = true + query, args := appendVulnerabilityAccessFilter("SELECT COALESCE(project_id,''), COALESCE(case_id,'') FROM vulnerabilities WHERE id=?", []interface{}{id}, access) + var pid, cid string + if err := tx.QueryRow(query, args...).Scan(&pid, &cid); err != nil { + return "", errors.New("漏洞不存在或无权归并") + } + if cid != "" { + return "", errors.New("记录已归并,请先拆分原归并组") + } + if i == 0 { + project = pid + } else if project != pid { + return "", errors.New("归并记录必须属于同一项目") + } + } + id := uuid.NewString() + if _, err := tx.Exec("INSERT INTO vulnerability_cases(id,project_id,title,reason) VALUES(?,?,?,?)", id, project, strings.TrimSpace(title), strings.TrimSpace(reason)); err != nil { + return "", err + } + for _, member := range ids { + if _, err := tx.Exec("UPDATE vulnerabilities SET case_id=? WHERE id=?", id, member); err != nil { + return "", err + } + } + rawIDs, _ := json.Marshal(ids) + if _, err := tx.Exec("INSERT INTO vulnerability_case_events(case_id,action,actor,reason,finding_ids) VALUES(?,?,?,?,?)", + id, "merge", actor, strings.TrimSpace(reason), string(rawIDs)); err != nil { + return "", err + } + return id, tx.Commit() +} + +func (db *DB) DissolveVulnerabilityCase(id, reason, actor string, access RBACListAccess) error { + if strings.TrimSpace(reason) == "" { + return errors.New("请填写拆分理由") + } + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + var all, allowed int + if err = tx.QueryRow("SELECT COUNT(*) FROM vulnerabilities WHERE case_id=?", id).Scan(&all); err != nil { + return err + } + query, args := appendVulnerabilityAccessFilter("SELECT COUNT(*) FROM vulnerabilities WHERE case_id=?", []interface{}{id}, access) + if err = tx.QueryRow(query, args...).Scan(&allowed); err != nil { + return err + } + if all == 0 || all != allowed { + return errors.New("归并组不存在或无权修改全部成员") + } + if _, err = tx.Exec(`INSERT INTO vulnerability_case_events(case_id,action,actor,reason,finding_ids) + SELECT ?, 'split', ?, ?, json_group_array(id) FROM vulnerabilities WHERE case_id=?`, id, actor, reason, id); err != nil { + return err + } + if _, err = tx.Exec("UPDATE vulnerabilities SET case_id=NULL WHERE case_id=?", id); err != nil { + return err + } + if _, err = tx.Exec("DELETE FROM vulnerability_cases WHERE id=?", id); err != nil { + return err + } + return tx.Commit() +} + +type VulnerabilityHistoryItem struct { + Kind string `json:"kind"` + ConversationID string `json:"conversation_id,omitempty"` + Status string `json:"status,omitempty"` + Evidence string `json:"evidence,omitempty"` + Actor string `json:"actor,omitempty"` + CreatedAt time.Time `json:"created_at"` + Snapshot *Vulnerability `json:"snapshot,omitempty"` +} + +func (db *DB) VulnerabilityHistory(id string) ([]VulnerabilityHistoryItem, error) { + rows, err := db.Query(` + SELECT 'retest',conversation_id,status,evidence,'',created_at, + COALESCE((SELECT snapshot_json FROM vulnerability_retests vr WHERE vr.conversation_id=vulnerability_retest_results.conversation_id),'') + FROM vulnerability_retest_results WHERE vulnerability_id=? + UNION ALL + SELECT action,'','',reason,actor,created_at,'' FROM vulnerability_case_events + WHERE EXISTS (SELECT 1 FROM json_each(finding_ids) WHERE value=?) + UNION ALL + SELECT 'retest_started',vr.conversation_id,'','','',c.created_at,vr.snapshot_json + FROM vulnerability_retests vr JOIN conversations c ON c.id=vr.conversation_id + WHERE vr.vulnerability_id=? AND NOT EXISTS ( + SELECT 1 FROM vulnerability_retest_results r WHERE r.vulnerability_id=vr.vulnerability_id AND r.conversation_id=vr.conversation_id) + ORDER BY created_at DESC`, id, id, id) + if err != nil { + return nil, err + } + defer rows.Close() + items := []VulnerabilityHistoryItem{} + for rows.Next() { + var item VulnerabilityHistoryItem + var snapshot string + if err := rows.Scan(&item.Kind, &item.ConversationID, &item.Status, &item.Evidence, &item.Actor, &item.CreatedAt, &snapshot); err != nil { + return nil, err + } + if snapshot != "" { + if err := json.Unmarshal([]byte(snapshot), &item.Snapshot); err != nil { + return nil, err + } + } + items = append(items, item) + } + return items, rows.Err() +} + +func scanVulnerabilityTargets(raw string, v *Vulnerability) error { + if err := json.Unmarshal([]byte(raw), &v.Targets); err != nil { + return fmt.Errorf("读取漏洞目标失败: %w", err) + } + return nil +} + +// Keep project changes from silently carrying a cross-project merge relation. +func detachVulnerabilityCaseOnProjectChange(tx *sql.Tx, id, projectID string) error { + var oldProject, caseID string + if err := tx.QueryRow("SELECT COALESCE(project_id,''),COALESCE(case_id,'') FROM vulnerabilities WHERE id=?", id).Scan(&oldProject, &caseID); err != nil { + return err + } + if caseID == "" || oldProject == projectID { + return nil + } + raw, _ := json.Marshal([]string{id}) + if _, err := tx.Exec("INSERT INTO vulnerability_case_events(case_id,action,actor,reason,finding_ids) VALUES(?,'detach','system','项目归属变更',?)", caseID, string(raw)); err != nil { + return err + } + _, err := tx.Exec("UPDATE vulnerabilities SET case_id=NULL WHERE id=?", id) + return err +} diff --git a/internal/database/vulnerability_workspace_test.go b/internal/database/vulnerability_workspace_test.go new file mode 100644 index 00000000..8d5ac6ad --- /dev/null +++ b/internal/database/vulnerability_workspace_test.go @@ -0,0 +1,183 @@ +package database + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestVulnerabilityWorkspaceTargetsAndLegacyUpgrade(t *testing.T) { + db := newRBACTestDB(t) + v, err := db.CreateVulnerability(&Vulnerability{Title: "multiple", Severity: "high", Target: "https://api.example.co.uk/login;https://store.example.co.uk:8443/orders, http://192.0.2.5:8080/"}) + if err != nil { + t.Fatal(err) + } + if len(v.Targets) != 3 || v.Targets[0].Domain != "example.co.uk" || v.Targets[2].Domain != "192.0.2.5" { + t.Fatalf("targets: %+v", v.Targets) + } + if _, err := db.Exec("UPDATE vulnerabilities SET targets_json='[]',targets_version=0 WHERE id=?", v.ID); err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + if err := db.initTables(); err != nil { + t.Fatal(err) + } + } + count, err := db.CountVulnerabilityGroupsForAccess(VulnerabilityListFilter{}, RBACListAccess{}, "host") + if err != nil || count != 2 { + t.Fatalf("host count: %d %v", count, err) + } + rows, err := db.ListVulnerabilitiesByGroupForAccess(2, 0, VulnerabilityListFilter{SummaryOnly: true}, RBACListAccess{}, "host", "") + if err != nil || len(rows) != 2 || rows[0].ID != v.ID || rows[1].ID != v.ID { + t.Fatalf("group page: %+v %v", rows, err) + } + matched, err := db.ListVulnerabilitiesByGroupForAccess(20, 0, VulnerabilityListFilter{ID: v.ID, HostQuery: "store."}, RBACListAccess{}, "host", "") + if err != nil || len(matched) != 1 || *matched[0].TargetHost != "store.example.co.uk" { + t.Fatalf("host search: %+v %v", matched, err) + } +} + +func TestVulnerabilityDomainGroupsPreferNamesAndRepairStoredTargets(t *testing.T) { + db := newRBACTestDB(t) + raw := "https://vpn.example.com/login (真实 IP 192.0.2.25,Citrix Gateway 26.6.1.20,Keycloak 4.4.0.final,Ivanti Connect Secure 22.x,getFunction.php)" + v, err := db.CreateVulnerability(&Vulnerability{Title: "domain with infrastructure notes", Severity: "high", Target: raw}) + if err != nil { + t.Fatal(err) + } + if len(v.Targets) != 2 || v.Targets[0].Host != "vpn.example.com" || !v.Targets[0].GroupHost || v.Targets[1].Host != "192.0.2.25" || v.Targets[1].GroupHost { + t.Fatalf("parsed targets: %+v", v.Targets) + } + if _, err := db.Exec(`UPDATE vulnerabilities SET targets_json='[{"host":"22.x"}]',targets_version=1 WHERE id=?`, v.ID); err != nil { + t.Fatal(err) + } + if err := db.backfillVulnerabilityTargets(); err != nil { + t.Fatal(err) + } + ip, err := db.CreateVulnerability(&Vulnerability{Title: "IP only", Severity: "high", Target: "192.0.2.5:8080"}) + if err != nil { + t.Fatal(err) + } + rows, err := db.ListVulnerabilitiesByGroupForAccess(20, 0, VulnerabilityListFilter{}, RBACListAccess{}, "host", "") + if err != nil || len(rows) != 2 || rows[0].ID != v.ID || rows[1].ID != ip.ID { + t.Fatalf("domain-first groups: %+v %v", rows, err) + } + got, _ := db.GetVulnerability(v.ID) + if got.Target != raw || len(got.Targets) != 2 { + t.Fatalf("backfill changed raw target: %+v", got) + } +} + +func TestVulnerabilityTargetParserIgnoresRelativeEndpoints(t *testing.T) { + if got := vulnerabilityTargetsJSON("GET /api/config.php HTTP/1.1"); got != "[]" { + t.Fatalf("relative endpoint parsed as host: %s", got) + } + var targets []VulnerabilityTarget + if err := json.Unmarshal([]byte(vulnerabilityTargetsJSON("https://a.example.com/a,https://b.example.com/b")), &targets); err != nil || len(targets) != 2 { + t.Fatalf("comma-separated targets: %+v %v", targets, err) + } +} + +func TestVulnerabilityWorkspaceMergeSplitAndScope(t *testing.T) { + db := newRBACTestDB(t) + a, _ := db.CreateVulnerability(&Vulnerability{Title: "report A", Severity: "high", Evidence: "request A"}) + b, _ := db.CreateVulnerability(&Vulnerability{Title: "report B", Severity: "medium", Evidence: "request B"}) + if _, err := db.MergeVulnerabilities([]string{a.ID, b.ID}, "issue", "same root cause", "reader", RBACListAccess{UserID: "outsider", Scope: RBACScopeAssigned}); err == nil { + t.Fatal("unauthorized merge") + } + id, err := db.MergeVulnerabilities([]string{a.ID, b.ID}, "issue", "same root cause", "operator", RBACListAccess{}) + if err != nil { + t.Fatal(err) + } + rows, err := db.ListVulnerabilitiesByGroupForAccess(1, 0, VulnerabilityListFilter{SummaryOnly: true}, RBACListAccess{}, "case", "") + if err != nil || len(rows) != 2 || rows[0].CaseID != id || rows[0].Evidence != "" || !rows[0].SummaryOnly { + t.Fatalf("case summaries: %+v %v", rows, err) + } + stats, err := db.GetVulnerabilityStats(VulnerabilityListFilter{}) + if err != nil || stats["distinct"] != 1 || stats["total"] != 2 { + t.Fatalf("stats: %+v %v", stats, err) + } + got, err := db.GetVulnerability(a.ID) + if err != nil || got.Evidence != "request A" { + t.Fatal("original evidence changed", err) + } + if _, err := db.MergeVulnerabilities([]string{a.ID, b.ID}, "again", "retry", "operator", RBACListAccess{}); err == nil { + t.Fatal("accepted already merged records") + } + if err := db.DissolveVulnerabilityCase(id, "different causes", "outsider", RBACListAccess{UserID: "outsider", Scope: RBACScopeAssigned}); err == nil { + t.Fatal("unauthorized split") + } + if err := db.DissolveVulnerabilityCase(id, "different causes", "operator", RBACListAccess{}); err != nil { + t.Fatal(err) + } + got, _ = db.GetVulnerability(a.ID) + if got.CaseID != "" || got.Evidence != "request A" { + t.Fatal("split damaged original") + } + history, err := db.VulnerabilityHistory(a.ID) + if err != nil || len(history) != 2 { + t.Fatalf("history: %+v %v", history, err) + } +} + +func TestVulnerabilityWorkspaceRetestIdempotencyAndSnapshot(t *testing.T) { + db := newRBACTestDB(t) + source, _ := db.CreateConversation("source", ConversationCreateMeta{}) + v, _ := db.CreateVulnerability(&Vulnerability{Title: "finding", Evidence: "original proof", Severity: "high", ConversationID: source.ID}) + branch, err := db.CreateVulnerabilityRetest(v, ConversationCreateMeta{}) + if err != nil { + t.Fatal(err) + } + var raw string + if err := db.QueryRow("SELECT snapshot_json FROM vulnerability_retests WHERE conversation_id=?", branch.ID).Scan(&raw); err != nil { + t.Fatal(err) + } + var snapshot Vulnerability + if err := json.Unmarshal([]byte(raw), &snapshot); err != nil || snapshot.Evidence != "original proof" { + t.Fatal("snapshot", err) + } + if err := db.UpdateVulnerabilityRetestResult(v.ID, "fixed", "403 verified", branch.ID); err != nil { + t.Fatal(err) + } + v.Status = "confirmed" + v.Evidence = "new proof" + if err := db.UpdateVulnerability(v.ID, v); err != nil { + t.Fatal(err) + } + if err := db.UpdateVulnerabilityRetestResult(v.ID, "fixed", "403 verified", branch.ID); err != nil { + t.Fatal(err) + } + got, _ := db.GetVulnerability(v.ID) + if got.Status != "confirmed" || strings.Count(got.RetestLog, "403 verified") != 1 { + t.Fatalf("retry changed state: %+v", got) + } + history, err := db.VulnerabilityHistory(v.ID) + if err != nil || len(history) != 1 || history[0].Status != "fixed" { + t.Fatalf("history: %+v %v", history, err) + } +} + +func TestVulnerabilityWorkspaceProjectBoundary(t *testing.T) { + db := newRBACTestDB(t) + p1, _ := db.CreateProject(&Project{Name: "one"}) + p2, _ := db.CreateProject(&Project{Name: "two"}) + a, _ := db.CreateVulnerability(&Vulnerability{Title: "a", Severity: "high", ProjectID: p1.ID}) + b, _ := db.CreateVulnerability(&Vulnerability{Title: "b", Severity: "high", ProjectID: p2.ID}) + if _, err := db.MergeVulnerabilities([]string{a.ID, b.ID}, "issue", "similar", "op", RBACListAccess{}); err == nil { + t.Fatal("cross project merge") + } + b.ProjectID = p1.ID + if err := db.UpdateVulnerability(b.ID, b); err != nil { + t.Fatal(err) + } + if _, err := db.MergeVulnerabilities([]string{a.ID, b.ID}, "issue", "same", "op", RBACListAccess{}); err != nil { + t.Fatal(err) + } + b.ProjectID = p2.ID + if err := db.UpdateVulnerability(b.ID, b); err != nil { + t.Fatal(err) + } + got, _ := db.GetVulnerability(b.ID) + if got.CaseID != "" { + t.Fatal("project move retained case") + } +} diff --git a/internal/handler/vulnerability.go b/internal/handler/vulnerability.go index fe7ef833..0833c321 100644 --- a/internal/handler/vulnerability.go +++ b/internal/handler/vulnerability.go @@ -168,6 +168,9 @@ func parseVulnerabilityListFilter(c *gin.Context) database.VulnerabilityListFilt ProjectID: c.Query("project_id"), ID: c.Query("id"), Search: q, + HostQuery: c.Query("host_q"), + TaskQuery: c.Query("task_q"), + CaseID: c.Query("case_id"), ConversationID: c.Query("conversation_id"), Severity: c.Query("severity"), Status: c.Query("status"), @@ -183,9 +186,19 @@ func (h *VulnerabilityHandler) ListVulnerabilities(c *gin.Context) { offsetStr := c.DefaultQuery("offset", "0") pageStr := c.Query("page") filter := parseVulnerabilityListFilter(c) + filter.SummaryOnly = c.Query("summary") == "true" access := vulnerabilityAccessFromContext(c) + groupBy := c.Query("group_by") + sortBy := c.Query("sort") + if sortBy != "" && sortBy != "severity" && sortBy != "latest" { + c.JSON(http.StatusBadRequest, gin.H{"error": "sort must be severity or latest"}) + return + } limit, _ := strconv.Atoi(limitStr) + if limit <= 0 || limit > 100 { + limit = 20 + } offset, _ := strconv.Atoi(offsetStr) page := 1 @@ -197,25 +210,14 @@ func (h *VulnerabilityHandler) ListVulnerabilities(c *gin.Context) { } } - if limit <= 0 || limit > 100 { - limit = 20 - } if offset < 0 { offset = 0 } // 获取总数 - total, err := h.db.CountVulnerabilitiesForAccess(filter, access) + total, err := h.db.CountVulnerabilityGroupsForAccess(filter, access, groupBy) if err != nil { h.logger.Error("获取漏洞总数失败", zap.Error(err)) - // 继续执行,使用0作为总数 - total = 0 - } - - // 获取漏洞列表 - vulnerabilities, err := h.db.ListVulnerabilitiesForAccess(limit, offset, filter, access) - if err != nil { - h.logger.Error("获取漏洞列表失败", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -231,6 +233,17 @@ func (h *VulnerabilityHandler) ListVulnerabilities(c *gin.Context) { page = (offset / limit) + 1 } + if (groupBy == "host" || groupBy == "task" || groupBy == "none" || groupBy == "case") && page > totalPages { + page = totalPages + offset = (page - 1) * limit + } + vulnerabilities, err := h.db.ListVulnerabilitiesByGroupForAccess(limit, offset, filter, access, groupBy, sortBy) + if err != nil { + h.logger.Error("获取漏洞列表失败", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + response := ListVulnerabilitiesResponse{ Vulnerabilities: vulnerabilities, Total: total, diff --git a/internal/handler/vulnerability_group_test.go b/internal/handler/vulnerability_group_test.go new file mode 100644 index 00000000..9c9907c7 --- /dev/null +++ b/internal/handler/vulnerability_group_test.go @@ -0,0 +1,146 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "cyberstrike-ai/internal/database" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +func TestListVulnerabilitiesGroupedViews(t *testing.T) { + db, err := database.NewDB(filepath.Join(t.TempDir(), "grouped.db"), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + linked, err := db.CreateConversation("linked task", database.ConversationCreateMeta{}) + if err != nil { + t.Fatal(err) + } + if err := db.CreateBatchQueue("queue", "queue", "", "eino_single", "manual", "", nil, "", 1, + []map[string]interface{}{{"id": "task", "message": "task"}}); err != nil { + t.Fatal(err) + } + if err := db.UpdateBatchTaskStatus("queue", "task", "completed", linked.ID, "", ""); err != nil { + t.Fatal(err) + } + + for i, item := range []struct { + id, target, severity, taskTag, conversationID string + }{ + {"domain-old", "HTTPS://Example.COM.:443/login?keyword=%ZZ (旧接口);https://secondary.example.net/", "high", "a", ""}, + {"domain-new", "example.com:8443(受影响的服务)", "high", "", linked.ID}, + {"subdomain", "https://sub.example.com(旧记录)", "high", "a", ""}, + {"ipv6", "https://[2001:0DB8::1]:8080/", "high", "b", linked.ID}, + {"unknown", "/api/login", "high", "", ""}, + {"filtered", "https://example.com/filtered", "low", "a", linked.ID}, + } { + _, err := db.CreateVulnerability(&database.Vulnerability{ + ID: item.id, Target: item.target, Title: item.id, Severity: item.severity, + TaskTag: item.taskTag, ConversationID: item.conversationID, + CreatedAt: time.Unix(int64(i+1), 0), + }) + if err != nil { + t.Fatal(err) + } + } + + h := NewVulnerabilityHandler(db, zap.NewNop()) + fetch := func(query string) ListVulnerabilitiesResponse { + t.Helper() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/vulnerabilities?limit=2&"+query, nil) + h.ListVulnerabilities(c) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + var result ListVulnerabilitiesResponse + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + return result + } + + for _, tc := range []struct { + query string + page, total, pages int + ids, hosts []string + }{ + {"group_by=host&page=1", 1, 5, 3, []string{"domain-new", "domain-old", "domain-old"}, []string{"example.com", "example.com", "secondary.example.net"}}, + {"group_by=host&page=4", 3, 5, 3, []string{"unknown"}, []string{""}}, + {"group_by=task&page=1", 1, 3, 2, []string{"ipv6", "domain-new", "subdomain", "domain-old"}, nil}, + {"group_by=task&page=2", 2, 3, 2, []string{"unknown"}, nil}, + {"group_by=task&task_q=queue", 1, 1, 1, []string{"ipv6", "domain-new"}, nil}, + {"group_by=none&page=1", 1, 5, 3, []string{"unknown", "ipv6"}, nil}, + {"page=1", 1, 5, 3, []string{"unknown", "ipv6"}, nil}, + } { + t.Run(tc.query, func(t *testing.T) { + result := fetch("severity=high&" + tc.query) + if result.Total != tc.total || result.TotalPages != tc.pages || result.Page != tc.page || + result.PageSize != 2 || len(result.Vulnerabilities) != len(tc.ids) { + t.Fatalf("unexpected pagination: %+v", result) + } + for i, v := range result.Vulnerabilities { + if v.ID != tc.ids[i] { + t.Fatalf("record %d = %q, want %q", i, v.ID, tc.ids[i]) + } + if tc.hosts == nil { + if v.TargetHost != nil { + t.Fatalf("ordinary listing returned target_host for %q", v.ID) + } + } else if v.TargetHost == nil || *v.TargetHost != tc.hosts[i] { + t.Fatalf("target_host for %q = %v, want %q", v.ID, v.TargetHost, tc.hosts[i]) + } else if v.ID == "subdomain" && v.TargetDomain != "example.com" { + t.Fatalf("subdomain parent = %q", v.TargetDomain) + } + } + }) + } + + // Differing severities and timestamps ensure sorting selects entire groups + // before applying the page boundary, including when a filter hides rows. + for id, severity := range map[string]string{ + "domain-old": "critical", "domain-new": "medium", "ipv6": "info", + } { + if _, err := db.Exec("UPDATE vulnerabilities SET severity = ? WHERE id = ?", severity, id); err != nil { + t.Fatal(err) + } + } + for _, tc := range []struct { + query string + ids []string + }{ + {"group_by=host&sort=severity&page=1", []string{"domain-old", "domain-new", "filtered", "domain-old"}}, + {"group_by=host&sort=severity&page=2", []string{"unknown", "subdomain"}}, + {"group_by=task&sort=severity&page=1", []string{"domain-old", "subdomain", "unknown"}}, + {"group_by=task&sort=severity&page=2", []string{"domain-new", "filtered", "ipv6"}}, + {"group_by=host&sort=latest", []string{"filtered", "domain-new", "domain-old", "unknown"}}, + {"group_by=task&sort=latest", []string{"filtered", "ipv6", "domain-new", "unknown"}}, + {"group_by=none&sort=severity", []string{"domain-old", "unknown"}}, + {"group_by=host&sort=severity&severity=high", []string{"unknown", "subdomain"}}, + {"group_by=host&sort=latest&q=missing", []string{}}, + {"group_by=host&host_q=example.com", []string{"filtered", "domain-new", "domain-old", "subdomain"}}, + {"group_by=host&host_q=%25", []string{}}, + {"group_by=host&host_q=missing", []string{}}, + } { + t.Run(tc.query, func(t *testing.T) { + result := fetch(tc.query) + if len(result.Vulnerabilities) != len(tc.ids) { + t.Fatalf("got %d records, want %d", len(result.Vulnerabilities), len(tc.ids)) + } + for i, id := range tc.ids { + if result.Vulnerabilities[i].ID != id { + t.Fatalf("record %d = %q, want %q", i, result.Vulnerabilities[i].ID, id) + } + } + }) + } +} diff --git a/internal/handler/vulnerability_workspace.go b/internal/handler/vulnerability_workspace.go new file mode 100644 index 00000000..eb12046c --- /dev/null +++ b/internal/handler/vulnerability_workspace.go @@ -0,0 +1,79 @@ +package handler + +import ( + "cyberstrike-ai/internal/database" + "cyberstrike-ai/internal/security" + "github.com/gin-gonic/gin" + "net/http" +) + +func (h *VulnerabilityHandler) VulnerabilityContext(c *gin.Context) { + v, err := h.db.GetVulnerability(c.Param("id")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "漏洞不存在"}) + return + } + history, err := h.db.VulnerabilityHistory(v.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + members := []*database.Vulnerability{} + if v.CaseID != "" { + members, err = h.db.ListVulnerabilitiesForAccess(100, 0, database.VulnerabilityListFilter{CaseID: v.CaseID, SummaryOnly: true}, vulnerabilityAccessFromContext(c)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + assets := []database.VulnerabilityLinkedAsset{} + if session, ok := security.CurrentSession(c); ok && security.SessionHasPermission(c, "asset:read") { + assets, err = h.db.VulnerabilityLinkedAssets(v, database.RBACListAccess{UserID: session.UserID, Scope: session.ScopeFor("asset:read")}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + c.JSON(http.StatusOK, gin.H{"vulnerability": v, "history": history, "members": members, "assets": assets}) +} + +func (h *VulnerabilityHandler) MergeVulnerabilities(c *gin.Context) { + var req struct { + IDs []string `json:"ids" binding:"required"` + Title string `json:"title" binding:"required"` + Reason string `json:"reason" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil || len(req.Title) > 500 || len(req.Reason) > 4000 { + c.JSON(http.StatusBadRequest, gin.H{"error": "请提供选中记录、名称和归并理由"}) + return + } + access := vulnerabilityAccessFromContext(c) + id, err := h.db.MergeVulnerabilities(req.IDs, req.Title, req.Reason, access.UserID, access) + if err != nil { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + if h.audit != nil { + h.audit.RecordOK(c, "vulnerability", "merge", "归并漏洞上报", "vulnerability_case", id, map[string]interface{}{"ids": req.IDs}) + } + c.JSON(http.StatusOK, gin.H{"id": id}) +} + +func (h *VulnerabilityHandler) DissolveVulnerabilityCase(c *gin.Context) { + var req struct { + Reason string `json:"reason" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil || len(req.Reason) > 4000 { + c.JSON(http.StatusBadRequest, gin.H{"error": "请填写拆分理由"}) + return + } + access := vulnerabilityAccessFromContext(c) + if err := h.db.DissolveVulnerabilityCase(c.Param("caseID"), req.Reason, access.UserID, access); err != nil { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + if h.audit != nil { + h.audit.RecordOK(c, "vulnerability", "split", "拆分漏洞归并组", "vulnerability_case", c.Param("caseID"), nil) + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} diff --git a/internal/handler/vulnerability_workspace_test.go b/internal/handler/vulnerability_workspace_test.go new file mode 100644 index 00000000..b1285d22 --- /dev/null +++ b/internal/handler/vulnerability_workspace_test.go @@ -0,0 +1,78 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "cyberstrike-ai/internal/database" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +func TestVulnerabilityWorkspaceHTTPFlow(t *testing.T) { + db, err := database.NewDB(filepath.Join(t.TempDir(), "workspace.db"), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + h := NewVulnerabilityHandler(db, zap.NewNop()) + router := gin.New() + router.POST("/vulnerabilities/cases", h.MergeVulnerabilities) + router.POST("/vulnerabilities/cases/:caseID/split", h.DissolveVulnerabilityCase) + router.GET("/vulnerabilities/:id/context", h.VulnerabilityContext) + router.GET("/vulnerabilities", h.ListVulnerabilities) + a, _ := db.CreateVulnerability(&database.Vulnerability{Title: "one", Severity: "high", Target: "https://a.example.com", Evidence: "full proof"}) + b, _ := db.CreateVulnerability(&database.Vulnerability{Title: "two", Severity: "high", Target: "https://b.example.com", Evidence: "other proof"}) + request := func(method, path string, payload any) map[string]json.RawMessage { + t.Helper() + body, _ := json.Marshal(payload) + req := httptest.NewRequest(method, path, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d: %s", rec.Code, rec.Body) + } + var result map[string]json.RawMessage + if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + return result + } + result := request("POST", "/vulnerabilities/cases", map[string]any{"ids": []string{a.ID, b.ID}, "title": "shared issue", "reason": "same root cause"}) + var id string + json.Unmarshal(result["id"], &id) + if id == "" { + t.Fatal("missing case ID") + } + result = request("GET", "/vulnerabilities?group_by=case&limit=1&summary=true", nil) + var list []*database.Vulnerability + if err := json.Unmarshal(result["vulnerabilities"], &list); err != nil { + t.Fatal(err) + } + if len(list) != 2 || list[0].Evidence != "" { + t.Fatal("summary response includes evidence") + } + context := request("GET", "/vulnerabilities/"+a.ID+"/context", nil) + var detail database.Vulnerability + json.Unmarshal(context["vulnerability"], &detail) + if detail.Evidence != "full proof" || detail.CaseID != id { + t.Fatalf("detail: %+v", detail) + } + var members []database.Vulnerability + json.Unmarshal(context["members"], &members) + if len(members) != 2 { + t.Fatalf("members: %d", len(members)) + } + request("POST", "/vulnerabilities/cases/"+id+"/split", map[string]string{"reason": "distinct causes"}) + context = request("GET", "/vulnerabilities/"+a.ID+"/context", nil) + var history []database.VulnerabilityHistoryItem + json.Unmarshal(context["history"], &history) + if len(history) != 2 { + t.Fatalf("history: %+v", history) + } +} diff --git a/web/static/css/style.css b/web/static/css/style.css index 01efa335..bd632b4a 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -27448,6 +27448,412 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { margin-bottom: 0; } +#page-vulnerabilities > .page-header, +.vulnerability-header-start { + flex-wrap: wrap; + gap: 12px; +} + +.vulnerability-header-start { + display: flex; + align-items: center; + min-width: 0; +} + +.vulnerability-view-switch { + display: flex; + flex-wrap: wrap; + gap: 2px; + padding: 3px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); +} + +.vulnerability-view-switch button { + border: 0; + border-radius: 5px; + background: transparent; + color: var(--text-secondary); + padding: 5px 10px; + font: inherit; + font-size: 0.8125rem; + cursor: pointer; +} + +.vulnerability-view-switch button:hover { + color: var(--text-primary); +} + +.vulnerability-view-switch button[aria-pressed="true"] { + background: var(--bg-primary); + color: var(--text-primary); + font-weight: 600; + box-shadow: var(--shadow-sm); +} + +.vulnerability-view-switch button:focus-visible { + outline: 2px solid var(--accent-color); + outline-offset: -2px; +} + +.vulnerability-group { + min-width: 0; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); +} + +.vulnerability-group > summary { + padding: 12px 16px; + cursor: pointer; + overflow-wrap: anywhere; +} + +.vulnerability-group > summary:focus-visible { + outline: 2px solid var(--accent-color); + outline-offset: -2px; + border-radius: 8px; +} + +.vulnerability-group-name { + margin-inline-start: 6px; + font-weight: 600; +} + +.vulnerability-group-count { + margin-inline-start: 8px; + color: var(--text-muted); + font-size: 0.8125rem; +} + +.vulnerability-group-cards { + display: grid; + gap: 10px; + padding: 0 12px 12px; +} + +.vulnerability-list-toolbar, +.vulnerability-list-tools, +.vulnerability-group-controls, +.vulnerability-sort-label { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.vulnerability-list-toolbar { + justify-content: space-between; + margin-bottom: 12px; + color: var(--text-secondary); + font-size: 0.8125rem; +} + +.vulnerability-sort-label select { + width: auto; + min-width: 112px; + padding: 7px 10px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-primary); + color: var(--text-primary); + font: inherit; +} + +.vulnerability-group-controls[hidden] { + display: none; +} + +.vulnerability-group-risk { + display: inline-flex; + flex-wrap: wrap; + gap: 6px; + margin: 4px 8px; + vertical-align: middle; +} + +.vulnerability-group-pending { + font-size: 0.8125rem; + color: var(--text-secondary); + white-space: nowrap; +} + +.vulnerability-group-tags { + display: block; + margin-top: 6px; + color: var(--text-secondary); + font-size: 0.8125rem; + overflow-wrap: anywhere; +} + +.vulnerability-host-browser { + display: grid; + grid-template-columns: minmax(210px, 25%) minmax(0, 1fr); + align-items: start; + gap: 16px; +} + +.vulnerability-host-sidebar { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 12px; + position: sticky; + top: 0; +} + +#vulnerability-host-search { + width: 100%; + min-width: 0; + margin-bottom: 10px; + padding: 8px 10px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-primary); + color: var(--text-primary); + font: inherit; + font-size: 0.8125rem; +} + +.vulnerability-host-tree { + max-height: 65vh; + overflow-y: auto; +} + +.vulnerability-host-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + padding: 8px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text-primary); + font: inherit; + font-size: 0.8125rem; + text-align: start; + cursor: pointer; + overflow-wrap: anywhere; +} + +.vulnerability-host-option:hover, +.vulnerability-host-option[aria-pressed="true"] { + background: var(--bg-secondary); +} + +.vulnerability-host-option[aria-pressed="true"] { + color: var(--accent-color); + font-weight: 600; +} + +.vulnerability-host-number { + color: var(--text-muted); + flex-shrink: 0; + font-size: 0.75rem; + margin-inline-start: 6px; +} + +.vulnerability-host-option-name { + min-width: 0; +} + +.vulnerability-host-risk-dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + margin-inline-end: 7px; + vertical-align: middle; +} + +.vulnerability-host-risk-dot.severity-critical { background: #dc3545; } +.vulnerability-host-risk-dot.severity-high { background: #fd7e14; } +.vulnerability-host-risk-dot.severity-medium { background: #d69e00; } +.vulnerability-host-risk-dot.severity-low { background: #0d9488; } +.vulnerability-host-risk-dot.severity-info { background: #6c757d; } + +.vulnerability-host-overview, +#vulnerability-host-risk-summary { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.vulnerability-host-overview { + justify-content: space-between; + margin-bottom: 10px; +} + +.vulnerability-domain-branch > summary { + cursor: pointer; + padding: 8px; + font-size: 0.8125rem; + overflow-wrap: anywhere; +} + +.vulnerability-domain-branch > .vulnerability-host-option { + padding-inline-start: 24px; +} + +.vulnerability-host-results { + min-width: 0; +} + +.vulnerability-host-results-heading { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 10px; +} + +.vulnerability-host-results-heading h3 { + font-size: 0.9375rem; + margin: 0; + overflow-wrap: anywhere; +} + +#vulnerability-host-selection-count { + color: var(--text-muted); + font-size: 0.8125rem; +} + +.vulnerability-table-scroll { + overflow-x: auto; + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-primary); +} + +.vulnerability-table-scroll:has(.vuln-status-picker.open) { + padding-bottom: 200px; +} + +.vulnerability-host-table { + width: 100%; + min-width: 850px; + border-collapse: collapse; + table-layout: fixed; + font-size: 0.8125rem; +} + +.vulnerability-host-table th, +.vulnerability-host-table td { + padding: 12px 10px; + text-align: start; + border-bottom: 1px solid var(--border-color); + vertical-align: middle; +} + +.vulnerability-host-table th { + font-weight: 500; + color: var(--text-secondary); + background: var(--bg-secondary); +} + +.vulnerability-host-table th:nth-child(1) { width: 9%; } +.vulnerability-host-table th:nth-child(2) { width: 29%; } +.vulnerability-host-table th:nth-child(3) { width: 17%; } +.vulnerability-host-table th:nth-child(4) { width: 12%; } +.vulnerability-host-table th:nth-child(5) { width: 12%; } +.vulnerability-host-table th:nth-child(6) { width: 10%; } +.vulnerability-host-table th:nth-child(7) { width: 11%; } + +.vulnerability-row-toggle { + display: flex; + align-items: start; + gap: 8px; + width: 100%; + border: 0; + background: transparent; + color: var(--text-primary); + text-align: start; + font: inherit; + font-weight: 600; + cursor: pointer; + overflow-wrap: anywhere; +} + +.vulnerability-row-caret { + display: inline-block; + flex-shrink: 0; + transition: transform 0.15s; +} + +.vulnerability-table-title p, +.vulnerability-table-task, +.vulnerability-table-target { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-secondary); + margin: 4px 0 0; +} + +.vulnerability-table-target { + background: var(--bg-secondary); + border-radius: 4px; + padding: 3px 5px; +} + +.vulnerability-table-date { + color: var(--text-muted); +} + +.vulnerability-table-actions { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.vulnerability-table-actions .btn-ghost { + padding: 2px; + font-size: 0.75rem; +} + +.vulnerability-table-detail[hidden] { + display: none; +} + +.vulnerability-table-detail .vulnerability-content { + padding: 8px; + margin: 0; +} + +@media (max-width: 1100px) { + .vulnerability-host-browser { + grid-template-columns: minmax(0, 1fr); + } + .vulnerability-host-sidebar { + position: static; + } + .vulnerability-host-tree { + max-height: 220px; + } +} + +@media (max-width: 768px) { + #page-vulnerabilities .page-header-actions { + flex-direction: row; + flex-wrap: wrap; + } + .vulnerability-header { + flex-wrap: wrap; + gap: 8px; + } + .vulnerability-title-section { + min-width: 0; + flex-basis: 100%; + overflow-wrap: anywhere; + } +} + .vulnerability-card { background: var(--bg-primary); border: 1px solid var(--border-color); @@ -35469,9 +35875,10 @@ body.app-modal-open { .vulnerability-related-facts { margin-top: 12px; padding: 10px 12px; - background: #f8fafc; - border: 1px solid #e2e8f0; + background: var(--bg-primary); + border: 1px solid var(--border-color); border-radius: 8px; + color: var(--text-primary); font-size: 0.8125rem; } .vulnerability-related-facts ul { @@ -35482,10 +35889,17 @@ body.app-modal-open { margin: 4px 0; } .vulnerability-related-facts a { - color: #2563eb; + color: var(--accent-color); cursor: pointer; text-decoration: underline; } +.vulnerability-related-facts p { + margin-top: 6px; + color: var(--text-secondary); +} +.vulnerability-related-facts .vulnerability-related-facts-error { + color: var(--error-color); +} .fact-detail-body { max-height: 50vh; overflow: auto; diff --git a/web/static/css/vulnerability-workspace.css b/web/static/css/vulnerability-workspace.css new file mode 100644 index 00000000..4b82b75c --- /dev/null +++ b/web/static/css/vulnerability-workspace.css @@ -0,0 +1,242 @@ +#page-vulnerabilities { + --vuln-panel: var(--bg-primary); + --vuln-surface: var(--bg-secondary); +} + +#page-vulnerabilities > .page-header { + padding: 18px 24px; + background: var(--vuln-panel); + box-shadow: none; +} + +#page-vulnerabilities .page-content { padding: 20px 24px; } +#page-vulnerabilities .vulnerability-header-start { gap: 20px; } +#page-vulnerabilities .vulnerability-view-switch { border: 0; padding: 4px; } +#page-vulnerabilities .vulnerability-view-switch button { padding: 7px 13px; } + +.vulnerability-workspace-caption { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; + font-size: 13px; + color: var(--text-secondary); +} + + +#page-vulnerabilities .dashboard-stats { + grid-template-columns: repeat(6,minmax(0,1fr)); + gap: 12px; +} + +#page-vulnerabilities .stat-card { + min-height: 110px; + padding: 16px 18px; + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--vuln-panel) !important; + box-shadow: none; + text-align: start; +} + +#page-vulnerabilities .stat-card.is-active { + border-color: var(--accent-color); + box-shadow: 0 0 0 1px var(--accent-color); +} + +#page-vulnerabilities .stat-value { + font-size: 30px; + line-height: 1.3; + font-weight: 650; + font-variant-numeric: tabular-nums; + margin: 6px 0 2px; +} + +#page-vulnerabilities .stat-label { font-size: 12px; color: var(--text-secondary); } +#page-vulnerabilities .stat-pct { font-size: 11px; } +#page-vulnerabilities .stat-stacked-bar { margin-top: 10px; } +#page-vulnerabilities .vulnerability-filter-panel { border-radius: 12px; box-shadow: none; } +#page-vulnerabilities .vulnerability-list-toolbar { margin: 16px 0 12px; } +#page-vulnerabilities .vulnerability-host-browser { grid-template-columns: 248px minmax(0,1fr); gap: 20px; } +#page-vulnerabilities .vulnerability-host-sidebar { + display: flex; + flex-direction: column; + height: min(660px, 70vh); + min-height: 300px; + border-radius: 12px; + padding: 12px; +} +#page-vulnerabilities .vulnerability-host-sidebar > input { flex-shrink: 0; } +#page-vulnerabilities .vulnerability-host-tree { flex: 1; min-height: 0; max-height: none; } +#page-vulnerabilities .vulnerability-host-option { padding: 10px 8px; } +#page-vulnerabilities .vulnerability-host-option[aria-pressed="true"] { background: color-mix(in srgb,var(--accent-color) 9%,transparent); } +#page-vulnerabilities .vulnerability-domain-branch > .vulnerability-host-option { padding-inline-start: 22px; } +#page-vulnerabilities .vulnerability-host-option-name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.vulnerability-domain-label { + display: inline-block; + max-width: calc(100% - 54px); + padding: 0; + border: 0; + background: transparent; + color: var(--text-primary); + font: inherit; + text-align: start; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: middle; + cursor: pointer; +} +.vulnerability-domain-label[aria-pressed="true"] { color: var(--accent-color); font-weight: 600; } +.vulnerability-host-heading-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } +.vulnerability-host-heading-actions [hidden] { display: none; } +#page-vulnerabilities .vulnerability-host-results-heading { align-items: start; } +#vulnerability-host-selection { + min-width: 0; + flex: 1 1 180px; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; +} +.vulnerability-table-name { + flex: 1; + min-width: 0; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; +} +#page-vulnerabilities .vulnerability-host-overview { min-height: 36px; margin-bottom: 12px; } +#page-vulnerabilities .vulnerability-host-results-heading h3 { font-size: 16px; font-weight: 600; } +#page-vulnerabilities .vulnerability-host-table { min-width: 880px; font-size: 13px; } +#page-vulnerabilities .vulnerability-host-table th { width: auto; font-size: 12px; padding: 12px; } +#page-vulnerabilities .vulnerability-host-table td { padding: 16px 12px; } +#page-vulnerabilities .vulnerability-host-table .vulnerability-select-column { text-align: center; padding-inline: 8px; } +#page-vulnerabilities .vulnerability-select-column .vulnerability-select-record { margin: 0; vertical-align: middle; } +.vulnerability-col-select { width: 44px; } +.vulnerability-col-severity { width: 84px; } +.vulnerability-col-target { width: 19%; } +.vulnerability-col-status { width: 110px; } +.vulnerability-col-task { width: 13%; } +.vulnerability-col-date { width: 90px; } +.vulnerability-col-actions { width: 100px; } +#page-vulnerabilities .vulnerability-table-item:hover { background: color-mix(in srgb,var(--accent-color) 3%,var(--vuln-panel)); } +#page-vulnerabilities .vulnerability-row-toggle { font-size: 14px; line-height: 1.5; padding: 0; } +#page-vulnerabilities .vulnerability-table-title p { margin-top: 6px; font-size: 12px; } +#page-vulnerabilities .vulnerability-table-target { font-size: 12px; } +#page-vulnerabilities .vulnerability-table-actions { gap: 6px 10px; } +#page-vulnerabilities .vulnerability-group { border-radius: 12px; background: var(--vuln-panel); } +#page-vulnerabilities .vulnerability-group > summary { padding: 16px; } +#page-vulnerabilities .vulnerability-group-cards { padding: 0 16px 16px; } +#page-vulnerabilities .vulnerability-card { box-shadow: none; padding: 16px; } +#page-vulnerabilities .vulnerability-title { font-size: 15px; } +#page-vulnerabilities .pagination-fixed { margin-top: 16px; border-radius: 10px; } + +.vulnerability-select-record { + width: 15px; + height: 15px; + accent-color: var(--accent-color); + margin-inline-end: 8px; + vertical-align: middle; + cursor: pointer; +} + +.vulnerability-select-record:disabled { opacity: 0.35; cursor: default; } +.vulnerability-case-badge { + display: inline-block; + margin-top: 6px; + padding: 2px 7px; + border: 1px solid var(--border-color); + border-radius: 5px; + font-size: 11px; + color: var(--text-secondary); +} +.vulnerability-case-split { justify-self: end; } + +#vulnerability-inspector { z-index: 1040; background: rgba(15,23,42,.25); } +#vulnerability-inspector .vulnerability-inspector-panel { + position: absolute; + inset-block: 0; + inset-inline-end: 0; + width: min(820px,95vw); + max-width: none; + max-height: none; + height: 100%; + margin: 0; + padding: 0; + border-radius: 0; + background: var(--bg-primary); + box-shadow: -12px 0 48px rgba(15,23,42,.16); + display: flex; + flex-direction: column; +} + +.vulnerability-inspector-header { + display: flex; + align-items: start; + justify-content: space-between; + gap: 20px; + padding: 24px 28px 20px; + border-bottom: 1px solid var(--border-color); +} +.vulnerability-inspector-header .modal-close { position: static; flex-shrink: 0; } +.vulnerability-inspector-eyebrow { color: var(--text-muted); font-size: 12px; } +.vulnerability-inspector-header h2 { font-size: 21px; line-height: 1.5; margin: 6px 0 0; overflow-wrap: anywhere; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; } +#vulnerability-inspector-body { padding: 20px 28px 32px; overflow-y: auto; flex: 1; } +.vulnerability-inspector-meta, +.vulnerability-inspector-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 18px; } +.vulnerability-inspector-meta { font-size: 12px; color: var(--text-secondary); } +.vulnerability-inspector-tabs { display: flex; gap: 24px; border-bottom: 1px solid var(--border-color); margin: 22px 0; } +.vulnerability-inspector-tabs button { border: 0; border-bottom: 2px solid transparent; padding: 10px 0; font: inherit; font-size: 14px; background: transparent; color: var(--text-secondary); cursor: pointer; } +.vulnerability-inspector-tabs button[aria-pressed="true"] { color: var(--accent-color); border-bottom-color: var(--accent-color); font-weight: 600; } +[data-inspector-panel][hidden] { display: none; } +[data-inspector-panel] h3 { margin: 24px 0 12px; font-size: 14px; } +.vulnerability-inspector-description { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.8; font-size: 14px; } +.vulnerability-target-list { list-style: none; padding: 0; margin: 0; border: 1px solid var(--border-color); border-radius: 10px; overflow: hidden; } +.vulnerability-target-list li { display: flex; flex-direction: column; gap: 5px; padding: 13px 16px; background: var(--bg-secondary); border-bottom: 1px solid var(--border-color); overflow-wrap: anywhere; } +.vulnerability-target-list li:last-child { border-bottom: 0; } +.vulnerability-target-list strong { font-size: 13px; } +.vulnerability-target-list code { display: block; max-width: 100%; max-height: 96px; overflow: auto; font-size: 12px; color: var(--text-secondary); white-space: pre; } +.vulnerability-target-heading { display: flex; align-items: center; gap: 10px; min-width: 0; } +.vulnerability-target-heading strong { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.vulnerability-target-heading button { flex-shrink: 0; font-size: 12px; } +.vulnerability-timeline { list-style: none; padding: 0 0 0 16px; border-inline-start: 1px solid var(--border-color); margin: 0; } +.vulnerability-timeline-event { position: relative; padding: 0 0 24px 12px; } +.vulnerability-timeline-event::before { content: ''; position: absolute; left: -21px; top: 5px; width: 8px; height: 8px; background: var(--accent-color); border-radius: 50%; } +.vulnerability-timeline-event > div { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; } +.vulnerability-timeline-event time { font-size: 11px; color: var(--text-muted); } +.vulnerability-timeline-event p { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 13px; margin: 8px 0; } +.vulnerability-member-link { display: flex; justify-content: space-between; gap: 12px; width: 100%; padding: 12px; margin: 6px 0; background: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 8px; text-align: start; cursor: pointer; } +.vulnerability-member-link span:last-child { color: var(--text-secondary); flex-shrink: 0; } +#vulnerability-merge-modal { z-index: 1050; } +.vulnerability-merge-panel { max-width: 540px; background: var(--bg-primary); } +.vulnerability-merge-panel label { display: flex; flex-direction: column; gap: 8px; margin-top: 18px; font-size: 13px; } +.vulnerability-merge-panel label[hidden] { display: none; } +.vulnerability-merge-panel input,.vulnerability-merge-panel textarea { width: 100%; padding: 10px 12px; background: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 8px; font: inherit; } +#vulnerability-merge-description { color: var(--text-secondary); font-size: 13px; line-height: 1.7; } +#vulnerability-merge-error { color: var(--error-color); margin-top: 12px; font-size: 13px; } +#vulnerability-modal,#vulnerability-retest-modal { z-index: 1050; } + +@media (max-width: 1100px) { + #page-vulnerabilities .dashboard-stats { grid-template-columns: repeat(3,minmax(0,1fr)); } + #page-vulnerabilities .stat-card-total { grid-column: auto; } +} +@media (max-width: 900px) { + #page-vulnerabilities .vulnerability-host-browser { grid-template-columns: minmax(0,1fr); } + #page-vulnerabilities .vulnerability-host-sidebar { height: min(300px, 45vh); min-height: 220px; } +} +@media (max-width: 600px) { + #page-vulnerabilities > .page-header,#page-vulnerabilities .page-content { padding: 14px; } + #page-vulnerabilities .dashboard-stats { grid-template-columns: repeat(3,minmax(0,1fr)); gap: 8px; } + #page-vulnerabilities .stat-card { padding: 12px; min-height: 92px; } + #page-vulnerabilities .stat-value { font-size: 24px; } + #vulnerability-inspector .vulnerability-inspector-panel { width: 100%; border-radius: 0; } + .vulnerability-inspector-header { padding: 18px; } + #vulnerability-inspector-body { padding: 18px; } +} diff --git a/web/static/i18n/ar-SA.json b/web/static/i18n/ar-SA.json index bfbfb047..dd672dd7 100644 --- a/web/static/i18n/ar-SA.json +++ b/web/static/i18n/ar-SA.json @@ -2720,6 +2720,50 @@ "errorGeneric": "حدث خطأ ما. يُرجى المحاولة مرة أخرى." }, "vulnerabilityPage": { + "taskSearch": "البحث في المهام أو قوائم الانتظار أو الوسوم…", + "allTasks": "جميع المهام", + "clearTaskSelection": "مسح مرشح المهام", + "taskQueueGroup": "قائمة المهام {{id}}", + "selectColumn": "تحديد", + "statReports": "تقارير الثغرات", + "history_retest_started": "تم إنشاء فرع إعادة الاختبار", + "retestSnapshot": "لقطة الأدلة عند إعادة الاختبار", + "viewCases": "ثغرات مستقلة", + "distinctCount": "{{count}} ثغرة مستقلة", + "workspaceSubtitle": "الأصول والتقارير والمعالجة", + "mergeSelected": "دمج المحدد", + "mergeCount": "دمج المحدد ({{count}})", + "splitCase": "فصل المجموعة", + "mergeReason": "السبب", + "mergeHint": "ربط {{count}} تقريراً كثغرة واحدة. تحقق من المشروع ونقاط الوصول والسبب الجذري ثم وضح سبب الدمج.", + "splitHint": "يعيد الفصل عرض التقارير بشكل مستقل مع الاحتفاظ بالسجل.", + "inspectorTitle": "مساحة عمل الثغرة", + "inspector_overview": "نظرة عامة", + "inspector_evidence": "الأدلة", + "inspector_history": "السجل", + "affectedTargets": "الأهداف المتأثرة", + "linkedAssets": "أصول مطابقة", + "mergedRecord": "مدمج", + "targetCount": "{{count}} هدفاً", + "selectRecord": "تحديد {{title}}", + "casePagination": "عرض {{start}}–{{end}} من {{total}} ثغرة مستقلة", + "history_retest": "نتيجة إعادة الاختبار", + "history_merge": "دمج التقارير", + "history_split": "فصل المجموعة", + "history_detach": "إزالة الارتباط", + "openRetestChat": "فتح محادثة الاختبار", + "noHistory": "لا يوجد سجل منظم بعد", + "exportVisibleHosts": "تصدير النتائج المعروضة", + "tableDownload": "تنزيل", + "allHosts": "جميع المضيفين", + "clearHostSelection": "مسح مرشح المضيف", + "hostSearch": "البحث في جميع المضيفين…", + "tableSeverity": "الخطورة", + "tableTitle": "اسم الثغرة", + "tableStatus": "الحالة", + "tableTask": "المهمة", + "tableDate": "وقت الاكتشاف", + "tableActions": "الإجراءات", "alertTitle": "تنبيهات الثغرات عبر الروبوت", "alertDescription": "إرسال الثغرات المكتشفة حديثًا إلى الروبوتات المرتبطة بهذا الحساب، مع التصفية حسب الخطورة.", "alertHint": "إرسال الثغرات المكتشفة حديثًا التي تبلغ هذه الخطورة أو أعلى إلى حسابات الروبوت المرتبطة بك.", @@ -2768,6 +2812,25 @@ "loading": "جارٍ التحميل...", "loadListFailed": "فشل التحميل", "noRecords": "لا توجد سجلات ثغرات", + "unknownHost": "مضيف غير معروف", + "groupPageCount": "الثغرات: {{count}}", + "pageFindings": "{{count}} ثغرة في هذه الصفحة", + "groupPending": "قيد المعالجة: {{count}}", + "sortLabel": "الترتيب", + "sortDefault": "الترتيب الافتراضي", + "sortSeverity": "الأعلى خطورة أولاً", + "sortLatest": "الأحدث أولاً", + "expandPage": "توسيع الصفحة", + "collapsePage": "طي الصفحة", + "hostPagination": "عرض {{start}}–{{end}} من أصل {{total}} مجموعة حسب المضيف", + "taskPagination": "عرض {{start}}–{{end}} من أصل {{total}} مجموعة حسب المهمة", + "recordPagination": "عرض {{start}}–{{end}} من أصل {{total}} سجل ثغرة", + "viewLabel": "عرض الثغرات", + "viewHost": "حسب المضيف", + "viewTask": "حسب المهمة", + "viewRaw": "الثغرات الأصلية", + "viewTaskGroup": "المهمة {{id}}", + "viewNoTask": "لا توجد مهمة مرتبطة", "batchExport": "تصدير جماعي", "batchDelete": "حذف جماعي", "batchDeleteNoResults": "لا توجد ثغرات تطابق عوامل التصفية الحالية لحذفها", diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 4e3ba1a8..8f0d1980 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -2862,6 +2862,50 @@ "errorGeneric": "Something went wrong. Please try again." }, "vulnerabilityPage": { + "taskSearch": "Search tasks, queues or tags…", + "allTasks": "All tasks", + "clearTaskSelection": "Clear task filter", + "taskQueueGroup": "Task queue {{id}}", + "selectColumn": "Select", + "statReports": "Reported findings", + "history_retest_started": "Retest branch created", + "retestSnapshot": "Evidence snapshot at retest", + "viewCases": "Distinct findings", + "distinctCount": "{{count}} distinct findings", + "workspaceSubtitle": "Assets, reports and remediation", + "mergeSelected": "Merge selected", + "mergeCount": "Merge selected ({{count}})", + "splitCase": "Split finding group", + "mergeReason": "Reason", + "mergeHint": "Link {{count}} reports as one finding. Verify the project, affected endpoints and root cause, then explain the merge.", + "splitHint": "Splitting restores individual reports and retains the event history.", + "inspectorTitle": "Finding workspace", + "inspector_overview": "Overview", + "inspector_evidence": "Evidence", + "inspector_history": "History", + "affectedTargets": "Affected targets", + "linkedAssets": "Matching inventory assets", + "mergedRecord": "Merged", + "targetCount": "{{count}} targets", + "selectRecord": "Select {{title}}", + "casePagination": "Showing {{start}}–{{end}} of {{total}} distinct findings", + "history_retest": "Retest result", + "history_merge": "Reports merged", + "history_split": "Group split", + "history_detach": "Association removed", + "openRetestChat": "Open retest chat", + "noHistory": "No structured history yet", + "exportVisibleHosts": "Export displayed findings", + "tableDownload": "Download", + "allHosts": "All hosts", + "clearHostSelection": "Clear host filter", + "hostSearch": "Search all hosts…", + "tableSeverity": "Severity", + "tableTitle": "Finding", + "tableStatus": "Status", + "tableTask": "Task", + "tableDate": "Discovered", + "tableActions": "Actions", "alertTitle": "Robot vulnerability alerts", "alertDescription": "Push newly discovered vulnerabilities to robots bound to this account, filtered by severity.", "alertHint": "Send newly discovered vulnerabilities at or above this severity to your bound robot accounts.", @@ -2910,6 +2954,25 @@ "loading": "Loading...", "loadListFailed": "Failed to load", "noRecords": "No vulnerability records", + "unknownHost": "Unknown host", + "groupPageCount": "Findings: {{count}}", + "pageFindings": "{{count}} findings on this page", + "groupPending": "Pending: {{count}}", + "sortLabel": "Sort", + "sortDefault": "Default order", + "sortSeverity": "Severity first", + "sortLatest": "Newest findings", + "expandPage": "Expand page", + "collapsePage": "Collapse page", + "hostPagination": "Showing {{start}}–{{end}} of {{total}} host groups", + "taskPagination": "Showing {{start}}–{{end}} of {{total}} task groups", + "recordPagination": "Showing {{start}}–{{end}} of {{total}} findings", + "viewLabel": "Vulnerability view", + "viewHost": "By host", + "viewTask": "By task", + "viewRaw": "Raw findings", + "viewTaskGroup": "Task {{id}}", + "viewNoTask": "No linked task", "batchExport": "Batch export", "batchDelete": "Batch delete", "batchDeleteNoResults": "No vulnerabilities match the current filters to delete", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index d754136e..6edaa883 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -2875,6 +2875,50 @@ "errorGeneric": "Что-то пошло не так. Повторите попытку." }, "vulnerabilityPage": { + "taskSearch": "Поиск задач, очередей или тегов…", + "allTasks": "Все задачи", + "clearTaskSelection": "Сбросить фильтр задач", + "taskQueueGroup": "Очередь {{id}}", + "selectColumn": "Выбор", + "statReports": "Отчёты об уязвимостях", + "history_retest_started": "Создана ветка проверки", + "retestSnapshot": "Снимок доказательств при проверке", + "viewCases": "Уникальные", + "distinctCount": "Уникальных уязвимостей: {{count}}", + "workspaceSubtitle": "Активы, отчёты и устранение", + "mergeSelected": "Объединить", + "mergeCount": "Объединить ({{count}})", + "splitCase": "Разделить группу", + "mergeReason": "Причина", + "mergeHint": "Объединить {{count}} отчётов в одну уязвимость. Проверьте проект, точки входа и первопричину, затем укажите причину.", + "splitHint": "После разделения отчёты отображаются отдельно. История сохраняется.", + "inspectorTitle": "Работа с уязвимостью", + "inspector_overview": "Обзор", + "inspector_evidence": "Доказательства", + "inspector_history": "История", + "affectedTargets": "Затронутые цели", + "linkedAssets": "Совпадения в активах", + "mergedRecord": "Объединено", + "targetCount": "Целей: {{count}}", + "selectRecord": "Выбрать {{title}}", + "casePagination": "Показано {{start}}–{{end}} из {{total}} уникальных уязвимостей", + "history_retest": "Результат проверки", + "history_merge": "Отчёты объединены", + "history_split": "Группа разделена", + "history_detach": "Связь удалена", + "openRetestChat": "Открыть чат проверки", + "noHistory": "Структурированной истории пока нет", + "exportVisibleHosts": "Экспорт показанных записей", + "tableDownload": "Скачать", + "allHosts": "Все хосты", + "clearHostSelection": "Сбросить фильтр хостов", + "hostSearch": "Поиск по всем хостам…", + "tableSeverity": "Критичность", + "tableTitle": "Уязвимость", + "tableStatus": "Статус", + "tableTask": "Задача", + "tableDate": "Обнаружено", + "tableActions": "Действия", "alertTitle": "Оповещения робота об уязвимостях", "alertDescription": "Отправлять новые уязвимости роботам, привязанным к этой учётной записи, с фильтром по серьёзности.", "alertHint": "Отправлять новые уязвимости не ниже выбранной серьёзности на привязанные учётные записи роботов.", @@ -2923,6 +2967,25 @@ "loading": "Загрузка...", "loadListFailed": "Не удалось загрузить", "noRecords": "Нет записей об уязвимостях", + "unknownHost": "Неизвестный хост", + "groupPageCount": "Уязвимостей: {{count}}", + "pageFindings": "На странице {{count}} уязвимостей", + "groupPending": "В обработке: {{count}}", + "sortLabel": "Сортировка", + "sortDefault": "По умолчанию", + "sortSeverity": "По критичности", + "sortLatest": "Сначала новые", + "expandPage": "Развернуть страницу", + "collapsePage": "Свернуть страницу", + "hostPagination": "Показано {{start}}–{{end}} из {{total}} групп по хостам", + "taskPagination": "Показано {{start}}–{{end}} из {{total}} групп по задачам", + "recordPagination": "Показано {{start}}–{{end}} из {{total}} записей", + "viewLabel": "Вид списка уязвимостей", + "viewHost": "По хостам", + "viewTask": "По задачам", + "viewRaw": "Исходные уязвимости", + "viewTaskGroup": "Задача {{id}}", + "viewNoTask": "Без связанной задачи", "batchExport": "Пакетный экспорт", "batchDelete": "Пакетное удаление", "batchDeleteNoResults": "Нет уязвимостей по текущим фильтрам для удаления", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index e0a55649..88bcbc58 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -2850,6 +2850,50 @@ "errorGeneric": "操作失败,请稍后重试。" }, "vulnerabilityPage": { + "taskSearch": "搜索任务、队列或标签…", + "allTasks": "全部任务", + "clearTaskSelection": "清除任务筛选", + "taskQueueGroup": "任务队列 {{id}}", + "selectColumn": "选择", + "statReports": "上报记录", + "history_retest_started": "复测分支已创建", + "retestSnapshot": "复测时的证据快照", + "viewCases": "独立漏洞", + "distinctCount": "{{count}} 个独立漏洞", + "workspaceSubtitle": "资产、上报与处置进展", + "mergeSelected": "归并所选", + "mergeCount": "归并所选({{count}})", + "splitCase": "拆分归并组", + "mergeReason": "操作理由", + "mergeHint": "将所选 {{count}} 条上报关联为一个独立漏洞。请核对项目、受影响接口和根因,并填写归并理由。", + "splitHint": "拆分后,各条上报恢复独立展示,历史操作记录可以继续查阅。", + "inspectorTitle": "漏洞工作台", + "inspector_overview": "概览", + "inspector_evidence": "复现与证据", + "inspector_history": "处置历史", + "affectedTargets": "受影响目标", + "linkedAssets": "资产库匹配", + "mergedRecord": "已归并", + "targetCount": "{{count}} 个目标", + "selectRecord": "选择 {{title}}", + "casePagination": "显示 {{start}}–{{end}} / 共 {{total}} 个独立漏洞", + "history_retest": "复测结果", + "history_merge": "归并上报", + "history_split": "拆分归并组", + "history_detach": "解除关联", + "openRetestChat": "打开复测对话", + "noHistory": "暂无结构化处置记录", + "exportVisibleHosts": "导出右侧列表", + "tableDownload": "下载", + "allHosts": "全部域名", + "clearHostSelection": "清除域名筛选", + "hostSearch": "搜索全部域名…", + "tableSeverity": "严重度", + "tableTitle": "漏洞名称", + "tableStatus": "状态", + "tableTask": "所属任务", + "tableDate": "发现时间", + "tableActions": "操作", "alertTitle": "机器人漏洞提醒", "alertDescription": "按严重级别将新发现的漏洞推送至当前账号绑定的机器人。", "alertHint": "发现符合级别的新漏洞后,通过已绑定机器人账号推送。", @@ -2898,6 +2942,25 @@ "loading": "加载中...", "loadListFailed": "加载失败", "noRecords": "暂无漏洞记录", + "unknownHost": "未识别到主机", + "groupPageCount": "{{count}} 条漏洞", + "pageFindings": "本页 {{count}} 条漏洞", + "groupPending": "待处理 {{count}}", + "sortLabel": "排序", + "sortDefault": "默认顺序", + "sortSeverity": "高危优先", + "sortLatest": "最新发现", + "expandPage": "展开本页", + "collapsePage": "收起本页", + "hostPagination": "显示 {{start}}–{{end}} / 共 {{total}} 个域名组", + "taskPagination": "显示 {{start}}–{{end}} / 共 {{total}} 个任务组", + "recordPagination": "显示 {{start}}–{{end}} / 共 {{total}} 条漏洞", + "viewLabel": "漏洞视图", + "viewHost": "按域名", + "viewTask": "按任务", + "viewRaw": "原始漏洞", + "viewTaskGroup": "任务 {{id}}", + "viewNoTask": "未关联任务", "batchExport": "批量导出", "batchDelete": "批量删除", "batchDeleteNoResults": "当前筛选条件下没有可删除的漏洞", diff --git a/web/static/js/vulnerability-host-view.test.cjs b/web/static/js/vulnerability-host-view.test.cjs new file mode 100644 index 00000000..62d492cd --- /dev/null +++ b/web/static/js/vulnerability-host-view.test.cjs @@ -0,0 +1,41 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const test = require('node:test'); + +test('domain export includes exactly the visible records from the loaded page', () => { + const rows = [ + { id: 'a', target_host: 'api.example.com', target_domain: 'example.com', task_id:'task-a', task_queue_id:'queue' }, + { id: 'b', target_host: 'www.example.com', target_domain: 'example.com', task_id:'task-b', task_queue_id:'queue' }, + { id: 'c', target_host: 'example.net', target_domain: 'example.net' }, + { id: 'unknown', target_host: '' }, + ]; + const downloads = []; + const context = vm.createContext({ + vulnerabilityBrowserRows: rows, + vulnerabilityBrowserSelection: '', + vulnerabilityView: 'host', + formatVulnerabilityAsMarkdown: item => item.id, + triggerTextDownload: (name, content) => downloads.push(content), + }); + const source = fs.readFileSync('web/static/js/vulnerability.js', 'utf8'); + vm.runInContext(source.slice( + source.indexOf('function getVisibleVulnerabilityBrowserItems()'), + source.indexOf('function renderVulnerabilityHostRiskDot(') + ), context); + + for (const [selection, expected] of [ + ['', ['a', 'b', 'c', 'unknown']], + ['host:api.example.com', ['a']], + ['domain:example.com', ['a', 'b']], + ['host:', ['unknown']], + ['host:missing.example.com', []], + ['task:task-a',['a']], + ['queue:queue',['a','b']], + ]) { + context.vulnerabilityBrowserSelection = selection; + downloads.length = 0; + context.exportVisibleVulnerabilityBrowser(); + assert.deepEqual(downloads, expected.length ? [expected.join('\n\n---\n\n')] : []); + } +}); diff --git a/web/static/js/vulnerability-workspace.js b/web/static/js/vulnerability-workspace.js new file mode 100644 index 00000000..2b041075 --- /dev/null +++ b/web/static/js/vulnerability-workspace.js @@ -0,0 +1,217 @@ +// Vulnerability selection, review and evidence workspace. +let vulnerabilityWorkspaceRows = new Map(); +let vulnerabilityWorkspaceSelected = new Set(); +let vulnerabilityMergeCaseID = ''; +let vulnerabilityMergeIDs = []; +let vulnerabilityMergeBusy = false; +let vulnerabilityInspectorID = ''; +let vulnerabilityInspectorRequest = 0; + +function setVulnerabilityWorkspaceRows(rows) { + vulnerabilityWorkspaceRows = new Map(rows.map(row => [row.id, row])); + vulnerabilityWorkspaceSelected = new Set(Array.from(vulnerabilityWorkspaceSelected).filter(id => + vulnerabilityWorkspaceRows.has(id) && !vulnerabilityWorkspaceRows.get(id).case_id)); + updateVulnerabilityWorkspaceSelection(); +} + +function vulnerabilitySelectionInput(vuln) { + return ``; +} + +function selectVulnerabilityRecord(id, checked) { + if (checked) vulnerabilityWorkspaceSelected.add(id); + else vulnerabilityWorkspaceSelected.delete(id); + updateVulnerabilityWorkspaceSelection(); +} + +function updateVulnerabilityWorkspaceSelection() { + const button = document.getElementById('vulnerability-merge-selected'); + if (!button) return; + const count = vulnerabilityWorkspaceSelected.size; + button.disabled = count < 2; + button.textContent = count + ? vulnT('vulnerabilityPage.mergeCount', {count}) + : vulnT('vulnerabilityPage.mergeSelected'); + document.querySelectorAll('[data-select-vulnerability]').forEach(input => { + input.checked = vulnerabilityWorkspaceSelected.has(input.dataset.selectVulnerability); + }); +} + +function openVulnerabilityMergeDialog(caseID = '') { + if (vulnerabilityMergeBusy) return; + vulnerabilityMergeCaseID = caseID; + vulnerabilityMergeIDs = Array.from(vulnerabilityWorkspaceSelected); + if (!caseID && vulnerabilityMergeIDs.length < 2) return; + const rows = vulnerabilityMergeIDs.map(id => vulnerabilityWorkspaceRows.get(id)); + document.getElementById('vulnerability-merge-title').textContent = + vulnT(caseID ? 'vulnerabilityPage.splitCase' : 'vulnerabilityPage.mergeSelected'); + document.getElementById('vulnerability-merge-description').textContent = + vulnT(caseID ? 'vulnerabilityPage.splitHint' : 'vulnerabilityPage.mergeHint', {count:rows.length}); + document.getElementById('vulnerability-merge-name-label').hidden = Boolean(caseID); + const name = document.getElementById('vulnerability-merge-name'); + name.required = !caseID; + name.value = rows[0]?.title || ''; + document.getElementById('vulnerability-merge-reason').value = ''; + document.getElementById('vulnerability-merge-error').textContent = ''; + openAppModal('vulnerability-merge-modal'); +} + +async function saveVulnerabilityMerge() { + if (vulnerabilityMergeBusy) return; + const title = document.getElementById('vulnerability-merge-name').value.trim(); + const reason = document.getElementById('vulnerability-merge-reason').value.trim(); + if (!reason || (!vulnerabilityMergeCaseID && !title)) return; + const button = document.getElementById('vulnerability-merge-save'); + vulnerabilityMergeBusy = true; + button.disabled = true; + try { + const url = vulnerabilityMergeCaseID + ? '/api/vulnerabilities/cases/' + encodeURIComponent(vulnerabilityMergeCaseID) + '/split' + : '/api/vulnerabilities/cases'; + const response = await apiFetch(url, { + method:'POST', headers:{'Content-Type':'application/json'}, + body:JSON.stringify({ids:vulnerabilityMergeIDs,title,reason}) + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || vulnT('vulnerabilityPage.saveFailed')); + vulnerabilityWorkspaceSelected.clear(); + closeAppModal('vulnerability-merge-modal'); + closeAppModal('vulnerability-inspector'); + refreshVulnerabilities(); + } catch(error) { + document.getElementById('vulnerability-merge-error').textContent = error.message; + } finally { + vulnerabilityMergeBusy = false; + button.disabled = false; + } +} + +function switchVulnerabilityInspectorTab(tab) { + const root = document.getElementById('vulnerability-inspector-body'); + root.querySelectorAll('[data-inspector-tab]').forEach(button => { + button.setAttribute('aria-pressed', String(button.dataset.inspectorTab === tab)); + }); + root.querySelectorAll('[data-inspector-panel]').forEach(panel => { + panel.hidden = panel.dataset.inspectorPanel !== tab; + }); +} + +async function openVulnerabilityInspector(id) { + vulnerabilityInspectorID = id; + const request = ++vulnerabilityInspectorRequest; + const title = document.getElementById('vulnerability-inspector-title'); + const body = document.getElementById('vulnerability-inspector-body'); + title.textContent = vulnerabilityWorkspaceRows.get(id)?.title || vulnT('vulnerabilityPage.inspectorTitle'); + body.innerHTML = `
${escapeHtml(target.raw)}
+ ${escapeHtml(event.evidence || '')}
+ ${event.snapshot ? `${escapeHtml(asset.id)}${asset.port ? ' · ' + asset.port : ''}${escapeHtml(v.description || '')}
+ ${vulnDetailProjectField(v)} + ${vulnDetailField(vulnT('vulnerabilityPage.detailVulnId'),v.id,true)} + ${vulnDetailConversationField(v.conversation_id)} + ${v.task_id ? vulnDetailField(vulnT('vulnerabilityPage.detailTaskId'),v.task_id,true) : ''} + ${vulnNarrativeSection(vulnT('vulnerabilityPage.detailImpact'),v.impact)} + ${vulnNarrativeSection(vulnT('vulnerabilityPage.detailRecommendation'),v.recommendation)} +| ${escapeHtml(vulnT('vulnerabilityPage.selectColumn'))} | + ${['tableSeverity', 'tableTitle', 'detailTarget', 'tableStatus', 'tableTask', 'tableDate', 'tableActions'] + .map(key => '' + escapeHtml(vulnT('vulnerabilityPage.' + key)) + ' | ').join('')} +
|---|
${escapeHtml((vuln.description || '').slice(0, 160))}
+ ${vuln.case_id ? `${escapeHtml(vulnT('vulnerabilityPage.mergedRecord'))}` : ''} +