diff --git a/internal/app/app.go b/internal/app/app.go index d0ef977e..ad4764bc 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) diff --git a/internal/app/asset_tools.go b/internal/app/asset_tools.go index e299ceeb..611976d7 100644 --- a/internal/app/asset_tools.go +++ b/internal/app/asset_tools.go @@ -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, diff --git a/internal/database/asset.go b/internal/database/asset.go index c90e78d1..b42184b0 100644 --- a/internal/database/asset.go +++ b/internal/database/asset.go @@ -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("资产列表不能为空") } diff --git a/internal/database/asset_test.go b/internal/database/asset_test.go index 8390c24e..c2929a0f 100644 --- a/internal/database/asset_test.go +++ b/internal/database/asset_test.go @@ -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 { diff --git a/internal/database/database.go b/internal/database/database.go index 713a1df2..bba8db64 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -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