mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-18 18:07:32 +02:00
Add files via upload
This commit is contained in:
@@ -983,10 +983,14 @@ func setupRoutes(
|
||||
|
||||
// 资产管理
|
||||
protected.GET("/assets", assetHandler.List)
|
||||
protected.GET("/assets/selection", assetHandler.Selection)
|
||||
protected.GET("/assets/stats", assetHandler.Stats)
|
||||
protected.POST("/assets/import", assetHandler.Import)
|
||||
protected.POST("/assets/scan-links", assetHandler.RecordScans)
|
||||
protected.PUT("/assets/bulk", assetHandler.BulkUpdate)
|
||||
protected.PUT("/assets/project-binding", assetHandler.UpdateProjectBinding)
|
||||
protected.POST("/assets/batch-delete", assetHandler.BatchDelete)
|
||||
protected.POST("/assets/merge", security.RequirePermission("asset:write"), assetHandler.Merge)
|
||||
protected.PUT("/assets/:id", assetHandler.Update)
|
||||
protected.DELETE("/assets/:id", assetHandler.Delete)
|
||||
|
||||
|
||||
+64
-12
@@ -193,7 +193,11 @@ func assetMutationProperties() map[string]interface{} {
|
||||
"domain": map[string]interface{}{"type": "string"}, "protocol": map[string]interface{}{"type": "string"},
|
||||
"title": map[string]interface{}{"type": "string"}, "server": map[string]interface{}{"type": "string"},
|
||||
"country": map[string]interface{}{"type": "string"}, "province": map[string]interface{}{"type": "string"}, "city": map[string]interface{}{"type": "string"},
|
||||
"source": map[string]interface{}{"type": "string"}, "source_query": map[string]interface{}{"type": "string"},
|
||||
"responsible_person": map[string]interface{}{"type": "string"}, "department": map[string]interface{}{"type": "string"},
|
||||
"business_system": map[string]interface{}{"type": "string"},
|
||||
"environment": map[string]interface{}{"type": "string", "enum": []string{"production", "staging", "testing", "development", "other"}},
|
||||
"criticality": map[string]interface{}{"type": "string", "enum": []string{"critical", "high", "medium", "low"}},
|
||||
"source": map[string]interface{}{"type": "string"}, "source_query": map[string]interface{}{"type": "string"},
|
||||
"status": map[string]interface{}{"type": "string", "enum": []string{"active", "inactive"}},
|
||||
"tags": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "maxItems": 50},
|
||||
}
|
||||
@@ -205,16 +209,25 @@ func assetQuerySchema() map[string]interface{} {
|
||||
"project_id": map[string]interface{}{"type": "string"}, "status": map[string]interface{}{"type": "string", "enum": []string{"active", "inactive"}},
|
||||
"protocol": map[string]interface{}{"type": "string"}, "source": map[string]interface{}{"type": "string"}, "tag": map[string]interface{}{"type": "string"},
|
||||
"host": map[string]interface{}{"type": "string"}, "ip": map[string]interface{}{"type": "string"}, "domain": map[string]interface{}{"type": "string"},
|
||||
"port": map[string]interface{}{"type": "integer", "minimum": 0, "maximum": 65535},
|
||||
"scan_state": map[string]interface{}{"type": "string", "enum": []string{"never", "scanned"}, "description": "never=从未扫描,scanned=扫描过"},
|
||||
"last_scan_before": map[string]interface{}{"type": "string", "description": "RFC3339 时间或 YYYY-MM-DD"},
|
||||
"last_scan_after": map[string]interface{}{"type": "string", "description": "RFC3339 时间或 YYYY-MM-DD"},
|
||||
"last_seen_before": map[string]interface{}{"type": "string", "description": "RFC3339 时间或 YYYY-MM-DD"},
|
||||
"last_seen_after": map[string]interface{}{"type": "string", "description": "RFC3339 时间或 YYYY-MM-DD"},
|
||||
"sort_by": map[string]interface{}{"type": "string", "enum": []string{"last_seen_at", "last_scan_at", "first_seen_at", "created_at", "updated_at", "host", "port"}},
|
||||
"sort_order": map[string]interface{}{"type": "string", "enum": []string{"asc", "desc"}},
|
||||
"page": map[string]interface{}{"type": "integer", "minimum": 1},
|
||||
"page_size": map[string]interface{}{"type": "integer", "minimum": 1, "maximum": agentAssetPageSizeMax},
|
||||
"port": map[string]interface{}{"type": "integer", "minimum": 0, "maximum": 65535},
|
||||
"risk_level": map[string]interface{}{"type": "string", "enum": []string{"unassessed", "critical", "high", "medium", "low", "info", "normal"}},
|
||||
"min_vulnerabilities": map[string]interface{}{"type": "integer", "minimum": 0},
|
||||
"max_vulnerabilities": map[string]interface{}{"type": "integer", "minimum": 0},
|
||||
"country": map[string]interface{}{"type": "string"}, "province": map[string]interface{}{"type": "string"}, "city": map[string]interface{}{"type": "string"},
|
||||
"responsible_person": map[string]interface{}{"type": "string"}, "department": map[string]interface{}{"type": "string"},
|
||||
"business_system": map[string]interface{}{"type": "string"}, "environment": map[string]interface{}{"type": "string"}, "criticality": map[string]interface{}{"type": "string"},
|
||||
"scan_state": map[string]interface{}{"type": "string", "enum": []string{"never", "scanned"}, "description": "never=从未扫描,scanned=扫描过"},
|
||||
"scan_overdue_days": map[string]interface{}{"type": "integer", "minimum": 1},
|
||||
"last_scan_before": map[string]interface{}{"type": "string", "description": "RFC3339 时间或 YYYY-MM-DD"},
|
||||
"last_scan_after": map[string]interface{}{"type": "string", "description": "RFC3339 时间或 YYYY-MM-DD"},
|
||||
"first_seen_before": map[string]interface{}{"type": "string", "description": "RFC3339 时间或 YYYY-MM-DD"},
|
||||
"first_seen_after": map[string]interface{}{"type": "string", "description": "RFC3339 时间或 YYYY-MM-DD"},
|
||||
"last_seen_before": map[string]interface{}{"type": "string", "description": "RFC3339 时间或 YYYY-MM-DD"},
|
||||
"last_seen_after": map[string]interface{}{"type": "string", "description": "RFC3339 时间或 YYYY-MM-DD"},
|
||||
"sort_by": map[string]interface{}{"type": "string", "enum": []string{"last_seen_at", "last_scan_at", "first_seen_at", "created_at", "updated_at", "host", "port", "risk_level", "vulnerability_count"}},
|
||||
"sort_order": map[string]interface{}{"type": "string", "enum": []string{"asc", "desc"}},
|
||||
"page": map[string]interface{}{"type": "integer", "minimum": 1},
|
||||
"page_size": map[string]interface{}{"type": "integer", "minimum": 1, "maximum": agentAssetPageSizeMax},
|
||||
}
|
||||
return map[string]interface{}{"type": "object", "properties": properties}
|
||||
}
|
||||
@@ -246,6 +259,11 @@ func applyAssetPatch(asset *database.Asset, args map[string]interface{}) error {
|
||||
setString("country", &asset.Country)
|
||||
setString("province", &asset.Province)
|
||||
setString("city", &asset.City)
|
||||
setString("responsible_person", &asset.ResponsiblePerson)
|
||||
setString("department", &asset.Department)
|
||||
setString("business_system", &asset.BusinessSystem)
|
||||
setString("environment", &asset.Environment)
|
||||
setString("criticality", &asset.Criticality)
|
||||
setString("source", &asset.Source)
|
||||
setString("source_query", &asset.SourceQuery)
|
||||
setString("status", &asset.Status)
|
||||
@@ -273,6 +291,11 @@ func assetFilterFromToolArgs(args map[string]interface{}) (database.AssetListFil
|
||||
Host: strings.TrimSpace(strArg(args, "host")), IP: strings.TrimSpace(strArg(args, "ip")), Domain: strings.TrimSpace(strArg(args, "domain")),
|
||||
ScanState: strings.ToLower(strings.TrimSpace(strArg(args, "scan_state"))), SortBy: strings.ToLower(strings.TrimSpace(strArg(args, "sort_by"))),
|
||||
SortOrder: strings.ToLower(strings.TrimSpace(strArg(args, "sort_order"))),
|
||||
RiskLevel: strings.ToLower(strings.TrimSpace(strArg(args, "risk_level"))),
|
||||
Country: strings.TrimSpace(strArg(args, "country")), Province: strings.TrimSpace(strArg(args, "province")), City: strings.TrimSpace(strArg(args, "city")),
|
||||
ResponsiblePerson: strings.TrimSpace(strArg(args, "responsible_person")), Department: strings.TrimSpace(strArg(args, "department")),
|
||||
BusinessSystem: strings.TrimSpace(strArg(args, "business_system")), Environment: strings.ToLower(strings.TrimSpace(strArg(args, "environment"))),
|
||||
Criticality: strings.ToLower(strings.TrimSpace(strArg(args, "criticality"))),
|
||||
}
|
||||
if !oneOfOrEmpty(filter.Status, "active", "inactive") {
|
||||
return filter, 0, 0, fmt.Errorf("status 仅支持 active 或 inactive")
|
||||
@@ -280,7 +303,7 @@ func assetFilterFromToolArgs(args map[string]interface{}) (database.AssetListFil
|
||||
if !oneOfOrEmpty(filter.ScanState, "never", "scanned") {
|
||||
return filter, 0, 0, fmt.Errorf("scan_state 仅支持 never 或 scanned")
|
||||
}
|
||||
if !oneOfOrEmpty(filter.SortBy, "last_seen_at", "last_scan_at", "first_seen_at", "created_at", "updated_at", "host", "port") {
|
||||
if !oneOfOrEmpty(filter.SortBy, "last_seen_at", "last_scan_at", "first_seen_at", "created_at", "updated_at", "host", "port", "risk_level", "vulnerability_count") {
|
||||
return filter, 0, 0, fmt.Errorf("sort_by 不受支持")
|
||||
}
|
||||
if !oneOfOrEmpty(filter.SortOrder, "asc", "desc") {
|
||||
@@ -293,6 +316,27 @@ func assetFilterFromToolArgs(args map[string]interface{}) (database.AssetListFil
|
||||
}
|
||||
filter.Port = &port
|
||||
}
|
||||
if _, ok := args["min_vulnerabilities"]; ok {
|
||||
value := intArg(args, "min_vulnerabilities", -1)
|
||||
if value < 0 {
|
||||
return filter, 0, 0, fmt.Errorf("min_vulnerabilities 不能小于 0")
|
||||
}
|
||||
filter.MinVulnerabilities = &value
|
||||
}
|
||||
if _, ok := args["max_vulnerabilities"]; ok {
|
||||
value := intArg(args, "max_vulnerabilities", -1)
|
||||
if value < 0 {
|
||||
return filter, 0, 0, fmt.Errorf("max_vulnerabilities 不能小于 0")
|
||||
}
|
||||
filter.MaxVulnerabilities = &value
|
||||
}
|
||||
if _, ok := args["scan_overdue_days"]; ok {
|
||||
value := intArg(args, "scan_overdue_days", 0)
|
||||
if value < 1 {
|
||||
return filter, 0, 0, fmt.Errorf("scan_overdue_days 必须大于 0")
|
||||
}
|
||||
filter.ScanOverdueDays = &value
|
||||
}
|
||||
var err error
|
||||
if filter.LastScanBefore, err = parseAssetToolTime("last_scan_before", strArg(args, "last_scan_before")); err != nil {
|
||||
return filter, 0, 0, err
|
||||
@@ -300,6 +344,12 @@ func assetFilterFromToolArgs(args map[string]interface{}) (database.AssetListFil
|
||||
if filter.LastScanAfter, err = parseAssetToolTime("last_scan_after", strArg(args, "last_scan_after")); err != nil {
|
||||
return filter, 0, 0, err
|
||||
}
|
||||
if filter.FirstSeenBefore, err = parseAssetToolTime("first_seen_before", strArg(args, "first_seen_before")); err != nil {
|
||||
return filter, 0, 0, err
|
||||
}
|
||||
if filter.FirstSeenAfter, err = parseAssetToolTime("first_seen_after", strArg(args, "first_seen_after")); err != nil {
|
||||
return filter, 0, 0, err
|
||||
}
|
||||
if filter.LastSeenBefore, err = parseAssetToolTime("last_seen_before", strArg(args, "last_seen_before")); err != nil {
|
||||
return filter, 0, 0, err
|
||||
}
|
||||
@@ -435,6 +485,8 @@ func assetToolDetail(asset *database.Asset) map[string]interface{} {
|
||||
"domain": truncateRunes(asset.Domain, 255), "protocol": truncateRunes(asset.Protocol, 50),
|
||||
"title": truncateRunes(asset.Title, 500), "server": truncateRunes(asset.Server, 500),
|
||||
"country": truncateRunes(asset.Country, 100), "province": truncateRunes(asset.Province, 100), "city": truncateRunes(asset.City, 100),
|
||||
"responsible_person": truncateRunes(asset.ResponsiblePerson, 255), "department": truncateRunes(asset.Department, 255),
|
||||
"business_system": truncateRunes(asset.BusinessSystem, 255), "environment": asset.Environment, "criticality": asset.Criticality,
|
||||
"source": truncateRunes(asset.Source, 100), "source_query": truncateRunes(asset.SourceQuery, 2000),
|
||||
"status": truncateRunes(asset.Status, 50), "tags": tags,
|
||||
"first_seen_at": asset.FirstSeenAt, "last_seen_at": asset.LastSeenAt, "created_at": asset.CreatedAt, "updated_at": asset.UpdatedAt,
|
||||
|
||||
+431
-43
@@ -31,6 +31,11 @@ type Asset struct {
|
||||
Country string `json:"country"`
|
||||
Province string `json:"province"`
|
||||
City string `json:"city"`
|
||||
ResponsiblePerson string `json:"responsible_person"`
|
||||
Department string `json:"department"`
|
||||
BusinessSystem string `json:"business_system"`
|
||||
Environment string `json:"environment"`
|
||||
Criticality string `json:"criticality"`
|
||||
Source string `json:"source"`
|
||||
SourceQuery string `json:"source_query"`
|
||||
Status string `json:"status"`
|
||||
@@ -49,23 +54,37 @@ type Asset struct {
|
||||
}
|
||||
|
||||
type AssetListFilter struct {
|
||||
Search string
|
||||
Status string
|
||||
Protocol string
|
||||
ProjectID string
|
||||
Source string
|
||||
Tag string
|
||||
Host string
|
||||
IP string
|
||||
Domain string
|
||||
Port *int
|
||||
ScanState string
|
||||
LastScanBefore *time.Time
|
||||
LastScanAfter *time.Time
|
||||
LastSeenBefore *time.Time
|
||||
LastSeenAfter *time.Time
|
||||
SortBy string
|
||||
SortOrder string
|
||||
Search string
|
||||
Status string
|
||||
Protocol string
|
||||
ProjectID string
|
||||
Source string
|
||||
Tag string
|
||||
Host string
|
||||
IP string
|
||||
Domain string
|
||||
Port *int
|
||||
RiskLevel string
|
||||
MinVulnerabilities *int
|
||||
MaxVulnerabilities *int
|
||||
Country string
|
||||
Province string
|
||||
City string
|
||||
ResponsiblePerson string
|
||||
Department string
|
||||
BusinessSystem string
|
||||
Environment string
|
||||
Criticality string
|
||||
ScanState string
|
||||
ScanOverdueDays *int
|
||||
LastScanBefore *time.Time
|
||||
LastScanAfter *time.Time
|
||||
FirstSeenBefore *time.Time
|
||||
FirstSeenAfter *time.Time
|
||||
LastSeenBefore *time.Time
|
||||
LastSeenAfter *time.Time
|
||||
SortBy string
|
||||
SortOrder string
|
||||
}
|
||||
|
||||
type AssetImportResult struct {
|
||||
@@ -84,6 +103,11 @@ func normalizeAsset(a *Asset) {
|
||||
a.Country = strings.TrimSpace(a.Country)
|
||||
a.Province = strings.TrimSpace(a.Province)
|
||||
a.City = strings.TrimSpace(a.City)
|
||||
a.ResponsiblePerson = strings.TrimSpace(a.ResponsiblePerson)
|
||||
a.Department = strings.TrimSpace(a.Department)
|
||||
a.BusinessSystem = strings.TrimSpace(a.BusinessSystem)
|
||||
a.Environment = strings.ToLower(strings.TrimSpace(a.Environment))
|
||||
a.Criticality = strings.ToLower(strings.TrimSpace(a.Criticality))
|
||||
a.Source = strings.TrimSpace(a.Source)
|
||||
a.SourceQuery = strings.TrimSpace(a.SourceQuery)
|
||||
a.ProjectID = strings.TrimSpace(a.ProjectID)
|
||||
@@ -192,6 +216,7 @@ func validateAsset(a *Asset) error {
|
||||
for name, value := range map[string]string{
|
||||
"Host": a.Host, "域名": a.Domain, "协议": a.Protocol, "页面标题": a.Title,
|
||||
"服务指纹": a.Server, "国家/地区": a.Country, "省份/州": a.Province, "城市": a.City,
|
||||
"负责人": a.ResponsiblePerson, "部门": a.Department, "业务系统": a.BusinessSystem,
|
||||
} {
|
||||
limit := 255
|
||||
if name == "Host" || name == "页面标题" {
|
||||
@@ -201,6 +226,12 @@ func validateAsset(a *Asset) error {
|
||||
return assetValidationErrorf("%s不能超过 %d 个字符", name, limit)
|
||||
}
|
||||
}
|
||||
if !oneOfAssetValue(a.Environment, "", "production", "staging", "testing", "development", "other") {
|
||||
return assetValidationErrorf("环境必须为 production、staging、testing、development 或 other")
|
||||
}
|
||||
if !oneOfAssetValue(a.Criticality, "", "critical", "high", "medium", "low") {
|
||||
return assetValidationErrorf("重要性必须为 critical、high、medium 或 low")
|
||||
}
|
||||
if len(a.Tags) > 30 {
|
||||
return assetValidationErrorf("标签不能超过 30 个")
|
||||
}
|
||||
@@ -212,6 +243,15 @@ func validateAsset(a *Asset) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func oneOfAssetValue(value string, allowed ...string) bool {
|
||||
for _, candidate := range allowed {
|
||||
if value == candidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validAssetDomain(domain string) bool {
|
||||
domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".")
|
||||
if domain == "" || len(domain) > 253 || net.ParseIP(domain) != nil {
|
||||
@@ -290,10 +330,12 @@ func (db *DB) UpsertAssets(assets []*Asset, ownerUserID string, allowGlobal ...b
|
||||
asset.FirstSeenAt, asset.LastSeenAt, asset.CreatedAt, asset.UpdatedAt = now, now, now, now
|
||||
_, err = tx.Exec(`INSERT INTO assets (
|
||||
id,dedup_key,project_id,host,ip,port,domain,protocol,title,server,country,province,city,source,source_query,status,tags_json,
|
||||
responsible_person,department,business_system,environment,criticality,
|
||||
first_seen_at,last_seen_at,created_at,updated_at,owner_user_id
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
asset.ID, key, nullIfEmpty(asset.ProjectID), asset.Host, asset.IP, asset.Port, asset.Domain, asset.Protocol, asset.Title, asset.Server,
|
||||
asset.Country, asset.Province, asset.City, asset.Source, asset.SourceQuery, asset.Status, string(tagsJSON),
|
||||
asset.ResponsiblePerson, asset.Department, asset.BusinessSystem, asset.Environment, asset.Criticality,
|
||||
now, now, now, now, nullIfEmpty(ownerUserID))
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("创建资产失败: %w", err)
|
||||
@@ -322,11 +364,18 @@ func (db *DB) UpsertAssets(assets []*Asset, ownerUserID string, allowGlobal ...b
|
||||
country=CASE WHEN ?<>'' THEN ? ELSE country END, province=CASE WHEN ?<>'' THEN ? ELSE province END,
|
||||
city=CASE WHEN ?<>'' THEN ? ELSE city END, source=CASE WHEN ?<>'' THEN ? ELSE source END,
|
||||
source_query=CASE WHEN ?<>'' THEN ? ELSE source_query END, project_id=CASE WHEN ?<>'' THEN ? ELSE project_id END,
|
||||
responsible_person=CASE WHEN ?<>'' THEN ? ELSE responsible_person END,
|
||||
department=CASE WHEN ?<>'' THEN ? ELSE department END,
|
||||
business_system=CASE WHEN ?<>'' THEN ? ELSE business_system END,
|
||||
environment=CASE WHEN ?<>'' THEN ? ELSE environment END,
|
||||
criticality=CASE WHEN ?<>'' THEN ? ELSE criticality END,
|
||||
tags_json=CASE WHEN ?<>'[]' THEN ? ELSE tags_json END,
|
||||
last_seen_at=?, updated_at=? WHERE id=?`,
|
||||
asset.Host, asset.Host, asset.IP, asset.IP, asset.Domain, asset.Domain, asset.Protocol, asset.Protocol,
|
||||
asset.Title, asset.Title, asset.Server, asset.Server, asset.Country, asset.Country, asset.Province, asset.Province,
|
||||
asset.City, asset.City, asset.Source, asset.Source, asset.SourceQuery, asset.SourceQuery, asset.ProjectID, nullIfEmpty(asset.ProjectID), string(tagsJSON), string(tagsJSON),
|
||||
asset.City, asset.City, asset.Source, asset.Source, asset.SourceQuery, asset.SourceQuery, asset.ProjectID, nullIfEmpty(asset.ProjectID),
|
||||
asset.ResponsiblePerson, asset.ResponsiblePerson, asset.Department, asset.Department, asset.BusinessSystem, asset.BusinessSystem,
|
||||
asset.Environment, asset.Environment, asset.Criticality, asset.Criticality, string(tagsJSON), string(tagsJSON),
|
||||
now, now, existingID)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("更新资产失败: %w", err)
|
||||
@@ -349,18 +398,19 @@ func assetWhere(filter AssetListFilter, access RBACListAccess) (string, []interf
|
||||
args := []interface{}{}
|
||||
if q := strings.TrimSpace(filter.Search); q != "" {
|
||||
pattern := "%" + escapeAssetLike(strings.ToLower(q)) + "%"
|
||||
query += ` AND (LOWER(host) LIKE ? ESCAPE '\' OR LOWER(ip) LIKE ? ESCAPE '\' OR LOWER(domain) LIKE ? ESCAPE '\'
|
||||
OR LOWER(title) LIKE ? ESCAPE '\' OR LOWER(server) LIKE ? ESCAPE '\' OR LOWER(tags_json) LIKE ? ESCAPE '\')`
|
||||
for i := 0; i < 6; i++ {
|
||||
query += ` AND (LOWER(assets.host) LIKE ? ESCAPE '\' OR LOWER(assets.ip) LIKE ? ESCAPE '\' OR LOWER(assets.domain) LIKE ? ESCAPE '\'
|
||||
OR LOWER(assets.title) LIKE ? ESCAPE '\' OR LOWER(assets.server) LIKE ? ESCAPE '\' OR LOWER(assets.tags_json) LIKE ? ESCAPE '\'
|
||||
OR LOWER(assets.responsible_person) LIKE ? ESCAPE '\' OR LOWER(assets.department) LIKE ? ESCAPE '\' OR LOWER(assets.business_system) LIKE ? ESCAPE '\')`
|
||||
for i := 0; i < 9; i++ {
|
||||
args = append(args, pattern)
|
||||
}
|
||||
}
|
||||
if filter.Status != "" {
|
||||
query += " AND status = ?"
|
||||
query += " AND assets.status = ?"
|
||||
args = append(args, filter.Status)
|
||||
}
|
||||
if filter.Protocol != "" {
|
||||
query += " AND protocol = ?"
|
||||
query += " AND assets.protocol = ?"
|
||||
args = append(args, filter.Protocol)
|
||||
}
|
||||
if filter.ProjectID != "" {
|
||||
@@ -392,12 +442,41 @@ func assetWhere(filter AssetListFilter, access RBACListAccess) (string, []interf
|
||||
query += " AND assets.port = ?"
|
||||
args = append(args, *filter.Port)
|
||||
}
|
||||
if filter.RiskLevel != "" {
|
||||
query += " AND " + assetRiskLevelExpr + " = ?"
|
||||
args = append(args, strings.ToLower(strings.TrimSpace(filter.RiskLevel)))
|
||||
}
|
||||
if filter.MinVulnerabilities != nil {
|
||||
query += " AND " + assetVulnerabilityCountExpr + " >= ?"
|
||||
args = append(args, *filter.MinVulnerabilities)
|
||||
}
|
||||
if filter.MaxVulnerabilities != nil {
|
||||
query += " AND " + assetVulnerabilityCountExpr + " <= ?"
|
||||
args = append(args, *filter.MaxVulnerabilities)
|
||||
}
|
||||
for _, item := range []struct {
|
||||
column string
|
||||
value string
|
||||
}{
|
||||
{"assets.country", filter.Country}, {"assets.province", filter.Province}, {"assets.city", filter.City},
|
||||
{"assets.responsible_person", filter.ResponsiblePerson}, {"assets.department", filter.Department},
|
||||
{"assets.business_system", filter.BusinessSystem}, {"assets.environment", filter.Environment}, {"assets.criticality", filter.Criticality},
|
||||
} {
|
||||
if strings.TrimSpace(item.value) != "" {
|
||||
query += " AND LOWER(" + item.column + ") = LOWER(?)"
|
||||
args = append(args, strings.TrimSpace(item.value))
|
||||
}
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(filter.ScanState)) {
|
||||
case "never":
|
||||
query += " AND " + assetEffectiveLastScanExpr + " IS NULL"
|
||||
case "scanned":
|
||||
query += " AND " + assetEffectiveLastScanExpr + " IS NOT NULL"
|
||||
}
|
||||
if filter.ScanOverdueDays != nil {
|
||||
query += " AND (" + assetEffectiveLastScanExpr + " IS NULL OR datetime(" + assetEffectiveLastScanExpr + ") < datetime('now', ?))"
|
||||
args = append(args, fmt.Sprintf("-%d days", *filter.ScanOverdueDays))
|
||||
}
|
||||
if filter.LastScanBefore != nil {
|
||||
query += " AND " + assetEffectiveLastScanExpr + " < ?"
|
||||
args = append(args, *filter.LastScanBefore)
|
||||
@@ -406,6 +485,14 @@ func assetWhere(filter AssetListFilter, access RBACListAccess) (string, []interf
|
||||
query += " AND " + assetEffectiveLastScanExpr + " > ?"
|
||||
args = append(args, *filter.LastScanAfter)
|
||||
}
|
||||
if filter.FirstSeenBefore != nil {
|
||||
query += " AND assets.first_seen_at < ?"
|
||||
args = append(args, *filter.FirstSeenBefore)
|
||||
}
|
||||
if filter.FirstSeenAfter != nil {
|
||||
query += " AND assets.first_seen_at > ?"
|
||||
args = append(args, *filter.FirstSeenAfter)
|
||||
}
|
||||
if filter.LastSeenBefore != nil {
|
||||
query += " AND assets.last_seen_at < ?"
|
||||
args = append(args, *filter.LastSeenBefore)
|
||||
@@ -428,7 +515,8 @@ func scanAsset(scanner interface{ Scan(...interface{}) error }) (*Asset, error)
|
||||
var tags string
|
||||
var lastScanAt interface{}
|
||||
err := scanner.Scan(&a.ID, &a.ProjectID, &a.ProjectName, &a.Host, &a.IP, &a.Port, &a.Domain, &a.Protocol, &a.Title, &a.Server, &a.Country,
|
||||
&a.Province, &a.City, &a.Source, &a.SourceQuery, &a.Status, &tags, &a.FirstSeenAt, &a.LastSeenAt, &a.CreatedAt, &a.UpdatedAt,
|
||||
&a.Province, &a.City, &a.ResponsiblePerson, &a.Department, &a.BusinessSystem, &a.Environment, &a.Criticality,
|
||||
&a.Source, &a.SourceQuery, &a.Status, &tags, &a.FirstSeenAt, &a.LastSeenAt, &a.CreatedAt, &a.UpdatedAt,
|
||||
&lastScanAt, &a.LastScanConversationID, &a.LastScanQueueID, &a.LastScanTaskID, &a.VulnerabilityCount, &a.RiskLevel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -476,20 +564,29 @@ const assetEffectiveLastScanExpr = `COALESCE(
|
||||
assets.last_scan_at
|
||||
)`
|
||||
|
||||
const assetVulnerabilityMatchExpr = `(
|
||||
(COALESCE(assets.last_scan_conversation_id,'')<>'' AND v.conversation_id=assets.last_scan_conversation_id)
|
||||
OR (COALESCE(assets.last_scan_task_id,'')<>'' AND EXISTS (
|
||||
SELECT 1 FROM batch_tasks bt WHERE bt.id=assets.last_scan_task_id AND bt.conversation_id=v.conversation_id
|
||||
))
|
||||
)`
|
||||
|
||||
const assetVulnerabilityCountExpr = `(SELECT COUNT(DISTINCT v.id) FROM vulnerabilities v WHERE ` + assetVulnerabilityMatchExpr + `)`
|
||||
|
||||
const assetRiskScoreExpr = `COALESCE((
|
||||
SELECT MAX(CASE LOWER(COALESCE(v.severity,'')) WHEN 'critical' THEN 5 WHEN 'high' THEN 4 WHEN 'medium' THEN 3 WHEN 'low' THEN 2 WHEN 'info' THEN 1 ELSE 0 END)
|
||||
FROM vulnerabilities v
|
||||
WHERE LOWER(COALESCE(v.status,'open')) NOT IN ('fixed','false_positive','ignored') AND ` + assetVulnerabilityMatchExpr + `
|
||||
),0)`
|
||||
|
||||
const assetRiskLevelExpr = `(CASE WHEN ` + assetEffectiveLastScanExpr + ` IS NULL THEN 'unassessed' ELSE CASE ` + assetRiskScoreExpr + `
|
||||
WHEN 5 THEN 'critical' WHEN 4 THEN 'high' WHEN 3 THEN 'medium' WHEN 2 THEN 'low' WHEN 1 THEN 'info' ELSE 'normal' END END)`
|
||||
|
||||
const assetSelectColumns = `assets.id,COALESCE(assets.project_id,''),COALESCE(p.name,''),assets.host,assets.ip,assets.port,assets.domain,assets.protocol,assets.title,assets.server,assets.country,
|
||||
assets.province,assets.city,assets.source,assets.source_query,assets.status,assets.tags_json,assets.first_seen_at,assets.last_seen_at,assets.created_at,assets.updated_at,
|
||||
assets.province,assets.city,assets.responsible_person,assets.department,assets.business_system,assets.environment,assets.criticality,
|
||||
assets.source,assets.source_query,assets.status,assets.tags_json,assets.first_seen_at,assets.last_seen_at,assets.created_at,assets.updated_at,
|
||||
` + assetEffectiveLastScanExpr + `,COALESCE(assets.last_scan_conversation_id,''),COALESCE(assets.last_scan_queue_id,''),COALESCE(assets.last_scan_task_id,''),
|
||||
(SELECT COUNT(DISTINCT v.id) FROM vulnerabilities v WHERE
|
||||
(COALESCE(assets.last_scan_conversation_id,'')<>'' AND v.conversation_id=assets.last_scan_conversation_id)
|
||||
OR (COALESCE(assets.last_scan_task_id,'')<>'' AND EXISTS (SELECT 1 FROM batch_tasks bt WHERE bt.id=assets.last_scan_task_id AND bt.conversation_id=v.conversation_id))),
|
||||
CASE WHEN assets.last_scan_at IS NULL THEN 'unassessed' ELSE CASE COALESCE((
|
||||
SELECT MAX(CASE LOWER(COALESCE(v.severity,'')) WHEN 'critical' THEN 5 WHEN 'high' THEN 4 WHEN 'medium' THEN 3 WHEN 'low' THEN 2 WHEN 'info' THEN 1 ELSE 0 END)
|
||||
FROM vulnerabilities v WHERE
|
||||
LOWER(COALESCE(v.status,'open')) NOT IN ('fixed','false_positive','ignored') AND (
|
||||
(COALESCE(assets.last_scan_conversation_id,'')<>'' AND v.conversation_id=assets.last_scan_conversation_id)
|
||||
OR (COALESCE(assets.last_scan_task_id,'')<>'' AND EXISTS (SELECT 1 FROM batch_tasks bt WHERE bt.id=assets.last_scan_task_id AND bt.conversation_id=v.conversation_id))
|
||||
)
|
||||
),0) WHEN 5 THEN 'critical' WHEN 4 THEN 'high' WHEN 3 THEN 'medium' WHEN 2 THEN 'low' WHEN 1 THEN 'info' ELSE 'normal' END END`
|
||||
` + assetVulnerabilityCountExpr + `,` + assetRiskLevelExpr
|
||||
|
||||
// MarkAssetScanned links an asset to the conversation or batch subtask created from it.
|
||||
// The link lets the asset list show the latest scan time and vulnerabilities produced by that scan.
|
||||
@@ -573,6 +670,36 @@ func (db *DB) ListAssets(limit, offset int, filter AssetListFilter, access RBACL
|
||||
return items, total, rows.Err()
|
||||
}
|
||||
|
||||
// ListAssetsForOperation resolves the complete filtered selection used by
|
||||
// cross-page bulk actions. The caller supplies a strict upper bound.
|
||||
func (db *DB) ListAssetsForOperation(limit int, filter AssetListFilter, access RBACListAccess) ([]*Asset, int, error) {
|
||||
if limit < 1 || limit > 10000 {
|
||||
limit = 10000
|
||||
}
|
||||
where, args := assetWhere(filter, access)
|
||||
var total int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM assets"+where, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if total > limit {
|
||||
return nil, total, fmt.Errorf("匹配资产超过 %d 条,请缩小筛选范围", limit)
|
||||
}
|
||||
rows, err := db.Query("SELECT "+assetSelectColumns+" FROM assets LEFT JOIN projects p ON p.id=assets.project_id"+where+" ORDER BY "+assetOrderBy(filter.SortBy, filter.SortOrder), args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]*Asset, 0, total)
|
||||
for rows.Next() {
|
||||
item, err := scanAsset(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, total, rows.Err()
|
||||
}
|
||||
|
||||
func assetOrderBy(sortBy, sortOrder string) string {
|
||||
direction := "DESC"
|
||||
if strings.EqualFold(strings.TrimSpace(sortOrder), "asc") {
|
||||
@@ -598,6 +725,10 @@ func assetOrderBy(sortBy, sortOrder string) string {
|
||||
expression = "LOWER(assets.host)"
|
||||
case "port":
|
||||
expression = "assets.port"
|
||||
case "vulnerability_count":
|
||||
expression = assetVulnerabilityCountExpr
|
||||
case "risk_level":
|
||||
expression = assetRiskScoreExpr
|
||||
default:
|
||||
expression = "assets.last_seen_at"
|
||||
}
|
||||
@@ -620,8 +751,10 @@ func (db *DB) UpdateAsset(id string, a *Asset, access RBACListAccess) error {
|
||||
}
|
||||
tags, _ := json.Marshal(a.Tags)
|
||||
where, args := appendAssetAccess(" WHERE id = ?", []interface{}{id}, access, "assets")
|
||||
res, err := db.Exec(`UPDATE assets SET dedup_key=?,project_id=?,host=?,ip=?,port=?,domain=?,protocol=?,title=?,server=?,country=?,province=?,city=?,source=?,source_query=?,status=?,tags_json=?,updated_at=?`+where,
|
||||
append([]interface{}{key, nullIfEmpty(a.ProjectID), a.Host, a.IP, a.Port, a.Domain, a.Protocol, a.Title, a.Server, a.Country, a.Province, a.City, a.Source, a.SourceQuery, a.Status, string(tags), time.Now()}, args...)...)
|
||||
res, err := db.Exec(`UPDATE assets SET dedup_key=?,project_id=?,host=?,ip=?,port=?,domain=?,protocol=?,title=?,server=?,country=?,province=?,city=?,
|
||||
responsible_person=?,department=?,business_system=?,environment=?,criticality=?,source=?,source_query=?,status=?,tags_json=?,updated_at=?`+where,
|
||||
append([]interface{}{key, nullIfEmpty(a.ProjectID), a.Host, a.IP, a.Port, a.Domain, a.Protocol, a.Title, a.Server, a.Country, a.Province, a.City,
|
||||
a.ResponsiblePerson, a.Department, a.BusinessSystem, a.Environment, a.Criticality, a.Source, a.SourceQuery, a.Status, string(tags), time.Now()}, args...)...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -632,10 +765,18 @@ func (db *DB) UpdateAsset(id string, a *Asset, access RBACListAccess) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateAssetsProject atomically replaces the project binding for every asset.
|
||||
// It refuses the whole update when any requested asset is missing or outside
|
||||
// the caller's access scope, so a bulk action can never partially succeed.
|
||||
func (db *DB) UpdateAssetsProject(ids []string, projectID string, access RBACListAccess) (int, error) {
|
||||
type AssetBulkPatch struct {
|
||||
Status *string
|
||||
ResponsiblePerson *string
|
||||
Department *string
|
||||
BusinessSystem *string
|
||||
Environment *string
|
||||
Criticality *string
|
||||
AddTags []string
|
||||
RemoveTags []string
|
||||
}
|
||||
|
||||
func normalizeAssetIDs(ids []string) []string {
|
||||
unique := make([]string, 0, len(ids))
|
||||
seen := make(map[string]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
@@ -649,6 +790,253 @@ func (db *DB) UpdateAssetsProject(ids []string, projectID string, access RBACLis
|
||||
seen[id] = struct{}{}
|
||||
unique = append(unique, id)
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
func normalizeBulkTags(tags []string) ([]string, error) {
|
||||
seen := map[string]struct{}{}
|
||||
result := make([]string, 0, len(tags))
|
||||
for _, tag := range tags {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
if utf8.RuneCountInString(tag) > 64 {
|
||||
return nil, assetValidationErrorf("单个标签不能超过 64 个字符")
|
||||
}
|
||||
if _, exists := seen[tag]; exists {
|
||||
continue
|
||||
}
|
||||
seen[tag] = struct{}{}
|
||||
result = append(result, tag)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// UpdateAssetsBulk atomically applies operational metadata to a selected set.
|
||||
func (db *DB) UpdateAssetsBulk(ids []string, patch AssetBulkPatch, access RBACListAccess) (int, error) {
|
||||
unique := normalizeAssetIDs(ids)
|
||||
if len(unique) == 0 {
|
||||
return 0, fmt.Errorf("资产列表不能为空")
|
||||
}
|
||||
if patch.Status != nil {
|
||||
value := strings.ToLower(strings.TrimSpace(*patch.Status))
|
||||
if value != "active" && value != "inactive" {
|
||||
return 0, assetValidationErrorf("资产状态必须为 active 或 inactive")
|
||||
}
|
||||
patch.Status = &value
|
||||
}
|
||||
if patch.Environment != nil {
|
||||
value := strings.ToLower(strings.TrimSpace(*patch.Environment))
|
||||
if !oneOfAssetValue(value, "", "production", "staging", "testing", "development", "other") {
|
||||
return 0, assetValidationErrorf("环境值无效")
|
||||
}
|
||||
patch.Environment = &value
|
||||
}
|
||||
if patch.Criticality != nil {
|
||||
value := strings.ToLower(strings.TrimSpace(*patch.Criticality))
|
||||
if !oneOfAssetValue(value, "", "critical", "high", "medium", "low") {
|
||||
return 0, assetValidationErrorf("重要性值无效")
|
||||
}
|
||||
patch.Criticality = &value
|
||||
}
|
||||
var err error
|
||||
if patch.AddTags, err = normalizeBulkTags(patch.AddTags); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if patch.RemoveTags, err = normalizeBulkTags(patch.RemoveTags); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(unique)), ",")
|
||||
idArgs := make([]interface{}, len(unique))
|
||||
for i, id := range unique {
|
||||
idArgs[i] = id
|
||||
}
|
||||
countQuery, countArgs := appendAssetAccess("SELECT COUNT(*) FROM assets WHERE id IN ("+placeholders+")", idArgs, access, "assets")
|
||||
var accessible int
|
||||
if err := tx.QueryRow(countQuery, countArgs...).Scan(&accessible); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if accessible != len(unique) {
|
||||
return 0, fmt.Errorf("部分资产不存在或无权更新")
|
||||
}
|
||||
|
||||
for _, id := range unique {
|
||||
var rawTags string
|
||||
if err := tx.QueryRow("SELECT tags_json FROM assets WHERE id=?", id).Scan(&rawTags); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
tags := []string{}
|
||||
_ = json.Unmarshal([]byte(rawTags), &tags)
|
||||
remove := map[string]struct{}{}
|
||||
for _, tag := range patch.RemoveTags {
|
||||
remove[tag] = struct{}{}
|
||||
}
|
||||
merged := make([]string, 0, len(tags)+len(patch.AddTags))
|
||||
seen := map[string]struct{}{}
|
||||
for _, tag := range append(tags, patch.AddTags...) {
|
||||
if _, removed := remove[tag]; removed {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[tag]; exists {
|
||||
continue
|
||||
}
|
||||
seen[tag] = struct{}{}
|
||||
merged = append(merged, tag)
|
||||
}
|
||||
if len(merged) > 30 {
|
||||
return 0, assetValidationErrorf("批量修改后标签不能超过 30 个")
|
||||
}
|
||||
tagsJSON, _ := json.Marshal(merged)
|
||||
_, err := tx.Exec(`UPDATE assets SET
|
||||
status=CASE WHEN ? THEN ? ELSE status END,
|
||||
responsible_person=CASE WHEN ? THEN ? ELSE responsible_person END,
|
||||
department=CASE WHEN ? THEN ? ELSE department END,
|
||||
business_system=CASE WHEN ? THEN ? ELSE business_system END,
|
||||
environment=CASE WHEN ? THEN ? ELSE environment END,
|
||||
criticality=CASE WHEN ? THEN ? ELSE criticality END,
|
||||
tags_json=?,updated_at=? WHERE id=?`,
|
||||
patch.Status != nil, valueOrEmpty(patch.Status),
|
||||
patch.ResponsiblePerson != nil, valueOrEmpty(patch.ResponsiblePerson),
|
||||
patch.Department != nil, valueOrEmpty(patch.Department),
|
||||
patch.BusinessSystem != nil, valueOrEmpty(patch.BusinessSystem),
|
||||
patch.Environment != nil, valueOrEmpty(patch.Environment),
|
||||
patch.Criticality != nil, valueOrEmpty(patch.Criticality),
|
||||
string(tagsJSON), time.Now(), id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(unique), nil
|
||||
}
|
||||
|
||||
func valueOrEmpty(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(*value)
|
||||
}
|
||||
|
||||
func (db *DB) DeleteAssets(ids []string, access RBACListAccess) (int, error) {
|
||||
unique := normalizeAssetIDs(ids)
|
||||
if len(unique) == 0 {
|
||||
return 0, fmt.Errorf("资产列表不能为空")
|
||||
}
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(unique)), ",")
|
||||
args := make([]interface{}, len(unique))
|
||||
for i, id := range unique {
|
||||
args[i] = id
|
||||
}
|
||||
countQuery, countArgs := appendAssetAccess("SELECT COUNT(*) FROM assets WHERE id IN ("+placeholders+")", args, access, "assets")
|
||||
var accessible int
|
||||
if err := tx.QueryRow(countQuery, countArgs...).Scan(&accessible); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if accessible != len(unique) {
|
||||
return 0, fmt.Errorf("部分资产不存在或无权删除")
|
||||
}
|
||||
deleteQuery, deleteArgs := appendAssetAccess("DELETE FROM assets WHERE id IN ("+placeholders+")", args, access, "assets")
|
||||
result, err := tx.Exec(deleteQuery, deleteArgs...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
deleted, err := result.RowsAffected()
|
||||
if err != nil || int(deleted) != len(unique) {
|
||||
return 0, fmt.Errorf("批量删除资产失败")
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(deleted), nil
|
||||
}
|
||||
|
||||
// MergeAssets atomically updates the surviving asset and removes duplicates.
|
||||
// Separate access scopes preserve permission-specific RBAC boundaries.
|
||||
func (db *DB) MergeAssets(primary *Asset, duplicateIDs []string, writeAccess, deleteAccess RBACListAccess) (int, error) {
|
||||
if primary == nil || strings.TrimSpace(primary.ID) == "" {
|
||||
return 0, fmt.Errorf("主资产不能为空")
|
||||
}
|
||||
normalizeAsset(primary)
|
||||
if err := validateAsset(primary); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
duplicates := normalizeAssetIDs(duplicateIDs)
|
||||
filtered := duplicates[:0]
|
||||
for _, id := range duplicates {
|
||||
if id != primary.ID {
|
||||
filtered = append(filtered, id)
|
||||
}
|
||||
}
|
||||
duplicates = filtered
|
||||
if len(duplicates) == 0 {
|
||||
return 0, fmt.Errorf("重复资产列表不能为空")
|
||||
}
|
||||
key := assetDedupKey(primary)
|
||||
tagsJSON, _ := json.Marshal(primary.Tags)
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
primaryQuery, primaryArgs := appendAssetAccess("SELECT COUNT(*) FROM assets WHERE id=?", []interface{}{primary.ID}, writeAccess, "assets")
|
||||
var primaryCount int
|
||||
if err := tx.QueryRow(primaryQuery, primaryArgs...).Scan(&primaryCount); err != nil || primaryCount != 1 {
|
||||
return 0, fmt.Errorf("主资产不存在或无权更新")
|
||||
}
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(duplicates)), ",")
|
||||
deleteArgs := make([]interface{}, len(duplicates))
|
||||
for i, id := range duplicates {
|
||||
deleteArgs[i] = id
|
||||
}
|
||||
countQuery, countArgs := appendAssetAccess("SELECT COUNT(*) FROM assets WHERE id IN ("+placeholders+")", deleteArgs, deleteAccess, "assets")
|
||||
var accessible int
|
||||
if err := tx.QueryRow(countQuery, countArgs...).Scan(&accessible); err != nil || accessible != len(duplicates) {
|
||||
return 0, fmt.Errorf("部分重复资产不存在或无权删除")
|
||||
}
|
||||
deleteQuery, scopedDeleteArgs := appendAssetAccess("DELETE FROM assets WHERE id IN ("+placeholders+")", deleteArgs, deleteAccess, "assets")
|
||||
if result, err := tx.Exec(deleteQuery, scopedDeleteArgs...); err != nil {
|
||||
return 0, err
|
||||
} else if deleted, _ := result.RowsAffected(); int(deleted) != len(duplicates) {
|
||||
return 0, fmt.Errorf("删除重复资产失败")
|
||||
}
|
||||
updateQuery, updateScopeArgs := appendAssetAccess(`UPDATE assets SET dedup_key=?,project_id=?,host=?,ip=?,port=?,domain=?,protocol=?,title=?,server=?,country=?,province=?,city=?,
|
||||
responsible_person=?,department=?,business_system=?,environment=?,criticality=?,source=?,source_query=?,status=?,tags_json=?,updated_at=? WHERE id=?`,
|
||||
[]interface{}{key, nullIfEmpty(primary.ProjectID), primary.Host, primary.IP, primary.Port, primary.Domain, primary.Protocol, primary.Title, primary.Server,
|
||||
primary.Country, primary.Province, primary.City, primary.ResponsiblePerson, primary.Department, primary.BusinessSystem, primary.Environment,
|
||||
primary.Criticality, primary.Source, primary.SourceQuery, primary.Status, string(tagsJSON), time.Now(), primary.ID}, writeAccess, "assets")
|
||||
result, err := tx.Exec(updateQuery, updateScopeArgs...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if updated, _ := result.RowsAffected(); updated != 1 {
|
||||
return 0, fmt.Errorf("更新主资产失败")
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(duplicates), nil
|
||||
}
|
||||
|
||||
// UpdateAssetsProject atomically replaces the project binding for every asset.
|
||||
// It refuses the whole update when any requested asset is missing or outside
|
||||
// the caller's access scope, so a bulk action can never partially succeed.
|
||||
func (db *DB) UpdateAssetsProject(ids []string, projectID string, access RBACListAccess) (int, error) {
|
||||
unique := normalizeAssetIDs(ids)
|
||||
if len(unique) == 0 {
|
||||
return 0, fmt.Errorf("资产列表不能为空")
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -224,6 +225,133 @@ func TestUpdateAssetsProjectIsAtomicAndScoped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetAdvancedFiltersAndBulkMetadata(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "asset-advanced.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
project, err := db.CreateProject(&Project{Name: "Production", Status: "active"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := []*Asset{
|
||||
{ProjectID: project.ID, Domain: "critical.example.com", Port: 443, Protocol: "https", Country: "CN", ResponsiblePerson: "Alice", Department: "Security", BusinessSystem: "Portal", Environment: "production", Criticality: "critical", Tags: []string{"internet"}},
|
||||
{ProjectID: project.ID, Domain: "dev.example.com", Port: 8080, Protocol: "http", Country: "US", Environment: "development", Criticality: "low"},
|
||||
}
|
||||
if result, err := db.UpsertAssets(input, "", true); err != nil || result.Created != 2 {
|
||||
t.Fatalf("create assets: result=%#v err=%v", result, err)
|
||||
}
|
||||
conversation, err := db.CreateConversation("critical scan", ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.MarkAssetScanned(input[0].ID, conversation.ID, "", "", RBACListAccess{Scope: RBACScopeAll}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.CreateVulnerability(&Vulnerability{ConversationID: conversation.ID, Title: "critical finding", Severity: "critical", Target: input[0].Domain}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
minVulns := 1
|
||||
items, total, err := db.ListAssets(20, 0, AssetListFilter{
|
||||
Status: "active", RiskLevel: "critical", MinVulnerabilities: &minVulns,
|
||||
Country: "cn", Environment: "production", Criticality: "critical",
|
||||
SortBy: "vulnerability_count", SortOrder: "desc",
|
||||
}, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || total != 1 || len(items) != 1 {
|
||||
t.Fatalf("advanced query: total=%d items=%#v err=%v", total, items, err)
|
||||
}
|
||||
if items[0].ResponsiblePerson != "Alice" || items[0].BusinessSystem != "Portal" || items[0].VulnerabilityCount != 1 {
|
||||
t.Fatalf("metadata did not round-trip: %#v", items[0])
|
||||
}
|
||||
|
||||
status := "inactive"
|
||||
owner := "Bob"
|
||||
environment := "staging"
|
||||
updated, err := db.UpdateAssetsBulk([]string{input[0].ID, input[1].ID}, AssetBulkPatch{
|
||||
Status: &status, ResponsiblePerson: &owner, Environment: &environment,
|
||||
AddTags: []string{"review"}, RemoveTags: []string{"internet"},
|
||||
}, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || updated != 2 {
|
||||
t.Fatalf("bulk update: updated=%d err=%v", updated, err)
|
||||
}
|
||||
for _, id := range []string{input[0].ID, input[1].ID} {
|
||||
item, err := db.GetAsset(id, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if item.Status != "inactive" || item.ResponsiblePerson != "Bob" || item.Environment != "staging" || len(item.Tags) != 1 || item.Tags[0] != "review" {
|
||||
t.Fatalf("unexpected bulk metadata: %#v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAssetsForOperationAndBatchDelete(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "asset-selection.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
for i := 1; i <= 3; i++ {
|
||||
if _, err := db.UpsertAssets([]*Asset{{IP: "198.51.100." + strconv.Itoa(i), Port: 443, Protocol: "https", Tags: []string{"selected"}}}, "", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
items, total, err := db.ListAssetsForOperation(10, AssetListFilter{Tag: "selected"}, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || total != 3 || len(items) != 3 {
|
||||
t.Fatalf("selection: total=%d len=%d err=%v", total, len(items), err)
|
||||
}
|
||||
ids := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
ids = append(ids, item.ID)
|
||||
}
|
||||
deleted, err := db.DeleteAssets(ids, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || deleted != 3 {
|
||||
t.Fatalf("batch delete: deleted=%d err=%v", deleted, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeAssetsIsAtomic(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "asset-merge.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
input := []*Asset{
|
||||
{Domain: "merge.example.com", Port: 80, Protocol: "http", Title: "Primary", Tags: []string{"one"}},
|
||||
{Domain: "merge.example.com", Port: 443, Protocol: "https", ResponsiblePerson: "Alice", Tags: []string{"two"}},
|
||||
}
|
||||
if _, err := db.UpsertAssets(input, "", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
primary, err := db.GetAsset(input[0].ID, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
primary.ResponsiblePerson = "Alice"
|
||||
primary.Tags = []string{"one", "two"}
|
||||
merged, err := db.MergeAssets(primary, []string{input[1].ID}, RBACListAccess{Scope: RBACScopeAll}, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || merged != 1 {
|
||||
t.Fatalf("merge: merged=%d err=%v", merged, err)
|
||||
}
|
||||
items, total, err := db.ListAssets(10, 0, AssetListFilter{}, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || total != 1 || len(items) != 1 || items[0].ResponsiblePerson != "Alice" || len(items[0].Tags) != 2 {
|
||||
t.Fatalf("unexpected merged asset: total=%d items=%#v err=%v", total, items, err)
|
||||
}
|
||||
|
||||
before := items[0].Title
|
||||
items[0].Title = "Must roll back"
|
||||
if _, err := db.MergeAssets(items[0], []string{"missing"}, RBACListAccess{Scope: RBACScopeAll}, RBACListAccess{Scope: RBACScopeAll}); err == nil {
|
||||
t.Fatal("merge with missing duplicate unexpectedly succeeded")
|
||||
}
|
||||
after, err := db.GetAsset(items[0].ID, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || after.Title != before {
|
||||
t.Fatalf("failed merge was not atomic: asset=%#v err=%v", after, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetScanLinkReturnsTimeAndRelatedVulnerabilities(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "asset-scan.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
|
||||
@@ -412,6 +412,8 @@ func (db *DB) initTables() error {
|
||||
host TEXT NOT NULL DEFAULT '', ip TEXT NOT NULL DEFAULT '', port INTEGER NOT NULL DEFAULT 0,
|
||||
domain TEXT NOT NULL DEFAULT '', protocol TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '',
|
||||
server TEXT NOT NULL DEFAULT '', country TEXT NOT NULL DEFAULT '', province TEXT NOT NULL DEFAULT '', city TEXT NOT NULL DEFAULT '',
|
||||
responsible_person TEXT NOT NULL DEFAULT '', department TEXT NOT NULL DEFAULT '', business_system TEXT NOT NULL DEFAULT '',
|
||||
environment TEXT NOT NULL DEFAULT '', criticality TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT 'manual', source_query TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'active',
|
||||
tags_json TEXT NOT NULL DEFAULT '[]', first_seen_at DATETIME NOT NULL, last_seen_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, owner_user_id TEXT,
|
||||
@@ -987,6 +989,11 @@ func (db *DB) migrateAssetsTable() error {
|
||||
{"last_scan_conversation_id", "ALTER TABLE assets ADD COLUMN last_scan_conversation_id TEXT NOT NULL DEFAULT ''"},
|
||||
{"last_scan_queue_id", "ALTER TABLE assets ADD COLUMN last_scan_queue_id TEXT NOT NULL DEFAULT ''"},
|
||||
{"last_scan_task_id", "ALTER TABLE assets ADD COLUMN last_scan_task_id TEXT NOT NULL DEFAULT ''"},
|
||||
{"responsible_person", "ALTER TABLE assets ADD COLUMN responsible_person TEXT NOT NULL DEFAULT ''"},
|
||||
{"department", "ALTER TABLE assets ADD COLUMN department TEXT NOT NULL DEFAULT ''"},
|
||||
{"business_system", "ALTER TABLE assets ADD COLUMN business_system TEXT NOT NULL DEFAULT ''"},
|
||||
{"environment", "ALTER TABLE assets ADD COLUMN environment TEXT NOT NULL DEFAULT ''"},
|
||||
{"criticality", "ALTER TABLE assets ADD COLUMN criticality TEXT NOT NULL DEFAULT ''"},
|
||||
}
|
||||
for _, column := range columns {
|
||||
var count int
|
||||
|
||||
Reference in New Issue
Block a user