mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-11-14 00:07:10 +01:00
Merge pull request #400 from Sycun/codex/vulnerability-workspace
feat(vulnerabilities): add grouped browsing and a review workspace
This commit is contained in:
23 files changed
+2865
-298
No files matched your search
@@ -1354,9 +1354,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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 '{}',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+417
-3
@@ -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;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
.vulnerability-record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vulnerability-record-row {
|
||||
display: grid;
|
||||
grid-template-columns: 18px 52px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
column-gap: 12px;
|
||||
min-height: 72px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.vulnerability-record-row:hover {
|
||||
border-color: color-mix(in srgb, var(--accent-color) 35%, var(--border-color));
|
||||
}
|
||||
|
||||
.vulnerability-record-row:has(.vulnerability-select-record:checked) {
|
||||
background: color-mix(in srgb, var(--accent-color) 5%, var(--bg-primary));
|
||||
}
|
||||
|
||||
.vulnerability-record-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.vulnerability-record-select .vulnerability-select-record {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.vulnerability-record-row > .severity-badge {
|
||||
justify-self: start;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vulnerability-record-main {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vulnerability-record-title-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vulnerability-record-title,
|
||||
.vulnerability-record-summary {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.vulnerability-record-title {
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vulnerability-record-summary {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.vulnerability-record-title-line .vulnerability-case-badge {
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vulnerability-record-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.vulnerability-record-view {
|
||||
padding: 6px 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vulnerability-record-view:hover,
|
||||
.vulnerability-record-main:hover .vulnerability-record-title {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.vulnerability-record-main:focus-visible,
|
||||
.vulnerability-record-view:focus-visible {
|
||||
outline: 2px solid var(--accent-color);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.vulnerability-inspector-actions .btn-delete {
|
||||
margin-inline-start: auto;
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.vulnerability-record-row {
|
||||
grid-template-columns: 18px 46px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
.vulnerability-record-main {
|
||||
padding: 0 !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
.vulnerability-record-actions {
|
||||
grid-column: 2 / -1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
#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; justify-content: flex-end; flex-wrap: wrap; gap: 8px; max-width: 100%; margin-inline-start: auto; }
|
||||
.vulnerability-host-heading-actions [hidden] { display: none; }
|
||||
#page-vulnerabilities .vulnerability-host-results-heading { align-items: start; }
|
||||
#page-vulnerabilities .vulnerability-host-results-heading[hidden] { display: none; }
|
||||
#vulnerability-host-selection {
|
||||
min-width: 0;
|
||||
flex: 1 1 180px;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
#vulnerability-host-selection[hidden] { display: none; }
|
||||
.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; }
|
||||
}
|
||||
@@ -2720,6 +2720,51 @@
|
||||
"errorGeneric": "حدث خطأ ما. يُرجى المحاولة مرة أخرى."
|
||||
},
|
||||
"vulnerabilityPage": {
|
||||
"viewFinding": "عرض التفاصيل",
|
||||
"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 +2813,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": "لا توجد ثغرات تطابق عوامل التصفية الحالية لحذفها",
|
||||
|
||||
@@ -2862,6 +2862,51 @@
|
||||
"errorGeneric": "Something went wrong. Please try again."
|
||||
},
|
||||
"vulnerabilityPage": {
|
||||
"viewFinding": "View details",
|
||||
"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 +2955,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",
|
||||
|
||||
@@ -2875,6 +2875,51 @@
|
||||
"errorGeneric": "Что-то пошло не так. Повторите попытку."
|
||||
},
|
||||
"vulnerabilityPage": {
|
||||
"viewFinding": "Подробнее",
|
||||
"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 +2968,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": "Нет уязвимостей по текущим фильтрам для удаления",
|
||||
|
||||
@@ -2850,6 +2850,51 @@
|
||||
"errorGeneric": "操作失败,请稍后重试。"
|
||||
},
|
||||
"vulnerabilityPage": {
|
||||
"viewFinding": "查看详情",
|
||||
"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 +2943,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": "当前筛选条件下没有可删除的漏洞",
|
||||
|
||||
@@ -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')] : []);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
// 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 `<input class="vulnerability-select-record" type="checkbox"
|
||||
data-select-vulnerability="${escapeAttr(vuln.id)}"
|
||||
aria-label="${escapeAttr(vulnT('vulnerabilityPage.selectRecord', {title:vuln.title}))}"
|
||||
${vuln.case_id ? 'disabled' : ''} ${vulnerabilityWorkspaceSelected.has(vuln.id) ? 'checked' : ''}
|
||||
data-require-permission="vulnerability:write"
|
||||
onclick="event.stopPropagation()" onchange="selectVulnerabilityRecord(this.dataset.selectVulnerability,this.checked)">`;
|
||||
}
|
||||
|
||||
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 = `<div class="loading-spinner">${escapeHtml(vulnT('vulnerabilityPage.loading'))}</div>`;
|
||||
openAppModal('vulnerability-inspector');
|
||||
document.querySelector('#vulnerability-inspector .modal-close').focus();
|
||||
try {
|
||||
const response = await apiFetch('/api/vulnerabilities/' + encodeURIComponent(id) + '/context');
|
||||
if (!response.ok) throw new Error(vulnT('vulnerabilityPage.fetchFailed'));
|
||||
const context = await response.json();
|
||||
if (request !== vulnerabilityInspectorRequest) return;
|
||||
const v = context.vulnerability;
|
||||
title.textContent = v.title;
|
||||
title.title = v.title;
|
||||
const jsID = escapeJsStringAttr(v.id);
|
||||
const targets = (v.targets || []).map(target => `
|
||||
<li><div class="vulnerability-target-heading"><strong title="${escapeAttr(target.host)}">${escapeHtml(target.host)}</strong>
|
||||
<button type="button" class="btn-ghost" data-target="${escapeAttr(target.raw)}" onclick="vulnerabilityCopyEncoded(event,encodeURIComponent(this.dataset.target))">${escapeHtml(vulnT('common.copy'))}</button></div>
|
||||
<code tabindex="0" title="${escapeAttr(target.raw)}">${escapeHtml(target.raw)}</code>
|
||||
</li>`).join('');
|
||||
const history = (context.history || []).map(event => `
|
||||
<li class="vulnerability-timeline-event">
|
||||
<div><strong>${escapeHtml(vulnT('vulnerabilityPage.history_' + event.kind))}</strong>
|
||||
<time>${escapeHtml(new Date(event.created_at).toLocaleString(vulnDateLocale()))}</time></div>
|
||||
${event.status ? `<span class="severity-badge">${escapeHtml(vulnStatusLabel(event.status))}</span>` : ''}
|
||||
<p>${escapeHtml(event.evidence || '')}</p>
|
||||
${event.snapshot ? `<details class="vulnerability-evidence-snapshot"><summary>${escapeHtml(vulnT('vulnerabilityPage.retestSnapshot'))}</summary>
|
||||
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailReproductionSteps'),event.snapshot.reproduction_steps)}
|
||||
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailEvidence'),event.snapshot.evidence,{code:true})}</details>` : ''}
|
||||
${event.conversation_id ? `<button type="button" class="btn-ghost" onclick="openVulnerabilityConversation(${escapeJsStringAttr(event.conversation_id)})">${escapeHtml(vulnT('vulnerabilityPage.openRetestChat'))}</button>` : ''}
|
||||
</li>`).join('');
|
||||
const members = (context.members || []).map(member => `
|
||||
<button type="button" class="vulnerability-member-link" onclick="openVulnerabilityInspector(${escapeJsStringAttr(member.id)})">
|
||||
<span>${escapeHtml(member.title)}</span><span>${escapeHtml(vulnStatusLabel(member.status))}</span>
|
||||
</button>`).join('');
|
||||
const assets = (context.assets || []).map(asset =>
|
||||
`<li><strong>${escapeHtml(asset.host)}</strong><code>${escapeHtml(asset.id)}${asset.port ? ' · ' + asset.port : ''}</code></li>`).join('');
|
||||
body.innerHTML = `
|
||||
<div class="vulnerability-inspector-meta">
|
||||
<span class="severity-badge severity-${escapeAttr(v.severity)}">${escapeHtml(vulnSeverityLabel(v.severity))}</span>
|
||||
${buildVulnerabilityStatusPicker(v)}
|
||||
<span>${escapeHtml(v.type || '')}</span>
|
||||
<span>${escapeHtml(new Date(v.created_at).toLocaleString(vulnDateLocale()))}</span>
|
||||
</div>
|
||||
<div class="vulnerability-inspector-actions">
|
||||
<button class="btn-primary" data-require-permission="vulnerability:write" onclick="openVulnerabilityRetest(${jsID})" ${v.conversation_id ? '' : 'disabled'}>${escapeHtml(vulnT('vulnerabilityRetest.button'))}</button>
|
||||
<button class="btn-secondary" data-require-permission="vulnerability:write" onclick="editVulnerability(${jsID})">${escapeHtml(vulnT('common.edit'))}</button>
|
||||
<button class="btn-secondary" onclick="openVulnerabilityDownload(${jsID},event)">${escapeHtml(vulnT('vulnerabilityPage.tableDownload'))}</button>
|
||||
<button class="btn-secondary btn-delete" data-require-permission="vulnerability:delete" onclick="deleteVulnerability(${jsID})">${escapeHtml(vulnT('common.delete'))}</button>
|
||||
</div>
|
||||
<div class="vulnerability-inspector-tabs" role="group">
|
||||
${['overview','evidence','history'].map(tab => `<button type="button" data-inspector-tab="${tab}" aria-pressed="${tab==='overview'}" onclick="switchVulnerabilityInspectorTab('${tab}')">${escapeHtml(vulnT('vulnerabilityPage.inspector_' + tab))}</button>`).join('')}
|
||||
</div>
|
||||
<section data-inspector-panel="overview">
|
||||
<p class="vulnerability-inspector-description">${escapeHtml(v.description || '')}</p>
|
||||
${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)}
|
||||
<h3>${escapeHtml(vulnT('vulnerabilityPage.affectedTargets'))}</h3>
|
||||
<ul class="vulnerability-target-list">${targets || '<li>'+escapeHtml(v.target || vulnT('vulnerabilityPage.unknownHost'))+'</li>'}</ul>
|
||||
${assets ? `<h3>${escapeHtml(vulnT('vulnerabilityPage.linkedAssets'))}</h3><ul class="vulnerability-target-list">${assets}</ul>` : ''}
|
||||
${members ? `<section class="vulnerability-case-members"><h3>${escapeHtml(v.case_title)}</h3>${members}
|
||||
<button class="btn-secondary" data-require-permission="vulnerability:write" onclick="openVulnerabilityMergeDialog(${escapeJsStringAttr(v.case_id)})">${escapeHtml(vulnT('vulnerabilityPage.splitCase'))}</button></section>` : ''}
|
||||
<div class="vulnerability-related-facts" id="vuln-related-facts-${escapeAttr(v.id)}" data-project-id="${escapeAttr(v.project_id || '')}" data-vuln-id="${escapeAttr(v.id)}" hidden></div>
|
||||
</section>
|
||||
<section data-inspector-panel="evidence" hidden>
|
||||
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailPreconditions'),v.preconditions)}
|
||||
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailReproductionSteps'),v.reproduction_steps)}
|
||||
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailEvidence'),v.evidence,{code:true})}
|
||||
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailRetestNotes'),v.retest_notes)}
|
||||
</section>
|
||||
<section data-inspector-panel="history" hidden>
|
||||
<ol class="vulnerability-timeline">${history || '<li>'+escapeHtml(vulnT('vulnerabilityPage.noHistory'))+'</li>'}</ol>
|
||||
${v.retest_log ? vulnNarrativeSection(vulnT('vulnerabilityPage.detailRetestLog'),v.retest_log,{code:true}) : ''}
|
||||
</section>`;
|
||||
initVulnerabilityStatusPickers(body);
|
||||
initVulnerabilityProjectBindSelects(body);
|
||||
if (typeof rbacAfterDynamicRender === 'function') rbacAfterDynamicRender(body);
|
||||
loadVulnerabilityRelatedFacts(v.id).catch(console.warn);
|
||||
} catch(error) {
|
||||
if (request === vulnerabilityInspectorRequest) body.innerHTML = `<div class="error-message">${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshVulnerabilityInspector(id) {
|
||||
if (vulnerabilityInspectorID === id && isAppModalOpen('vulnerability-inspector')) openVulnerabilityInspector(id);
|
||||
}
|
||||
|
||||
['vulnerability-inspector','vulnerability-merge-modal'].forEach(id => {
|
||||
const dialog = document.getElementById(id);
|
||||
if (!dialog) return;
|
||||
if (id === 'vulnerability-inspector') {
|
||||
dialog.addEventListener('click', event => {
|
||||
if (event.target === dialog) closeAppModal(id);
|
||||
});
|
||||
}
|
||||
dialog.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape' && !vulnerabilityMergeBusy) {
|
||||
closeAppModal(id);
|
||||
event.stopPropagation();
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const controls = Array.from(dialog.querySelectorAll('button,input,textarea,select,[tabindex="0"]'))
|
||||
.filter(el => !el.disabled && el.getClientRects().length);
|
||||
const first = controls[0], last = controls[controls.length-1];
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
|
||||
});
|
||||
});
|
||||
+328
-219
@@ -47,29 +47,6 @@ function getVulnerabilityScrollContainer() {
|
||||
return page ? page.querySelector('.page-content') : null;
|
||||
}
|
||||
|
||||
function getExpandedVulnerabilityIds() {
|
||||
const ids = [];
|
||||
document.querySelectorAll('#vulnerabilities-list .vulnerability-content').forEach(function (el) {
|
||||
if (el.style.display !== 'none') {
|
||||
const id = (el.id || '').replace(/^content-/, '');
|
||||
if (id) ids.push(id);
|
||||
}
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
function restoreExpandedVulnerabilityDetails(expandedIds) {
|
||||
if (!expandedIds || !expandedIds.length) return;
|
||||
expandedIds.forEach(function (id) {
|
||||
const content = document.getElementById('content-' + id);
|
||||
const icon = document.getElementById('expand-icon-' + id);
|
||||
if (!content || content.style.display !== 'none') return;
|
||||
content.style.display = 'block';
|
||||
if (icon) icon.style.transform = 'rotate(90deg)';
|
||||
loadVulnerabilityRelatedFacts(id).catch(function (e) { console.warn(e); });
|
||||
});
|
||||
}
|
||||
|
||||
function buildVulnerabilityStatusPicker(vuln) {
|
||||
const current = vuln.status || 'open';
|
||||
const id = escapeHtml(vuln.id);
|
||||
@@ -187,40 +164,13 @@ function vulnerabilityStatusMatchesFilter(status) {
|
||||
return !filterStatus || filterStatus === status;
|
||||
}
|
||||
|
||||
function removeVulnerabilityCard(vulnId, options) {
|
||||
const opts = options || {};
|
||||
const card = document.getElementById('vulnerability-card-' + vulnId) ||
|
||||
document.querySelector('.vulnerability-card[data-vuln-id="' + vulnId + '"]');
|
||||
if (!card) return;
|
||||
|
||||
const nextCard = card.nextElementSibling;
|
||||
card.classList.add('vulnerability-card--removing');
|
||||
|
||||
setTimeout(function () {
|
||||
card.remove();
|
||||
if (opts.decrementTotal !== false) {
|
||||
vulnerabilityPagination.total = Math.max(0, (vulnerabilityPagination.total || 0) - 1);
|
||||
vulnerabilityPagination.totalPages = Math.max(
|
||||
1,
|
||||
Math.ceil(vulnerabilityPagination.total / vulnerabilityPagination.pageSize)
|
||||
);
|
||||
renderVulnerabilityPagination();
|
||||
}
|
||||
|
||||
const list = document.getElementById('vulnerabilities-list');
|
||||
const remaining = list ? list.querySelectorAll('.vulnerability-card').length : 0;
|
||||
if (remaining === 0) {
|
||||
if (vulnerabilityPagination.currentPage > 1) {
|
||||
vulnerabilityPagination.currentPage--;
|
||||
}
|
||||
loadVulnerabilities();
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.focusNext !== false && nextCard && nextCard.classList.contains('vulnerability-card')) {
|
||||
nextCard.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
}, VULNERABILITY_REMOVE_ANIM_MS);
|
||||
async function removeVulnerabilityCard(vulnId) {
|
||||
const card = document.getElementById('vulnerability-card-' + vulnId);
|
||||
if (card) {
|
||||
card.classList.add('vulnerability-card--removing');
|
||||
await new Promise(resolve => setTimeout(resolve, VULNERABILITY_REMOVE_ANIM_MS));
|
||||
}
|
||||
await loadVulnerabilities(null, { preserveScroll: true, silent: true });
|
||||
}
|
||||
|
||||
async function changeVulnerabilityStatus(vulnId, newStatus, pickerEl) {
|
||||
@@ -241,10 +191,13 @@ async function changeVulnerabilityStatus(vulnId, newStatus, pickerEl) {
|
||||
}
|
||||
|
||||
updateVulnerabilityStatusPicker(pickerEl, newStatus);
|
||||
if (typeof refreshVulnerabilityInspector === 'function') refreshVulnerabilityInspector(vulnId);
|
||||
loadVulnerabilityStats();
|
||||
|
||||
if (!vulnerabilityStatusMatchesFilter(newStatus)) {
|
||||
removeVulnerabilityCard(vulnId, { decrementTotal: true, focusNext: true });
|
||||
await removeVulnerabilityCard(vulnId);
|
||||
} else {
|
||||
await loadVulnerabilities(null, { preserveScroll: true, silent: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新漏洞状态失败:', error);
|
||||
@@ -257,8 +210,8 @@ async function changeVulnerabilityStatus(vulnId, newStatus, pickerEl) {
|
||||
|
||||
// 从localStorage读取每页显示数量,默认为20
|
||||
const getVulnerabilityPageSize = () => {
|
||||
const saved = localStorage.getItem('vulnerabilityPageSize');
|
||||
return saved ? parseInt(saved, 10) : 20;
|
||||
const saved = Number(localStorage.getItem('vulnerabilityPageSize'));
|
||||
return [10, 20, 50, 100].includes(saved) ? saved : 20;
|
||||
};
|
||||
|
||||
let currentVulnerabilityId = null;
|
||||
@@ -279,6 +232,19 @@ let vulnerabilityPagination = {
|
||||
total: 0,
|
||||
totalPages: 1
|
||||
};
|
||||
const VULNERABILITY_VIEWS = ['host', 'task', 'none'];
|
||||
const savedVulnerabilityView = localStorage.getItem('vulnerabilityListView');
|
||||
let vulnerabilityView = savedVulnerabilityView === 'case' ? 'none'
|
||||
: VULNERABILITY_VIEWS.includes(savedVulnerabilityView) ? savedVulnerabilityView : 'host';
|
||||
let vulnerabilityListRequest = 0;
|
||||
let vulnerabilityStatsRequest = 0;
|
||||
const savedVulnerabilitySort = localStorage.getItem('vulnerabilityListSort');
|
||||
let vulnerabilitySort = ['severity', 'latest'].includes(savedVulnerabilitySort) ? savedVulnerabilitySort : '';
|
||||
let vulnerabilityHostQuery = '';
|
||||
let vulnerabilityTaskQuery = '';
|
||||
let vulnerabilityBrowserSelection = '';
|
||||
let vulnerabilityBrowserRows = [];
|
||||
let vulnerabilityHostSearchTimer = null;
|
||||
|
||||
const VULN_STAT_SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
|
||||
let vulnerabilityStatCardsBound = false;
|
||||
@@ -400,6 +366,8 @@ async function initVulnerabilityPage() {
|
||||
vulnerabilityPagination.pageSize = getVulnerabilityPageSize();
|
||||
initVulnerabilityStatCards();
|
||||
initVulnerabilityFilterPanel();
|
||||
initVulnerabilityViewSwitch();
|
||||
document.getElementById('vulnerability-sort').value = vulnerabilitySort;
|
||||
syncVulnerabilityFiltersFromLocationHash();
|
||||
await refreshVulnerabilityProjectFilter();
|
||||
updateVulnerabilityFilterPanelState();
|
||||
@@ -409,6 +377,29 @@ async function initVulnerabilityPage() {
|
||||
loadVulnerabilities();
|
||||
}
|
||||
|
||||
function initVulnerabilityViewSwitch() {
|
||||
const control = document.getElementById('vulnerability-view-switch');
|
||||
if (!control || control.dataset.bound) return;
|
||||
control.dataset.bound = 'true';
|
||||
const buttons = control.querySelectorAll('button[data-vuln-view]');
|
||||
const updateSelection = () => buttons.forEach(button => {
|
||||
button.setAttribute('aria-pressed', String(button.dataset.vulnView === vulnerabilityView));
|
||||
});
|
||||
updateSelection();
|
||||
control.addEventListener('click', event => {
|
||||
const view = event.target.closest('button[data-vuln-view]')?.dataset.vulnView;
|
||||
if (!VULNERABILITY_VIEWS.includes(view) || view === vulnerabilityView) return;
|
||||
vulnerabilityView = view;
|
||||
clearTimeout(vulnerabilityHostSearchTimer);
|
||||
vulnerabilityBrowserSelection = '';
|
||||
localStorage.setItem('vulnerabilityListView', view);
|
||||
updateSelection();
|
||||
const scroll = getVulnerabilityScrollContainer();
|
||||
if (scroll) scroll.scrollTop = 0;
|
||||
loadVulnerabilities(1);
|
||||
});
|
||||
}
|
||||
|
||||
function initVulnerabilityStatCards() {
|
||||
if (vulnerabilityStatCardsBound) {
|
||||
syncVulnerabilityStatCardActiveState();
|
||||
@@ -1212,6 +1203,7 @@ function updateVulnerabilityStatStackedBar(bySeverity, total) {
|
||||
|
||||
// 加载漏洞统计
|
||||
async function loadVulnerabilityStats() {
|
||||
const request = ++vulnerabilityStatsRequest;
|
||||
try {
|
||||
// 检查apiFetch是否可用
|
||||
if (typeof apiFetch === 'undefined') {
|
||||
@@ -1229,8 +1221,10 @@ async function loadVulnerabilityStats() {
|
||||
}
|
||||
|
||||
const stats = await response.json();
|
||||
if (request !== vulnerabilityStatsRequest) return;
|
||||
updateVulnerabilityStats(stats);
|
||||
} catch (error) {
|
||||
if (request !== vulnerabilityStatsRequest) return;
|
||||
console.error('加载漏洞统计失败:', error);
|
||||
// 统计失败不影响列表显示,只重置统计为0
|
||||
updateVulnerabilityStats(null);
|
||||
@@ -1278,20 +1272,22 @@ function updateVulnerabilityStats(stats) {
|
||||
|
||||
// 加载漏洞列表
|
||||
async function loadVulnerabilities(page = null, options = {}) {
|
||||
const request = ++vulnerabilityListRequest;
|
||||
const view = vulnerabilityView;
|
||||
const opts = options && typeof options === 'object' ? options : {};
|
||||
const preserveScroll = !!opts.preserveScroll;
|
||||
const silent = !!opts.silent;
|
||||
let expandedIds = opts.expandedIds;
|
||||
|
||||
const scrollEl = preserveScroll ? getVulnerabilityScrollContainer() : null;
|
||||
const scrollTop = scrollEl ? scrollEl.scrollTop : 0;
|
||||
if (expandedIds === undefined && preserveScroll) {
|
||||
expandedIds = getExpandedVulnerabilityIds();
|
||||
}
|
||||
|
||||
const listContainer = document.getElementById('vulnerabilities-list');
|
||||
const hostOpenStates = preserveScroll
|
||||
? new Map(Array.from(listContainer.querySelectorAll('.vulnerability-domain-branch'), branch => [branch.dataset.domain, branch.open]))
|
||||
: new Map();
|
||||
if (!silent) {
|
||||
listContainer.innerHTML = `<div class="loading-spinner">${escapeHtml(vulnT('vulnerabilityPage.loading'))}</div>`;
|
||||
updateVulnerabilityListSummary();
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1307,6 +1303,11 @@ async function loadVulnerabilities(page = null, options = {}) {
|
||||
}
|
||||
|
||||
const params = buildVulnerabilityFilterParams();
|
||||
params.append('group_by', view);
|
||||
params.append('summary', 'true');
|
||||
if (vulnerabilitySort) params.append('sort', vulnerabilitySort);
|
||||
if (view === 'host' && vulnerabilityHostQuery) params.append('host_q', vulnerabilityHostQuery);
|
||||
if (view === 'task' && vulnerabilityTaskQuery) params.append('task_q', vulnerabilityTaskQuery);
|
||||
params.append('page', vulnerabilityPagination.currentPage.toString());
|
||||
params.append('limit', vulnerabilityPagination.pageSize.toString());
|
||||
|
||||
@@ -1318,6 +1319,7 @@ async function loadVulnerabilities(page = null, options = {}) {
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (request !== vulnerabilityListRequest) return;
|
||||
|
||||
// 判断响应格式:新格式(有total字段)还是旧格式(直接是数组)
|
||||
let vulnerabilities;
|
||||
@@ -1341,17 +1343,26 @@ async function loadVulnerabilities(page = null, options = {}) {
|
||||
console.error('未知的响应格式:', data);
|
||||
}
|
||||
|
||||
renderVulnerabilities(vulnerabilities, { expandedIds: expandedIds || [] });
|
||||
renderVulnerabilities(vulnerabilities, {
|
||||
view, hostOpenStates,
|
||||
preserveHostSelection: preserveScroll
|
||||
});
|
||||
renderVulnerabilityPagination();
|
||||
if (page !== null && !preserveScroll) {
|
||||
const container = getVulnerabilityScrollContainer();
|
||||
if (container) container.scrollTop = 0;
|
||||
}
|
||||
|
||||
if (preserveScroll && scrollEl) {
|
||||
requestAnimationFrame(function () {
|
||||
scrollEl.scrollTop = scrollTop;
|
||||
if (request === vulnerabilityListRequest) scrollEl.scrollTop = scrollTop;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (request !== vulnerabilityListRequest) return;
|
||||
console.error('加载漏洞列表失败:', error);
|
||||
listContainer.innerHTML = `<div class="error-message">${escapeHtml(vulnT('vulnerabilityPage.loadListFailed'))}: ${escapeHtml(error.message)}</div>`;
|
||||
updateVulnerabilityListSummary();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1359,122 +1370,252 @@ async function loadVulnerabilities(page = null, options = {}) {
|
||||
function renderVulnerabilities(vulnerabilities, renderOptions) {
|
||||
const opts = renderOptions && typeof renderOptions === 'object' ? renderOptions : {};
|
||||
const listContainer = document.getElementById('vulnerabilities-list');
|
||||
if (typeof setVulnerabilityWorkspaceRows === 'function') setVulnerabilityWorkspaceRows(vulnerabilities || []);
|
||||
if (opts.view === 'host' || opts.view === 'task') {
|
||||
renderVulnerabilityBrowser(Array.isArray(vulnerabilities) ? vulnerabilities : [], opts);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理空值情况(使用 data-i18n 以便语言切换时自动更新)
|
||||
if (!vulnerabilities || !Array.isArray(vulnerabilities)) {
|
||||
listContainer.innerHTML = '<div class="empty-state" data-i18n="vulnerabilityPage.noRecords">暂无漏洞记录</div>';
|
||||
if (typeof window.applyTranslations === 'function') {
|
||||
window.applyTranslations(listContainer);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (vulnerabilities.length === 0) {
|
||||
if (!Array.isArray(vulnerabilities) || vulnerabilities.length === 0) {
|
||||
listContainer.innerHTML = '<div class="empty-state" data-i18n="vulnerabilityPage.noRecords">暂无漏洞记录</div>';
|
||||
if (typeof window.applyTranslations === 'function') {
|
||||
window.applyTranslations(listContainer);
|
||||
}
|
||||
updateVulnerabilityListSummary();
|
||||
return;
|
||||
}
|
||||
|
||||
const html = vulnerabilities.map(vuln => {
|
||||
const severityClass = `severity-${vuln.severity}`;
|
||||
const severityText = vulnSeverityLabel(vuln.severity);
|
||||
const createdDate = new Date(vuln.created_at).toLocaleString(vulnDateLocale());
|
||||
const projectLabel = vuln.project_id
|
||||
? escapeHtml(typeof getProjectName === 'function' ? getProjectName(vuln.project_id) : vuln.project_id)
|
||||
: escapeHtml(vulnT('vulnerabilityPage.projectUnbound'));
|
||||
const projectBadge = vuln.project_id
|
||||
? `<span class="vulnerability-project-badge" title="${escapeAttr(vuln.project_id)}">${escapeHtml(vulnT('vulnerabilityPage.detailProject'))}: ${projectLabel}</span>`
|
||||
: `<span class="vulnerability-project-badge vulnerability-project-badge--unbound">${escapeHtml(vulnT('vulnerabilityPage.projectUnbound'))}</span>`;
|
||||
const dlTitle = escapeHtml(vulnT('vulnerabilityDownload.download'));
|
||||
const editTitle = escapeHtml(vulnT('common.edit'));
|
||||
const deleteTitle = escapeHtml(vulnT('common.delete'));
|
||||
const vulnIdJs = escapeJsStringAttr(vuln.id);
|
||||
|
||||
return `
|
||||
<div class="vulnerability-card ${severityClass}" id="vulnerability-card-${escapeAttr(vuln.id)}" data-vuln-id="${escapeAttr(vuln.id)}">
|
||||
<div class="vulnerability-header" onclick="toggleVulnerabilityDetails(${vulnIdJs})" style="cursor: pointer;">
|
||||
<div class="vulnerability-title-section">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<svg class="vulnerability-expand-icon" id="expand-icon-${escapeAttr(vuln.id)}" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="transition: transform 0.2s ease; flex-shrink: 0;">
|
||||
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<h3 class="vulnerability-title">${escapeHtml(vuln.title)}</h3>
|
||||
</div>
|
||||
<div class="vulnerability-meta">
|
||||
<span class="severity-badge ${severityClass}">${severityText}</span>
|
||||
${buildVulnerabilityStatusPicker(vuln)}
|
||||
${projectBadge}
|
||||
<span class="vulnerability-date">${createdDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vulnerability-actions" onclick="event.stopPropagation();">
|
||||
<button type="button" class="btn-ghost" data-require-permission="vulnerability:write" onclick="openVulnerabilityRetest(${vulnIdJs})" ${vuln.conversation_id ? '' : 'disabled'}>${escapeHtml(vulnT('vulnerabilityRetest.button'))}</button>
|
||||
<button class="btn-ghost" type="button" onclick="openVulnerabilityDownload(${vulnIdJs}, event)" title="${dlTitle}" aria-label="${dlTitle}">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="7 10 12 15 17 10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-ghost" onclick="editVulnerability(${vulnIdJs})" title="${editTitle}">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="btn-ghost" data-require-permission="vulnerability:delete" onclick="deleteVulnerability(${vulnIdJs})" title="${deleteTitle}">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6h14z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vulnerability-content" id="content-${vuln.id}" style="display: none;">
|
||||
${vuln.description ? `<div class="vulnerability-description">${escapeHtml(vuln.description)}</div>` : ''}
|
||||
<div class="vulnerability-details">
|
||||
${vulnDetailField(vulnT('vulnerabilityPage.detailVulnId'), vuln.id, true)}
|
||||
${vulnDetailProjectField(vuln)}
|
||||
${vuln.type ? vulnDetailField(vulnT('vulnerabilityPage.detailType'), vuln.type, false) : ''}
|
||||
${vuln.target ? vulnDetailField(vulnT('vulnerabilityPage.detailTarget'), vuln.target, false) : ''}
|
||||
${vulnDetailConversationField(vuln.conversation_id)}
|
||||
${vuln.task_id ? vulnDetailField(vulnT('vulnerabilityPage.detailTaskId'), vuln.task_id, true) : ''}
|
||||
${vuln.task_queue_id ? vulnDetailField(vulnT('vulnerabilityPage.detailTaskQueueId'), vuln.task_queue_id, true) : ''}
|
||||
${vuln.conversation_tag ? vulnDetailField(vulnT('vulnerabilityPage.detailConversationTag'), vuln.conversation_tag, false) : ''}
|
||||
${vuln.task_tag ? vulnDetailField(vulnT('vulnerabilityPage.detailTaskTag'), vuln.task_tag, false) : ''}
|
||||
</div>
|
||||
<div class="vulnerability-repro">
|
||||
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailPreconditions'), vuln.preconditions)}
|
||||
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailReproductionSteps'), vuln.reproduction_steps)}
|
||||
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailEvidence'), vuln.evidence, { code: true })}
|
||||
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailImpact'), vuln.impact)}
|
||||
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailRecommendation'), vuln.recommendation)}
|
||||
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailRetestNotes'), vuln.retest_notes)}
|
||||
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailRetestLog'), vuln.retest_log, { code: true })}
|
||||
</div>
|
||||
<div class="vulnerability-related-facts" id="vuln-related-facts-${escapeAttr(vuln.id)}" data-project-id="${escapeAttr(vuln.project_id || '')}" data-vuln-id="${escapeAttr(vuln.id)}" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
listContainer.innerHTML = html;
|
||||
const exactMatch = vulnerabilities.length === 1 && vulnerabilityFilters.id === vulnerabilities[0].id
|
||||
? vulnerabilities[0].id
|
||||
: null;
|
||||
listContainer.innerHTML = renderVulnerabilityList(vulnerabilities);
|
||||
initVulnerabilityStatusPickers(listContainer);
|
||||
initVulnerabilityProjectBindSelects(listContainer);
|
||||
if (typeof window.applyTranslations === 'function') {
|
||||
window.applyTranslations(listContainer);
|
||||
}
|
||||
if (typeof rbacAfterDynamicRender === 'function') rbacAfterDynamicRender(listContainer);
|
||||
|
||||
restoreExpandedVulnerabilityDetails(opts.expandedIds);
|
||||
|
||||
// 如果通过漏洞ID筛选且只返回一条记录,自动展开详情(提升“点击查看”的用户体验)
|
||||
if (vulnerabilities.length === 1 && vulnerabilityFilters.id && vulnerabilityFilters.id === vulnerabilities[0].id) {
|
||||
setTimeout(() => {
|
||||
toggleVulnerabilityDetails(vulnerabilities[0].id);
|
||||
}, 300);
|
||||
if (exactMatch && typeof openVulnerabilityInspector === 'function') {
|
||||
openVulnerabilityInspector(exactMatch);
|
||||
}
|
||||
updateVulnerabilityListSummary();
|
||||
}
|
||||
|
||||
function changeVulnerabilitySort(value) {
|
||||
if (!['', 'severity', 'latest'].includes(value)) return;
|
||||
vulnerabilitySort = value;
|
||||
localStorage.setItem('vulnerabilityListSort', value);
|
||||
loadVulnerabilities(1);
|
||||
}
|
||||
|
||||
function updateVulnerabilityListSummary() {
|
||||
const list = document.getElementById('vulnerabilities-list');
|
||||
const toolbar = document.getElementById('vulnerability-list-toolbar');
|
||||
if (!list || !toolbar) return;
|
||||
const count = ['host','task'].includes(vulnerabilityView) && list.querySelector('.vulnerability-host-browser')
|
||||
? new Set(vulnerabilityBrowserRows.map(vuln => vuln.id)).size
|
||||
: list.querySelectorAll('.vulnerability-record-row').length;
|
||||
document.getElementById('vulnerability-page-summary').textContent = count
|
||||
? vulnT('vulnerabilityPage.pageFindings', { count }) : '';
|
||||
}
|
||||
|
||||
function renderVulnerabilityBrowser(vulnerabilities, opts) {
|
||||
const activeSearch = document.activeElement?.id === 'vulnerability-host-search';
|
||||
const caret = activeSearch ? document.activeElement.selectionStart : null;
|
||||
vulnerabilityBrowserRows = vulnerabilities;
|
||||
const isTask = opts.view === 'task';
|
||||
const groups = new Map();
|
||||
for (const vuln of vulnerabilities) {
|
||||
const host = vuln.target_host || '';
|
||||
const domain = vuln.target_domain || host;
|
||||
let rootKey = 'domain:' + domain, rootLabel = domain;
|
||||
let key = 'host:' + host, label = host || vulnT('vulnerabilityPage.unknownHost');
|
||||
let flat = host === domain;
|
||||
if (isTask) {
|
||||
const tag = (vuln.task_tag || '').replace(/^ +| +$/g, '');
|
||||
key = vuln.task_id ? 'task:' + vuln.task_id : 'tag:' + tag;
|
||||
label = vuln.task_id ? vulnT('vulnerabilityPage.viewTaskGroup', {id:vuln.task_id}) : tag || vulnT('vulnerabilityPage.viewNoTask');
|
||||
rootKey = vuln.task_queue_id ? 'queue:' + vuln.task_queue_id : key;
|
||||
rootLabel = vuln.task_queue_title || (vuln.task_queue_id
|
||||
? vulnT('vulnerabilityPage.taskQueueGroup', {id:vuln.task_queue_id}) : label);
|
||||
flat = !vuln.task_queue_id;
|
||||
}
|
||||
if (!groups.has(rootKey)) groups.set(rootKey, {label:rootLabel, children:new Map(), items:[], flat});
|
||||
const group = groups.get(rootKey);
|
||||
group.flat = group.flat && flat;
|
||||
group.items.push(vuln);
|
||||
if (!group.children.has(key)) group.children.set(key, {label, items:[]});
|
||||
group.children.get(key).items.push(vuln);
|
||||
}
|
||||
const button = (key, label, count, items = []) => `
|
||||
<button type="button" class="vulnerability-host-option" data-host-key="${escapeAttr(key)}" aria-pressed="false">
|
||||
<span class="vulnerability-host-option-name" title="${escapeAttr(label)}">${renderVulnerabilityHostRiskDot(items)}${escapeHtml(label)}</span><span class="vulnerability-host-number">${count}</span>
|
||||
</button>`;
|
||||
const tree = Array.from(groups, ([rootKey, group]) => {
|
||||
const count = new Set(group.items.map(vuln => vuln.id)).size;
|
||||
if (group.flat && group.children.size === 1) {
|
||||
const [key,child] = group.children.entries().next().value;
|
||||
return button(key, child.label, count, child.items);
|
||||
}
|
||||
const open = opts.hostOpenStates?.get(rootKey) ?? true;
|
||||
return `<details class="vulnerability-domain-branch" data-domain="${escapeAttr(rootKey)}"${open ? ' open' : ''}>
|
||||
<summary><button type="button" class="vulnerability-domain-label" data-host-key="${escapeAttr(rootKey)}" title="${escapeAttr(group.label)}" aria-pressed="false">${escapeHtml(group.label)}</button><span class="vulnerability-host-number">${count}</span></summary>
|
||||
${Array.from(group.children, ([key, child]) => button(key, child.label, child.items.length, child.items)).join('')}
|
||||
</details>`;
|
||||
}).join('');
|
||||
const list = document.getElementById('vulnerabilities-list');
|
||||
list.innerHTML = `
|
||||
<div class="vulnerability-host-browser">
|
||||
<aside class="vulnerability-host-sidebar" aria-label="${escapeAttr(vulnT(isTask ? 'vulnerabilityPage.viewTask' : 'vulnerabilityPage.viewHost'))}">
|
||||
<input id="vulnerability-host-search" type="search"
|
||||
aria-label="${escapeAttr(vulnT(isTask ? 'vulnerabilityPage.taskSearch' : 'vulnerabilityPage.hostSearch'))}"
|
||||
placeholder="${escapeAttr(vulnT(isTask ? 'vulnerabilityPage.taskSearch' : 'vulnerabilityPage.hostSearch'))}" value="${escapeAttr(isTask ? vulnerabilityTaskQuery : vulnerabilityHostQuery)}">
|
||||
<nav class="vulnerability-host-tree">
|
||||
${tree}
|
||||
</nav>
|
||||
</aside>
|
||||
<section class="vulnerability-host-results">
|
||||
<div class="vulnerability-host-results-heading">
|
||||
<h3 id="vulnerability-host-selection"></h3>
|
||||
</div>
|
||||
<div class="vulnerability-host-overview">
|
||||
<div id="vulnerability-host-risk-summary"></div>
|
||||
<div class="vulnerability-host-heading-actions">
|
||||
<button type="button" id="vulnerability-host-clear" class="btn-ghost" onclick="vulnerabilityBrowserSelection='';renderVulnerabilityBrowserTable()" hidden>${escapeHtml(vulnT(isTask ? 'vulnerabilityPage.clearTaskSelection' : 'vulnerabilityPage.clearHostSelection'))}</button>
|
||||
<span id="vulnerability-host-selection-count"></span>
|
||||
<button type="button" class="btn-secondary" id="vulnerability-host-export"
|
||||
onclick="exportVisibleVulnerabilityBrowser()" data-require-permission="vulnerability:read">${escapeHtml(vulnT('vulnerabilityPage.exportVisibleHosts'))}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="vulnerability-host-table"></div>
|
||||
</section>
|
||||
</div>`;
|
||||
const search = document.getElementById('vulnerability-host-search');
|
||||
search.addEventListener('input', () => {
|
||||
if (isTask) vulnerabilityTaskQuery = search.value.trim();
|
||||
else vulnerabilityHostQuery = search.value.trim();
|
||||
clearTimeout(vulnerabilityHostSearchTimer);
|
||||
// Invalidate an in-flight response as soon as the next query is typed.
|
||||
vulnerabilityListRequest++;
|
||||
vulnerabilityHostSearchTimer = setTimeout(() => {
|
||||
vulnerabilityBrowserSelection = '';
|
||||
loadVulnerabilities(1, { silent: true });
|
||||
}, VULNERABILITY_FILTER_DEBOUNCE_MS);
|
||||
});
|
||||
list.querySelector('.vulnerability-host-tree').addEventListener('click', event => {
|
||||
const option = event.target.closest('button[data-host-key]');
|
||||
if (!option) return;
|
||||
event.preventDefault();
|
||||
vulnerabilityBrowserSelection = option.dataset.hostKey;
|
||||
renderVulnerabilityBrowserTable();
|
||||
});
|
||||
if (!opts.preserveHostSelection &&
|
||||
!Array.from(list.querySelectorAll('[data-host-key]')).some(el => el.dataset.hostKey === vulnerabilityBrowserSelection)) {
|
||||
vulnerabilityBrowserSelection = '';
|
||||
}
|
||||
renderVulnerabilityBrowserTable();
|
||||
if (activeSearch) {
|
||||
search.focus({ preventScroll: true });
|
||||
search.setSelectionRange(caret, caret);
|
||||
}
|
||||
}
|
||||
|
||||
function getVisibleVulnerabilityBrowserItems() {
|
||||
const selection = vulnerabilityBrowserSelection;
|
||||
const items = vulnerabilityBrowserRows.filter(vuln => !selection ||
|
||||
selection === 'host:' + (vuln.target_host || '') ||
|
||||
selection === 'domain:' + (vuln.target_domain || vuln.target_host || '') ||
|
||||
(vuln.task_id && selection === 'task:' + vuln.task_id) ||
|
||||
(vuln.task_queue_id && selection === 'queue:' + vuln.task_queue_id) ||
|
||||
(!vuln.task_id && selection === 'tag:' + (vuln.task_tag || '').replace(/^ +| +$/g, '')));
|
||||
return Array.from(new Map(items.map(vuln => [vuln.id,vuln])).values());
|
||||
}
|
||||
|
||||
async function exportVisibleVulnerabilityBrowser() {
|
||||
const items = getVisibleVulnerabilityBrowserItems();
|
||||
if (!items.length) return;
|
||||
try {
|
||||
const full = [];
|
||||
for (const item of items) {
|
||||
if (!item.summary_only) { full.push(item); continue; }
|
||||
const response = await apiFetch('/api/vulnerabilities/' + encodeURIComponent(item.id));
|
||||
if (!response.ok) throw new Error(vulnT('vulnerabilityPage.exportFailed'));
|
||||
full.push(await response.json());
|
||||
}
|
||||
const content = full.map(formatVulnerabilityAsMarkdown).join('\n\n---\n\n');
|
||||
triggerTextDownload('vulnerabilities-' + vulnerabilityView + '-' + new Date().toISOString().slice(0, 10) + '.md', content);
|
||||
} catch(error) { alert(error.message); }
|
||||
}
|
||||
|
||||
function renderVulnerabilityHostRiskDot(items) {
|
||||
const highest = VULN_STAT_SEVERITIES.find(severity => items.some(item => item.severity === severity));
|
||||
return highest ? `<span class="vulnerability-host-risk-dot severity-${highest}" title="${escapeAttr(vulnSeverityLabel(highest))}"></span>` : '';
|
||||
}
|
||||
|
||||
function renderVulnerabilityBrowserTable() {
|
||||
const selection = vulnerabilityBrowserSelection;
|
||||
const items = getVisibleVulnerabilityBrowserItems();
|
||||
document.querySelectorAll('#vulnerabilities-list [data-host-key]').forEach(option => {
|
||||
option.setAttribute('aria-pressed', String(option.dataset.hostKey === selection));
|
||||
});
|
||||
const selectedOption = Array.from(document.querySelectorAll('#vulnerabilities-list [data-host-key]')).find(option => option.dataset.hostKey === selection);
|
||||
const label = selection ? (selectedOption?.querySelector('[title]')?.title || selectedOption?.title || selection.slice(selection.indexOf(':') + 1))
|
||||
: '';
|
||||
const heading = document.getElementById('vulnerability-host-selection');
|
||||
heading.hidden = !selection;
|
||||
heading.parentElement.hidden = !selection;
|
||||
heading.textContent = selection ? label || vulnT('vulnerabilityPage.unknownHost') : '';
|
||||
heading.title = heading.textContent;
|
||||
document.getElementById('vulnerability-host-clear').hidden = !selection;
|
||||
document.getElementById('vulnerability-host-selection-count').textContent =
|
||||
vulnT('vulnerabilityPage.groupPageCount', { count: items.length });
|
||||
const counts = {};
|
||||
let pending = 0;
|
||||
for (const item of items) {
|
||||
counts[item.severity] = (counts[item.severity] || 0) + 1;
|
||||
if (!item.status || item.status === 'open' || item.status === 'confirmed') pending++;
|
||||
}
|
||||
document.getElementById('vulnerability-host-risk-summary').innerHTML =
|
||||
VULN_STAT_SEVERITIES.filter(severity => counts[severity]).map(severity =>
|
||||
`<span class="severity-badge severity-${severity}">${escapeHtml(vulnSeverityLabel(severity))} ${counts[severity]}</span>`
|
||||
).join('') + `<span class="vulnerability-group-pending">${escapeHtml(vulnT('vulnerabilityPage.groupPending', { count: pending }))}</span>`;
|
||||
document.getElementById('vulnerability-host-export').disabled = items.length === 0;
|
||||
const container = document.getElementById('vulnerability-host-table');
|
||||
container.innerHTML = renderVulnerabilityList(items);
|
||||
initVulnerabilityStatusPickers(container);
|
||||
initVulnerabilityProjectBindSelects(container);
|
||||
if (typeof rbacAfterDynamicRender === 'function') rbacAfterDynamicRender(container);
|
||||
const exact = items.find(vuln => vulnerabilityFilters.id === vuln.id);
|
||||
if (exact && typeof openVulnerabilityInspector === 'function') openVulnerabilityInspector(exact.id);
|
||||
updateVulnerabilityListSummary();
|
||||
}
|
||||
|
||||
function renderVulnerabilityList(items) {
|
||||
if (!items.length) {
|
||||
return `<div class="empty-state">${escapeHtml(vulnT('vulnerabilityPage.noRecords'))}</div>`;
|
||||
}
|
||||
return `<ul class="vulnerability-record-list">${items.map(renderVulnerabilityListItem).join('')}</ul>`;
|
||||
}
|
||||
|
||||
function renderVulnerabilityListItem(vuln) {
|
||||
const id = escapeAttr(vuln.id);
|
||||
const jsID = escapeJsStringAttr(vuln.id);
|
||||
const summary = (vuln.description || vuln.target || '').replace(/\s+/g, ' ').trim();
|
||||
return `<li class="vulnerability-record-row" id="vulnerability-card-${id}" data-vuln-id="${id}">
|
||||
<span class="vulnerability-record-select">${typeof vulnerabilitySelectionInput === 'function' ? vulnerabilitySelectionInput(vuln) : ''}</span>
|
||||
<span class="severity-badge severity-${escapeAttr(vuln.severity)}">${escapeHtml(vulnSeverityLabel(vuln.severity))}</span>
|
||||
<button type="button" class="vulnerability-record-main" aria-haspopup="dialog" onclick="openVulnerabilityInspector(${jsID})">
|
||||
<span class="vulnerability-record-title-line">
|
||||
<span class="vulnerability-record-title" title="${escapeAttr(vuln.title)}">${escapeHtml(vuln.title)}</span>
|
||||
${vuln.case_id ? `<span class="vulnerability-case-badge">${escapeHtml(vulnT('vulnerabilityPage.mergedRecord'))}</span>` : ''}
|
||||
</span>
|
||||
${summary ? `<span class="vulnerability-record-summary" title="${escapeAttr(summary)}">${escapeHtml(summary)}</span>` : ''}
|
||||
</button>
|
||||
<div class="vulnerability-record-actions">
|
||||
${buildVulnerabilityStatusPicker(vuln)}
|
||||
<button type="button" class="vulnerability-record-view" aria-haspopup="dialog" onclick="openVulnerabilityInspector(${jsID})">${escapeHtml(vulnT('vulnerabilityPage.viewFinding'))}</button>
|
||||
</div>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
// 渲染分页控件
|
||||
@@ -1485,6 +1626,11 @@ function renderVulnerabilityPagination() {
|
||||
}
|
||||
|
||||
const { currentPage, totalPages, total, pageSize } = vulnerabilityPagination;
|
||||
const paginationLabel = {
|
||||
host: 'vulnerabilityPage.hostPagination',
|
||||
task: 'vulnerabilityPage.taskPagination',
|
||||
none: 'vulnerabilityPage.recordPagination'
|
||||
}[vulnerabilityView];
|
||||
|
||||
// 计算显示范围
|
||||
const start = total === 0 ? 0 : (currentPage - 1) * pageSize + 1;
|
||||
@@ -1495,7 +1641,7 @@ function renderVulnerabilityPagination() {
|
||||
// 左侧:显示范围信息和每页数量选择器(参考Skills样式)
|
||||
paginationHTML += `
|
||||
<div class="pagination-info">
|
||||
<span>${escapeHtml(vulnT('skillsPage.paginationShow', { start, end, total }))}</span>
|
||||
<span>${escapeHtml(vulnT(paginationLabel, { start, end, total }))}</span>
|
||||
<label class="pagination-page-size">
|
||||
${escapeHtml(vulnT('skillsPage.perPageLabel'))}
|
||||
<select id="vulnerability-page-size-pagination" onchange="changeVulnerabilityPageSize()">
|
||||
@@ -1541,16 +1687,9 @@ async function changeVulnerabilityPageSize() {
|
||||
// 保存到localStorage
|
||||
localStorage.setItem('vulnerabilityPageSize', newPageSize.toString());
|
||||
|
||||
// 更新分页配置
|
||||
const currentStartItem = (vulnerabilityPagination.currentPage - 1) * vulnerabilityPagination.pageSize;
|
||||
vulnerabilityPagination.pageSize = newPageSize;
|
||||
|
||||
// 重新计算当前页(保持显示的数据范围尽可能接近)
|
||||
const currentStartItem = (vulnerabilityPagination.currentPage - 1) * vulnerabilityPagination.pageSize + 1;
|
||||
const newPage = Math.max(1, Math.floor((currentStartItem - 1) / newPageSize) + 1);
|
||||
vulnerabilityPagination.currentPage = newPage;
|
||||
|
||||
// 重新加载数据
|
||||
await loadVulnerabilities();
|
||||
await loadVulnerabilities(Math.floor(currentStartItem / newPageSize) + 1);
|
||||
}
|
||||
|
||||
function buildVulnerabilityProjectOptionsHtml(selectedId) {
|
||||
@@ -1744,9 +1883,9 @@ async function saveVulnerability() {
|
||||
}
|
||||
|
||||
const updated = await response.json();
|
||||
if (typeof refreshVulnerabilityInspector === 'function') refreshVulnerabilityInspector(currentVulnerabilityId);
|
||||
const editedId = currentVulnerabilityId;
|
||||
const isEdit = !!editedId;
|
||||
const expandedIds = isEdit ? getExpandedVulnerabilityIds() : [];
|
||||
|
||||
closeVulnerabilityModal();
|
||||
loadVulnerabilityStats();
|
||||
@@ -1759,11 +1898,11 @@ async function saveVulnerability() {
|
||||
|
||||
const newStatus = (updated && updated.status) || data.status;
|
||||
if (!vulnerabilityStatusMatchesFilter(newStatus)) {
|
||||
removeVulnerabilityCard(editedId, { decrementTotal: true, focusNext: true });
|
||||
await removeVulnerabilityCard(editedId);
|
||||
return;
|
||||
}
|
||||
|
||||
await loadVulnerabilities(null, { preserveScroll: true, silent: true, expandedIds: expandedIds });
|
||||
await loadVulnerabilities(null, { preserveScroll: true, silent: true });
|
||||
} catch (error) {
|
||||
console.error('保存漏洞失败:', error);
|
||||
alert(vulnT('vulnerabilityPage.saveFailed') + ': ' + error.message);
|
||||
@@ -1783,21 +1922,11 @@ async function deleteVulnerability(id) {
|
||||
|
||||
if (!response.ok) throw new Error(vulnT('vulnerabilityPage.deleteFailed'));
|
||||
|
||||
if (typeof vulnerabilityInspectorID !== 'undefined' && vulnerabilityInspectorID === id) {
|
||||
closeAppModal('vulnerability-inspector');
|
||||
}
|
||||
loadVulnerabilityStats();
|
||||
const card = document.getElementById('vulnerability-card-' + id) ||
|
||||
document.querySelector('.vulnerability-card[data-vuln-id="' + id + '"]');
|
||||
if (card) {
|
||||
removeVulnerabilityCard(id, { decrementTotal: true, focusNext: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (vulnerabilityPagination.currentPage > 1 && vulnerabilityPagination.total > 0) {
|
||||
const itemsOnCurrentPage = vulnerabilityPagination.total - (vulnerabilityPagination.currentPage - 1) * vulnerabilityPagination.pageSize;
|
||||
if (itemsOnCurrentPage <= 1) {
|
||||
vulnerabilityPagination.currentPage--;
|
||||
}
|
||||
}
|
||||
await loadVulnerabilities(null, { preserveScroll: true });
|
||||
await removeVulnerabilityCard(id);
|
||||
} catch (error) {
|
||||
console.error('删除漏洞失败:', error);
|
||||
alert(vulnT('vulnerabilityPage.deleteFailed') + ': ' + error.message);
|
||||
@@ -1857,7 +1986,7 @@ function clearVulnerabilityFilters() {
|
||||
// 刷新漏洞
|
||||
function refreshVulnerabilities() {
|
||||
loadVulnerabilityStats();
|
||||
loadVulnerabilities();
|
||||
loadVulnerabilities(null, { preserveScroll: true, silent: true });
|
||||
}
|
||||
|
||||
async function loadVulnerabilityAlertSubscription() {
|
||||
@@ -1933,23 +2062,6 @@ async function saveVulnerabilityAlertSubscription() {
|
||||
}
|
||||
}
|
||||
|
||||
// 切换漏洞详情展开/折叠
|
||||
function toggleVulnerabilityDetails(id) {
|
||||
const content = document.getElementById(`content-${id}`);
|
||||
const icon = document.getElementById(`expand-icon-${id}`);
|
||||
|
||||
if (!content || !icon) return;
|
||||
|
||||
if (content.style.display === 'none') {
|
||||
content.style.display = 'block';
|
||||
icon.style.transform = 'rotate(90deg)';
|
||||
loadVulnerabilityRelatedFacts(id).catch((e) => console.warn(e));
|
||||
} else {
|
||||
content.style.display = 'none';
|
||||
icon.style.transform = 'rotate(0deg)';
|
||||
}
|
||||
}
|
||||
|
||||
/** 展开漏洞详情时加载关联项目事实 */
|
||||
async function loadVulnerabilityRelatedFacts(vulnId) {
|
||||
const el = document.getElementById(`vuln-related-facts-${vulnId}`);
|
||||
@@ -1970,7 +2082,7 @@ async function loadVulnerabilityRelatedFacts(vulnId) {
|
||||
const facts = await res.json();
|
||||
if (!Array.isArray(facts) || !facts.length) {
|
||||
el.innerHTML =
|
||||
'<strong>关联事实</strong><p style="margin:6px 0 0;color:#64748b">暂无;可在「项目管理」事实详情中关联或生成漏洞草稿。</p>';
|
||||
'<strong>关联事实</strong><p>暂无;可在「项目管理」事实详情中关联或生成漏洞草稿。</p>';
|
||||
el.dataset.loaded = '1';
|
||||
return;
|
||||
}
|
||||
@@ -1986,7 +2098,7 @@ async function loadVulnerabilityRelatedFacts(vulnId) {
|
||||
el.innerHTML = `<strong>关联事实(${facts.length})</strong><ul>${items}</ul>`;
|
||||
el.dataset.loaded = '1';
|
||||
} catch (e) {
|
||||
el.innerHTML = '<strong>关联事实</strong><p style="margin:6px 0 0;color:#b91c1c">加载失败</p>';
|
||||
el.innerHTML = '<strong>关联事实</strong><p class="vulnerability-related-facts-error">加载失败</p>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2430,15 +2542,12 @@ async function bindVulnerabilityProject(vulnId, projectId, silent) {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
throw new Error(err.error || vulnT('vulnerabilityPage.projectBindFailed'));
|
||||
}
|
||||
if (typeof refreshVulnerabilityInspector === 'function') refreshVulnerabilityInspector(vulnId);
|
||||
if (!silent) {
|
||||
alert(vulnT('vulnerabilityPage.projectBindOk'));
|
||||
}
|
||||
loadVulnerabilityStats();
|
||||
const expandedIds = getExpandedVulnerabilityIds();
|
||||
if (!expandedIds.includes(vulnId)) {
|
||||
expandedIds.push(vulnId);
|
||||
}
|
||||
await loadVulnerabilities(null, { preserveScroll: true, silent: true, expandedIds: expandedIds });
|
||||
await loadVulnerabilities(null, { preserveScroll: true, silent: true });
|
||||
} catch (error) {
|
||||
console.error('绑定项目失败:', error);
|
||||
alert(vulnT('vulnerabilityPage.projectBindFailed') + ': ' + error.message);
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=20261027-pagination-custom-select-20261113-chat-scroll">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=20261113-vulnerability-domain-browser">
|
||||
<link rel="stylesheet" href="/static/css/vulnerability-download.css?v=20261030-2">
|
||||
<link rel="stylesheet" href="/static/css/tool-guard.css?v=20260907-7">
|
||||
<link rel="stylesheet" href="/static/css/chat-plan-progress.css?v=20260813-4">
|
||||
@@ -47,6 +47,8 @@
|
||||
<link rel="stylesheet" href="/static/vendor/xterm.css">
|
||||
<!-- 移动端样式层:全部规则限定在 767.98px 以下,桌面端零影响 -->
|
||||
<link rel="stylesheet" href="/static/css/mobile.css?v=20261015-project-table-3">
|
||||
<link rel="stylesheet" href="/static/css/vulnerability-workspace.css?v=20261113-aligned-overview">
|
||||
<link rel="stylesheet" href="/static/css/vulnerability-list.css?v=20261113-compact-rows">
|
||||
<script src="/static/js/router.js?v=20261011-token-settings"></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -2688,7 +2690,15 @@
|
||||
<!-- 漏洞管理页面 -->
|
||||
<div id="page-vulnerabilities" class="page">
|
||||
<div class="page-header">
|
||||
<h2 data-i18n="vulnerability.title">漏洞管理</h2>
|
||||
<div class="vulnerability-header-start">
|
||||
<h2 data-i18n="vulnerability.title">漏洞管理</h2>
|
||||
<div id="vulnerability-view-switch" class="vulnerability-view-switch" role="group"
|
||||
aria-label="漏洞视图" data-i18n="vulnerabilityPage.viewLabel" data-i18n-attr="aria-label">
|
||||
<button type="button" data-vuln-view="host" aria-pressed="true" data-i18n="vulnerabilityPage.viewHost">按域名</button>
|
||||
<button type="button" data-vuln-view="task" aria-pressed="false" data-i18n="vulnerabilityPage.viewTask">按任务</button>
|
||||
<button type="button" data-vuln-view="none" aria-pressed="false" data-i18n="vulnerabilityPage.viewRaw">原始漏洞</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-header-actions">
|
||||
<button class="btn-secondary" data-require-permission="vulnerability:read" onclick="exportVulnerabilityReports()" data-i18n="vulnerabilityPage.batchExport">批量导出</button>
|
||||
<button class="btn-secondary btn-delete" data-require-permission="vulnerability:delete" onclick="batchDeleteVulnerabilityReports()" data-i18n="vulnerabilityPage.batchDelete">批量删除</button>
|
||||
@@ -2699,10 +2709,13 @@
|
||||
<div class="page-content">
|
||||
<!-- 统计看板:点击卡片筛选严重度,与下方下拉/地址栏 hash 同步 -->
|
||||
<div class="vulnerability-dashboard" id="vulnerability-dashboard">
|
||||
<div class="vulnerability-workspace-caption">
|
||||
<span data-i18n="vulnerabilityPage.workspaceSubtitle">资产、上报与处置进展</span>
|
||||
</div>
|
||||
<div class="dashboard-stats" id="vulnerability-stat-cards" role="group" aria-label="漏洞严重度统计">
|
||||
<div class="stat-card stat-card-total is-clickable is-active" data-severity="" role="button" tabindex="0"
|
||||
data-i18n="vulnerabilityPage.statClickAll" data-i18n-attr="title" title="查看全部(清除严重度筛选)">
|
||||
<div class="stat-label" data-i18n="vulnerabilityPage.statTotal">总漏洞数</div>
|
||||
<div class="stat-label" data-i18n="vulnerabilityPage.statReports">上报记录</div>
|
||||
<div class="stat-value" id="stat-total">-</div>
|
||||
<div class="stat-stacked-bar" id="stat-stacked-bar" aria-hidden="true">
|
||||
<span class="stat-stacked-seg critical" data-sev="critical"></span>
|
||||
@@ -2842,6 +2855,21 @@
|
||||
</div>
|
||||
|
||||
<!-- 漏洞列表 -->
|
||||
<div id="vulnerability-list-toolbar" class="vulnerability-list-toolbar">
|
||||
<span id="vulnerability-page-summary" aria-live="polite"></span>
|
||||
<div class="vulnerability-list-tools">
|
||||
<button type="button" id="vulnerability-merge-selected" class="btn-secondary" data-require-permission="vulnerability:write" disabled onclick="openVulnerabilityMergeDialog()" data-i18n="vulnerabilityPage.mergeSelected">归并所选</button>
|
||||
<label class="vulnerability-sort-label">
|
||||
<span data-i18n="vulnerabilityPage.sortLabel">排序</span>
|
||||
<select id="vulnerability-sort" onchange="changeVulnerabilitySort(this.value)">
|
||||
<option value="" data-i18n="vulnerabilityPage.sortDefault">默认顺序</option>
|
||||
<option value="severity" data-i18n="vulnerabilityPage.sortSeverity">高危优先</option>
|
||||
<option value="latest" data-i18n="vulnerabilityPage.sortLatest">最新发现</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div id="vulnerabilities-list" class="vulnerabilities-list">
|
||||
<div class="loading-spinner" data-i18n="vulnerabilityPage.loading">加载中...</div>
|
||||
</div>
|
||||
@@ -6411,6 +6439,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="vulnerability-inspector" class="modal" role="dialog" aria-modal="true" aria-labelledby="vulnerability-inspector-title">
|
||||
<div class="modal-content vulnerability-inspector-panel">
|
||||
<header class="vulnerability-inspector-header">
|
||||
<div><span class="vulnerability-inspector-eyebrow" data-i18n="vulnerabilityPage.inspectorTitle">漏洞工作台</span><h2 id="vulnerability-inspector-title"></h2></div>
|
||||
<button type="button" class="modal-close" onclick="closeAppModal('vulnerability-inspector')" aria-label="关闭">×</button>
|
||||
</header>
|
||||
<div id="vulnerability-inspector-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="vulnerability-merge-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="vulnerability-merge-title">
|
||||
<div class="modal-content vulnerability-merge-panel">
|
||||
<header class="modal-header"><h2 id="vulnerability-merge-title"></h2><button type="button" class="modal-close" onclick="closeAppModal('vulnerability-merge-modal')" aria-label="关闭">×</button></header>
|
||||
<form onsubmit="event.preventDefault(); saveVulnerabilityMerge()">
|
||||
<div class="modal-body">
|
||||
<p id="vulnerability-merge-description"></p>
|
||||
<label id="vulnerability-merge-name-label"><span data-i18n="vulnerabilityPage.tableTitle">漏洞名称</span><input id="vulnerability-merge-name" maxlength="500"></label>
|
||||
<label><span data-i18n="vulnerabilityPage.mergeReason">操作理由</span><textarea id="vulnerability-merge-reason" required maxlength="4000" rows="4"></textarea></label>
|
||||
<p id="vulnerability-merge-error" role="alert"></p>
|
||||
</div>
|
||||
<footer class="modal-footer"><button type="button" class="btn-secondary" onclick="closeAppModal('vulnerability-merge-modal')" data-i18n="common.cancel">取消</button><button type="submit" id="vulnerability-merge-save" class="btn-primary" data-i18n="common.confirm">确认</button></footer>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div id="vulnerability-retest-modal" class="modal">
|
||||
<div class="modal-content" style="max-width: 720px;">
|
||||
<div class="modal-header">
|
||||
@@ -7055,7 +7106,8 @@
|
||||
<script src="/static/js/skills.js?v=20261027-pagination-custom-select"></script>
|
||||
<script src="/static/js/fact-graph.js"></script>
|
||||
<script src="/static/js/projects.js?v=20260819-1-pagination-20261027"></script>
|
||||
<script src="/static/js/vulnerability.js?v=20261030-format-download"></script>
|
||||
<script src="/static/js/vulnerability.js?v=20261113-aligned-overview"></script>
|
||||
<script src="/static/js/vulnerability-workspace.js?v=20261113-compact-rows"></script>
|
||||
<script src="/static/js/vulnerability-download.js?v=20261030-2"></script>
|
||||
<script src="/static/js/webshell.js?v=20261011-token-usage"></script>
|
||||
<script src="/static/js/chat-files.js?v=20261027-pagination-custom-select"></script>
|
||||
|
||||
Reference in new issue
Block a user