Add files via upload

This commit is contained in:
公明
2026-07-17 17:11:16 +08:00
committed by GitHub
parent 1923f7710a
commit 1f3472955c
8 changed files with 594 additions and 31 deletions
+241 -6
View File
@@ -18,6 +18,11 @@ type AssetHandler struct {
logger *zap.Logger
}
const (
maxAssetImportBatch = 100000
maxAssetOperationBatch = 10000
)
func NewAssetHandler(db *database.DB, logger *zap.Logger) *AssetHandler {
return &AssetHandler{db: db, logger: logger}
}
@@ -29,6 +34,13 @@ func assetAccess(c *gin.Context) database.RBACListAccess {
return database.RBACListAccess{}
}
func assetAccessForPermission(c *gin.Context, permission string) database.RBACListAccess {
if session, ok := security.CurrentSession(c); ok {
return database.RBACListAccess{UserID: session.UserID, Scope: session.ScopeFor(permission)}
}
return database.RBACListAccess{}
}
type importAssetsRequest struct {
Assets []*database.Asset `json:"assets" binding:"required"`
Source string `json:"source"`
@@ -51,14 +63,35 @@ type updateAssetsProjectRequest struct {
ProjectID string `json:"project_id"`
}
type bulkUpdateAssetsRequest struct {
AssetIDs []string `json:"asset_ids" binding:"required"`
Status *string `json:"status"`
ResponsiblePerson *string `json:"responsible_person"`
Department *string `json:"department"`
BusinessSystem *string `json:"business_system"`
Environment *string `json:"environment"`
Criticality *string `json:"criticality"`
AddTags []string `json:"add_tags"`
RemoveTags []string `json:"remove_tags"`
}
type assetIDsRequest struct {
AssetIDs []string `json:"asset_ids" binding:"required"`
}
type mergeAssetsRequest struct {
AssetIDs []string `json:"asset_ids" binding:"required"`
PrimaryID string `json:"primary_id"`
}
func (h *AssetHandler) Import(c *gin.Context) {
var req importAssetsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.Assets) == 0 || len(req.Assets) > 1000 {
c.JSON(http.StatusBadRequest, gin.H{"error": "assets 数量必须在 1-1000 之间"})
if len(req.Assets) == 0 || len(req.Assets) > maxAssetImportBatch {
c.JSON(http.StatusBadRequest, gin.H{"error": "assets 数量必须在 1-100000 之间"})
return
}
owner := ""
@@ -132,6 +165,11 @@ func assetListFilterFromQuery(c *gin.Context) (database.AssetListFilter, error)
Source: strings.TrimSpace(c.Query("source")), Tag: strings.TrimSpace(c.Query("tag")), Host: strings.TrimSpace(c.Query("host")),
IP: strings.TrimSpace(c.Query("ip")), Domain: strings.TrimSpace(c.Query("domain")), ScanState: strings.ToLower(strings.TrimSpace(c.Query("scan_state"))),
SortBy: strings.ToLower(strings.TrimSpace(c.Query("sort_by"))), SortOrder: strings.ToLower(strings.TrimSpace(c.Query("sort_order"))),
RiskLevel: strings.ToLower(strings.TrimSpace(c.Query("risk_level"))),
Country: strings.TrimSpace(c.Query("country")), Province: strings.TrimSpace(c.Query("province")), City: strings.TrimSpace(c.Query("city")),
ResponsiblePerson: strings.TrimSpace(c.Query("responsible_person")), Department: strings.TrimSpace(c.Query("department")),
BusinessSystem: strings.TrimSpace(c.Query("business_system")), Environment: strings.ToLower(strings.TrimSpace(c.Query("environment"))),
Criticality: strings.ToLower(strings.TrimSpace(c.Query("criticality"))),
}
if raw := strings.TrimSpace(c.Query("port")); raw != "" {
port, err := strconv.Atoi(raw)
@@ -140,6 +178,21 @@ func assetListFilterFromQuery(c *gin.Context) (database.AssetListFilter, error)
}
filter.Port = &port
}
for field, target := range map[string]**int{
"min_vulnerabilities": &filter.MinVulnerabilities,
"max_vulnerabilities": &filter.MaxVulnerabilities,
"scan_overdue_days": &filter.ScanOverdueDays,
} {
raw := strings.TrimSpace(c.Query(field))
if raw == "" {
continue
}
value, err := strconv.Atoi(raw)
if err != nil || value < 0 || (field == "scan_overdue_days" && value == 0) {
return filter, &assetQueryError{field: field, value: raw}
}
*target = &value
}
var err error
if filter.LastScanBefore, err = parseAssetQueryTime("last_scan_before", c.Query("last_scan_before")); err != nil {
return filter, err
@@ -147,6 +200,12 @@ func assetListFilterFromQuery(c *gin.Context) (database.AssetListFilter, error)
if filter.LastScanAfter, err = parseAssetQueryTime("last_scan_after", c.Query("last_scan_after")); err != nil {
return filter, err
}
if filter.FirstSeenBefore, err = parseAssetQueryTime("first_seen_before", c.Query("first_seen_before")); err != nil {
return filter, err
}
if filter.FirstSeenAfter, err = parseAssetQueryTime("first_seen_after", c.Query("first_seen_after")); err != nil {
return filter, err
}
if filter.LastSeenBefore, err = parseAssetQueryTime("last_seen_before", c.Query("last_seen_before")); err != nil {
return filter, err
}
@@ -156,6 +215,21 @@ func assetListFilterFromQuery(c *gin.Context) (database.AssetListFilter, error)
return filter, nil
}
// Selection resolves all assets matching the current filter for cross-page actions.
func (h *AssetHandler) Selection(c *gin.Context) {
filter, err := assetListFilterFromQuery(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
assets, total, err := h.db.ListAssetsForOperation(maxAssetOperationBatch, filter, assetAccess(c))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "total": total})
return
}
c.JSON(http.StatusOK, gin.H{"assets": assets, "total": total})
}
type assetQueryError struct{ field, value string }
func (e *assetQueryError) Error() string {
@@ -200,8 +274,8 @@ func (h *AssetHandler) RecordScans(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.Scans) == 0 || len(req.Scans) > 1000 {
c.JSON(http.StatusBadRequest, gin.H{"error": "scans 数量必须在 1-1000 之间"})
if len(req.Scans) == 0 || len(req.Scans) > maxAssetOperationBatch {
c.JSON(http.StatusBadRequest, gin.H{"error": "scans 数量必须在 1-10000 之间"})
return
}
access := assetAccess(c)
@@ -273,8 +347,8 @@ func (h *AssetHandler) UpdateProjectBinding(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.AssetIDs) == 0 || len(req.AssetIDs) > 1000 {
c.JSON(http.StatusBadRequest, gin.H{"error": "asset_ids 数量必须在 1-1000 之间"})
if len(req.AssetIDs) == 0 || len(req.AssetIDs) > maxAssetOperationBatch {
c.JSON(http.StatusBadRequest, gin.H{"error": "asset_ids 数量必须在 1-10000 之间"})
return
}
req.ProjectID = strings.TrimSpace(req.ProjectID)
@@ -296,6 +370,167 @@ func (h *AssetHandler) UpdateProjectBinding(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"updated": updated, "project_id": req.ProjectID})
}
func (h *AssetHandler) BulkUpdate(c *gin.Context) {
var req bulkUpdateAssetsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.AssetIDs) == 0 || len(req.AssetIDs) > maxAssetOperationBatch {
c.JSON(http.StatusBadRequest, gin.H{"error": "asset_ids 数量必须在 1-10000 之间"})
return
}
updated, err := h.db.UpdateAssetsBulk(req.AssetIDs, database.AssetBulkPatch{
Status: req.Status, ResponsiblePerson: req.ResponsiblePerson, Department: req.Department,
BusinessSystem: req.BusinessSystem, Environment: req.Environment, Criticality: req.Criticality,
AddTags: req.AddTags, RemoveTags: req.RemoveTags,
}, assetAccess(c))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"updated": updated})
}
func (h *AssetHandler) BatchDelete(c *gin.Context) {
var req assetIDsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.AssetIDs) == 0 || len(req.AssetIDs) > maxAssetOperationBatch {
c.JSON(http.StatusBadRequest, gin.H{"error": "asset_ids 数量必须在 1-10000 之间"})
return
}
deleted, err := h.db.DeleteAssets(req.AssetIDs, assetAccess(c))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": deleted})
}
func assetIdentityKeys(asset *database.Asset) map[string]struct{} {
keys := map[string]struct{}{}
if value := strings.ToLower(strings.TrimSpace(asset.Domain)); value != "" {
keys["domain:"+value] = struct{}{}
}
if value := strings.ToLower(strings.Trim(strings.TrimSpace(asset.IP), "[]")); value != "" {
keys["ip:"+value] = struct{}{}
}
if value := strings.ToLower(strings.TrimSpace(asset.Host)); value != "" {
keys["host:"+value] = struct{}{}
}
return keys
}
func shareAssetIdentity(left, right *database.Asset) bool {
for key := range assetIdentityKeys(left) {
if _, ok := assetIdentityKeys(right)[key]; ok {
return true
}
}
return false
}
// Merge keeps the selected primary asset and safely combines compatible duplicate metadata.
func (h *AssetHandler) Merge(c *gin.Context) {
var req mergeAssetsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.AssetIDs) < 2 || len(req.AssetIDs) > 100 {
c.JSON(http.StatusBadRequest, gin.H{"error": "合并资产数量必须在 2-100 之间"})
return
}
writeAccess := assetAccessForPermission(c, "asset:write")
deleteAccess := assetAccessForPermission(c, "asset:delete")
primaryID := strings.TrimSpace(req.PrimaryID)
if primaryID == "" {
primaryID = strings.TrimSpace(req.AssetIDs[0])
}
primary, err := h.db.GetAsset(primaryID, writeAccess)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "主资产不存在或无权访问"})
return
}
others := make([]*database.Asset, 0, len(req.AssetIDs)-1)
seen := map[string]struct{}{primaryID: {}}
for _, id := range req.AssetIDs {
id = strings.TrimSpace(id)
if id == "" || id == primaryID {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
item, err := h.db.GetAsset(id, writeAccess)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "部分资产不存在或无权访问"})
return
}
if !shareAssetIdentity(primary, item) {
c.JSON(http.StatusBadRequest, gin.H{"error": "所选资产没有共同域名、IP 或 Host,不能判定为重复资产"})
return
}
others = append(others, item)
}
if len(others) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "至少需要两个不同资产"})
return
}
mergeText := func(dst *string, src string) {
if strings.TrimSpace(*dst) == "" && strings.TrimSpace(src) != "" {
*dst = src
}
}
tagSet := map[string]struct{}{}
for _, tag := range primary.Tags {
tagSet[tag] = struct{}{}
}
for _, item := range others {
mergeText(&primary.ProjectID, item.ProjectID)
mergeText(&primary.Host, item.Host)
mergeText(&primary.IP, item.IP)
mergeText(&primary.Domain, item.Domain)
mergeText(&primary.Protocol, item.Protocol)
mergeText(&primary.Title, item.Title)
mergeText(&primary.Server, item.Server)
mergeText(&primary.Country, item.Country)
mergeText(&primary.Province, item.Province)
mergeText(&primary.City, item.City)
mergeText(&primary.ResponsiblePerson, item.ResponsiblePerson)
mergeText(&primary.Department, item.Department)
mergeText(&primary.BusinessSystem, item.BusinessSystem)
mergeText(&primary.Environment, item.Environment)
mergeText(&primary.Criticality, item.Criticality)
for _, tag := range item.Tags {
tagSet[tag] = struct{}{}
}
}
primary.Tags = primary.Tags[:0]
for tag := range tagSet {
primary.Tags = append(primary.Tags, tag)
}
if len(primary.Tags) > 30 {
c.JSON(http.StatusBadRequest, gin.H{"error": "合并后标签超过 30 个"})
return
}
ids := make([]string, 0, len(others))
for _, item := range others {
ids = append(ids, item.ID)
}
merged, err := h.db.MergeAssets(primary, ids, writeAccess, deleteAccess)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updated, _ := h.db.GetAsset(primary.ID, writeAccess)
c.JSON(http.StatusOK, gin.H{"merged": merged, "asset": updated})
}
func (h *AssetHandler) Delete(c *gin.Context) {
if err := h.db.DeleteAsset(c.Param("id"), assetAccess(c)); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "资产不存在或无权删除"})
+83
View File
@@ -0,0 +1,83 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"cyberstrike-ai/internal/database"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func TestAssetListPaginatesWithinProject(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := database.NewDB(filepath.Join(t.TempDir(), "asset-list-pagination.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
defer db.Close()
project, err := db.CreateProject(&database.Project{Name: "Paged Project", Status: "active"})
if err != nil {
t.Fatal(err)
}
otherProject, err := db.CreateProject(&database.Project{Name: "Other Project", Status: "active"})
if err != nil {
t.Fatal(err)
}
assets := make([]*database.Asset, 0, 8)
for i := 1; i <= 7; i++ {
assets = append(assets, &database.Asset{
ProjectID: project.ID,
IP: fmt.Sprintf("192.0.2.%d", i),
Port: 80,
Protocol: "http",
})
}
assets = append(assets, &database.Asset{
ProjectID: otherProject.ID,
IP: "198.51.100.1",
Port: 443,
Protocol: "https",
})
if _, err := db.UpsertAssets(assets, "", true); err != nil {
t.Fatal(err)
}
router := gin.New()
router.GET("/api/assets", NewAssetHandler(db, zap.NewNop()).List)
request := httptest.NewRequest(http.MethodGet, "/api/assets?project_id="+project.ID+"&page=2&page_size=3", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("unexpected status %d: %s", response.Code, response.Body.String())
}
var payload struct {
Assets []*database.Asset `json:"assets"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
}
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload.Total != 7 || payload.Page != 2 || payload.PageSize != 3 || payload.TotalPages != 3 {
t.Fatalf("unexpected pagination: total=%d page=%d page_size=%d total_pages=%d",
payload.Total, payload.Page, payload.PageSize, payload.TotalPages)
}
if len(payload.Assets) != 3 {
t.Fatalf("expected 3 assets on page 2, got %d", len(payload.Assets))
}
for _, asset := range payload.Assets {
if asset.ProjectID != project.ID {
t.Fatalf("asset from another project leaked into page: %#v", asset)
}
}
}
+14 -2
View File
@@ -775,7 +775,7 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
// 更新FOFA配置
if req.FOFA != nil {
h.config.FOFA = *req.FOFA
h.logger.Info("更新FOFA配置", zap.String("email", h.config.FOFA.Email))
h.logger.Info("更新FOFA配置", zap.String("base_url", h.config.FOFA.BaseURL))
}
// 更新MCP配置
@@ -1788,7 +1788,7 @@ func updateFOFAConfig(doc *yaml.Node, cfg config.FofaConfig) {
root := doc.Content[0]
fofaNode := ensureMap(root, "fofa")
setStringInMap(fofaNode, "base_url", cfg.BaseURL)
setStringInMap(fofaNode, "email", cfg.Email)
removeKeyFromMap(fofaNode, "email")
setStringInMap(fofaNode, "api_key", cfg.APIKey)
}
@@ -2102,6 +2102,18 @@ func setStringInMap(mapNode *yaml.Node, key, value string) {
valueNode.Value = value
}
func removeKeyFromMap(mapNode *yaml.Node, key string) {
if mapNode == nil || mapNode.Kind != yaml.MappingNode {
return
}
for i := 0; i+1 < len(mapNode.Content); i += 2 {
if mapNode.Content[i].Value == key {
mapNode.Content = append(mapNode.Content[:i], mapNode.Content[i+2:]...)
return
}
}
}
func setStringSliceInMap(mapNode *yaml.Node, key string, values []string) {
_, valueNode := ensureKeyValue(mapNode, key)
valueNode.Kind = yaml.SequenceNode
+36 -21
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
@@ -36,7 +37,7 @@ func NewFofaHandler(cfg *config.Config, logger *zap.Logger) *FofaHandler {
return &FofaHandler{
cfg: cfg,
logger: logger,
client: &http.Client{Timeout: 30 * time.Second},
client: &http.Client{Timeout: 60 * time.Second},
openAIClient: openaiClient.NewClient(llmCfg, llmHTTPClient, logger),
}
}
@@ -80,22 +81,15 @@ type fofaSearchResponse struct {
Results []map[string]interface{} `json:"results"`
}
func (h *FofaHandler) resolveCredentials() (email, apiKey string) {
// 优先环境变量(便于容器部署),其次配置文件
email = strings.TrimSpace(os.Getenv("FOFA_EMAIL"))
apiKey = strings.TrimSpace(os.Getenv("FOFA_API_KEY"))
if email != "" && apiKey != "" {
return email, apiKey
func (h *FofaHandler) resolveAPIKey() string {
// 优先环境变量(便于容器部署),其次配置文件
if apiKey := strings.TrimSpace(os.Getenv("FOFA_API_KEY")); apiKey != "" {
return apiKey
}
if h.cfg != nil {
if email == "" {
email = strings.TrimSpace(h.cfg.FOFA.Email)
}
if apiKey == "" {
apiKey = strings.TrimSpace(h.cfg.FOFA.APIKey)
}
return strings.TrimSpace(h.cfg.FOFA.APIKey)
}
return email, apiKey
return ""
}
func (h *FofaHandler) resolveBaseURL() string {
@@ -357,12 +351,12 @@ func (h *FofaHandler) Search(c *gin.Context) {
req.Fields = "host,ip,port,domain,title,protocol,country,province,city,server"
}
email, apiKey := h.resolveCredentials()
if email == "" || apiKey == "" {
apiKey := h.resolveAPIKey()
if apiKey == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "FOFA 未配置:请在系统设置中填写 FOFA Email/API Key,或设置环境变量 FOFA_EMAIL/FOFA_API_KEY",
"need": []string{"fofa.email", "fofa.api_key"},
"env_key": []string{"FOFA_EMAIL", "FOFA_API_KEY"},
"error": "FOFA 未配置:请在系统设置的资产管理中填写 FOFA API Key,或设置环境变量 FOFA_API_KEY",
"need": []string{"fofa.api_key"},
"env_key": []string{"FOFA_API_KEY"},
})
return
}
@@ -377,7 +371,6 @@ func (h *FofaHandler) Search(c *gin.Context) {
}
params := u.Query()
params.Set("email", email)
params.Set("key", apiKey)
params.Set("qbase64", qb64)
params.Set("size", fmt.Sprintf("%d", req.Size))
@@ -396,10 +389,18 @@ func (h *FofaHandler) Search(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建请求失败: " + err.Error()})
return
}
httpReq.Header.Set("User-Agent", "CyberStrikeAI/1.7.4")
httpReq.Header.Set("Accept", "application/json")
resp, err := h.client.Do(httpReq)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "请求 FOFA 失败: " + err.Error()})
status, message, timeout := safeFofaRequestError(err)
h.logger.Warn("请求 FOFA 失败",
zap.String("host", u.Host),
zap.Bool("timeout", timeout),
zap.String("error_type", fmt.Sprintf("%T", err)),
)
c.JSON(status, gin.H{"error": message})
return
}
defer resp.Body.Close()
@@ -448,6 +449,20 @@ func (h *FofaHandler) Search(c *gin.Context) {
})
}
func safeFofaRequestError(err error) (status int, message string, timeout bool) {
var netErr net.Error
timeout = errors.Is(err, context.DeadlineExceeded) ||
(errors.As(err, &netErr) && netErr.Timeout())
if timeout {
return http.StatusGatewayTimeout,
"FOFA 请求超时(60 秒):请稍后重试,或减少返回数量和返回字段",
true
}
return http.StatusBadGateway,
"无法连接 FOFA 服务,请检查服务器网络或代理配置",
false
}
func splitAndCleanCSV(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
+86
View File
@@ -0,0 +1,86 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"cyberstrike-ai/internal/config"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func TestFofaSearchUsesAPIKeyWithoutEmail(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("FOFA_API_KEY", "")
t.Setenv("FOFA_EMAIL", "legacy@example.com")
var receivedEmail string
var receivedKey string
fofaServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedEmail = r.URL.Query().Get("email")
receivedKey = r.URL.Query().Get("key")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"error":false,"size":1,"page":1,"results":[["https://example.com"]]}`))
}))
defer fofaServer.Close()
h := NewFofaHandler(&config.Config{
FOFA: config.FofaConfig{
BaseURL: fofaServer.URL,
APIKey: "test-api-key",
},
}, zap.NewNop())
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
body := `{"query":"domain=\"example.com\"","fields":"host"}`
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/fofa/search", strings.NewReader(body))
ctx.Request.Header.Set("Content-Type", "application/json")
h.Search(ctx)
if recorder.Code != http.StatusOK {
t.Fatalf("Search() status = %d, body = %s", recorder.Code, recorder.Body.String())
}
if receivedEmail != "" {
t.Fatalf("FOFA request unexpectedly included email = %q", receivedEmail)
}
if receivedKey != "test-api-key" {
t.Fatalf("FOFA request key = %q, want %q", receivedKey, "test-api-key")
}
var response fofaSearchResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.ResultsCount != 1 {
t.Fatalf("results_count = %d, want 1", response.ResultsCount)
}
}
func TestSafeFofaRequestErrorDoesNotExposeURLOrAPIKey(t *testing.T) {
const secretURL = "https://fofa.info/api/v1/search/all?key=secret-api-key"
err := &url.Error{
Op: http.MethodGet,
URL: secretURL,
Err: context.DeadlineExceeded,
}
status, message, timeout := safeFofaRequestError(err)
if status != http.StatusGatewayTimeout {
t.Fatalf("status = %d, want %d", status, http.StatusGatewayTimeout)
}
if !timeout {
t.Fatal("timeout = false, want true")
}
if strings.Contains(message, "secret-api-key") || strings.Contains(message, secretURL) {
t.Fatalf("safe error exposed request URL or API key: %q", message)
}
}
+82
View File
@@ -241,6 +241,58 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
},
},
},
"AssetImportItem": map[string]interface{}{
"type": "object",
"description": "待导入资产;host、ip、domain 至少一项非空",
"properties": map[string]interface{}{
"project_id": map[string]interface{}{"type": "string", "description": "所属项目 ID;调用者必须有权访问"},
"host": map[string]interface{}{"type": "string", "maxLength": 500, "example": "https://app.example.com:443"},
"ip": map[string]interface{}{"type": "string", "example": "192.0.2.10"},
"port": map[string]interface{}{"type": "integer", "minimum": 0, "maximum": 65535, "example": 443},
"domain": map[string]interface{}{"type": "string", "example": "app.example.com"},
"protocol": map[string]interface{}{"type": "string", "example": "https"},
"title": map[string]interface{}{"type": "string", "maxLength": 500},
"server": map[string]interface{}{"type": "string", "maxLength": 255, "example": "nginx"},
"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", "maxLength": 255, "description": "资产负责人"},
"department": map[string]interface{}{"type": "string", "maxLength": 255, "description": "所属部门"},
"business_system": map[string]interface{}{"type": "string", "maxLength": 255, "description": "所属业务系统"},
"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"}, "default": "active"},
"tags": map[string]interface{}{
"type": "array",
"maxItems": 30,
"items": map[string]interface{}{"type": "string", "maxLength": 64},
},
},
},
"AssetImportRequest": map[string]interface{}{
"type": "object",
"required": []string{"assets"},
"properties": map[string]interface{}{
"assets": map[string]interface{}{
"type": "array",
"minItems": 1,
"maxItems": 100000,
"items": map[string]interface{}{"$ref": "#/components/schemas/AssetImportItem"},
},
"source": map[string]interface{}{"type": "string", "description": "未在资产中填写来源时使用的默认来源"},
"source_query": map[string]interface{}{"type": "string", "description": "默认来源查询或导入文件名"},
},
},
"AssetImportResult": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"created": map[string]interface{}{"type": "integer", "description": "新建数量", "example": 120},
"updated": map[string]interface{}{"type": "integer", "description": "去重合并数量", "example": 8},
"skipped": map[string]interface{}{"type": "integer", "description": "跳过数量", "example": 2},
},
},
"ExecutionResult": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
@@ -2434,6 +2486,36 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
},
},
},
"/api/assets/import": map[string]interface{}{
"post": map[string]interface{}{
"tags": []string{"资产管理"},
"summary": "批量导入资产",
"description": "新增或按“目标 + 端口 + 协议”去重更新资产。接收 JSON,不直接接收 XLSX/CSV 文件;单次最多 100000 条,需要 asset:write 权限。",
"operationId": "importAssets",
"requestBody": map[string]interface{}{
"required": true,
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/AssetImportRequest"},
},
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "导入完成",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/AssetImportResult"},
},
},
},
"400": map[string]interface{}{"description": "数量或资产字段校验失败"},
"401": map[string]interface{}{"description": "未授权"},
"403": map[string]interface{}{"description": "缺少 asset:write 权限或无权访问指定项目"},
"500": map[string]interface{}{"description": "导入事务失败"},
},
},
},
"/api/projects": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"项目管理"},
+6 -2
View File
@@ -11,7 +11,7 @@ var apiDocI18nTagToKey = map[string]string{
"知识库": "knowledgeBase", "MCP": "mcp",
"FOFA信息收集": "fofaRecon", "终端": "terminal", "WebShell管理": "webshellManagement",
"对话附件": "chatUploads", "机器人集成": "robotIntegration", "多代理Markdown": "markdownAgents",
"项目管理": "projectManagement",
"项目管理": "projectManagement", "资产管理": "assetManagement",
}
var apiDocI18nSummaryToKey = map[string]string{
@@ -71,8 +71,9 @@ var apiDocI18nSummaryToKey = map[string]string{
"列出技能包文件": "listSkillPackageFiles", "获取技能包文件内容": "getSkillPackageFile", "写入技能包文件": "putSkillPackageFile",
"批量获取工具名称": "batchGetToolNames",
"获取知识库统计": "getKnowledgeStats",
"列出项目": "listProjects", "创建项目": "createProject", "获取项目": "getProject",
"列出项目": "listProjects", "创建项目": "createProject", "获取项目": "getProject",
"更新项目": "updateProject", "删除项目": "deleteProject",
"批量导入资产": "importAssets",
"列出或按 key 获取事实": "listProjectFacts", "创建/更新事实": "upsertProjectFact",
"获取项目事实攻击路径图": "getProjectFactGraph", "列出项目全部事实边": "listProjectFactEdges",
"添加事实边": "createProjectFactEdge", "删除事实边": "deleteProjectFactEdge",
@@ -109,6 +110,9 @@ var apiDocI18nResponseDescToKey = map[string]string{
"成功": "success", "nodes + edges": "factGraphNodesEdges",
"边列表": "edgeList", "边已创建": "edgeCreated",
"沉淀结果(facts/edges/graph": "promoteAttackChainResult",
"导入完成": "assetImportCompleted", "数量或资产字段校验失败": "assetImportValidationFailed",
"缺少 asset:write 权限或无权访问指定项目": "assetImportForbidden",
"导入事务失败": "assetImportTransactionFailed",
}
// enrichSpecWithI18nKeys 在 spec 的每个 operation 上写入 x-i18n-tags、x-i18n-summary
+46
View File
@@ -0,0 +1,46 @@
package handler
import "testing"
func TestEnrichSpecWithI18nKeysForAssetImport(t *testing.T) {
responses := map[string]interface{}{
"200": map[string]interface{}{"description": "导入完成"},
"400": map[string]interface{}{"description": "数量或资产字段校验失败"},
"403": map[string]interface{}{"description": "缺少 asset:write 权限或无权访问指定项目"},
"500": map[string]interface{}{"description": "导入事务失败"},
}
operation := map[string]interface{}{
"tags": []string{"资产管理"},
"summary": "批量导入资产",
"responses": responses,
}
spec := map[string]interface{}{
"paths": map[string]interface{}{
"/api/assets/import": map[string]interface{}{
"post": operation,
},
},
}
enrichSpecWithI18nKeys(spec)
tagKeys, ok := operation["x-i18n-tags"].([]string)
if !ok || len(tagKeys) != 1 || tagKeys[0] != "assetManagement" {
t.Fatalf("unexpected asset tag i18n keys: %#v", operation["x-i18n-tags"])
}
if got := operation["x-i18n-summary"]; got != "importAssets" {
t.Fatalf("unexpected asset summary i18n key: %#v", got)
}
expectedResponseKeys := map[string]string{
"200": "assetImportCompleted",
"400": "assetImportValidationFailed",
"403": "assetImportForbidden",
"500": "assetImportTransactionFailed",
}
for status, want := range expectedResponseKeys {
response := responses[status].(map[string]interface{})
if got := response["x-i18n-description"]; got != want {
t.Errorf("unexpected asset response i18n key for %s: got %#v, want %q", status, got, want)
}
}
}