diff --git a/internal/app/app.go b/internal/app/app.go index 38ec3d53..e39dfbad 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -15,6 +15,7 @@ import ( "time" "cyberstrike-ai/internal/agent" + "cyberstrike-ai/internal/audit" "cyberstrike-ai/internal/c2" "cyberstrike-ai/internal/config" "cyberstrike-ai/internal/database" @@ -61,6 +62,7 @@ type App struct { c2Watchdog *c2.SessionWatchdog // C2 会话看门狗 c2WatchdogCancel context.CancelFunc // 看门狗取消函数 c2Handler *handler.C2Handler // C2 REST(与 Manager 生命周期同步) + auditSvc *audit.Service } // New 创建新应用 @@ -93,6 +95,10 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error return nil, fmt.Errorf("初始化数据库失败: %w", err) } + auditSvc := audit.NewService(db, cfg, log.Logger) + auditSvc.PurgeExpired() + audit.StartRetentionLoop(auditSvc, log.Logger) + // 创建MCP服务器(带数据库持久化) mcpServer := mcp.NewServerWithStorage(log.Logger, db) mcpServer.ConfigureHTTPToolCallTimeoutFromAgentMinutes(cfg.Agent.ToolTimeoutMinutes) @@ -222,6 +228,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error // 创建知识库API处理器 knowledgeHandler = handler.NewKnowledgeHandler(knowledgeManager, knowledgeRetriever, knowledgeIndexer, db, log.Logger) + knowledgeHandler.SetAudit(auditSvc) log.Logger.Info("知识库模块初始化完成", zap.Bool("handler_created", knowledgeHandler != nil)) // 扫描知识库并建立索引(异步) @@ -318,31 +325,42 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error log.Logger.Warn("创建 agents 目录失败", zap.String("path", agentsDir), zap.Error(err)) } markdownAgentsHandler := handler.NewMarkdownAgentsHandler(agentsDir) + markdownAgentsHandler.SetAudit(auditSvc) log.Logger.Info("多代理 Markdown 子 Agent 目录", zap.String("agentsDir", agentsDir)) // 创建处理器 agentHandler := handler.NewAgentHandler(agent, db, cfg, log.Logger) + agentHandler.SetAudit(auditSvc) agentHandler.SetAgentsMarkdownDir(agentsDir) // 如果知识库已启用,设置知识库管理器到AgentHandler以便记录检索日志 if knowledgeManager != nil { agentHandler.SetKnowledgeManager(knowledgeManager) } monitorHandler := handler.NewMonitorHandler(mcpServer, executor, db, log.Logger) + monitorHandler.SetAudit(auditSvc) monitorHandler.SetExternalMCPManager(externalMCPMgr) // 设置外部MCP管理器,以便获取外部MCP执行记录 notificationHandler := handler.NewNotificationHandler(db, agentHandler, log.Logger) groupHandler := handler.NewGroupHandler(db, log.Logger) authHandler := handler.NewAuthHandler(authManager, cfg, configPath, log.Logger) + authHandler.SetAudit(auditSvc) attackChainHandler := handler.NewAttackChainHandler(db, &cfg.OpenAI, log.Logger) vulnerabilityHandler := handler.NewVulnerabilityHandler(db, log.Logger) + vulnerabilityHandler.SetAudit(auditSvc) webshellHandler := handler.NewWebShellHandler(log.Logger, db) + webshellHandler.SetAudit(auditSvc) chatUploadsHandler := handler.NewChatUploadsHandler(log.Logger) + chatUploadsHandler.SetAudit(auditSvc) registerWebshellTools(mcpServer, db, webshellHandler, log.Logger) registerWebshellManagementTools(mcpServer, db, webshellHandler, log.Logger) configHandler := handler.NewConfigHandler(configPath, cfg, mcpServer, executor, agent, attackChainHandler, externalMCPMgr, log.Logger) + configHandler.SetAudit(auditSvc) agentHandler.SetHitlToolWhitelistSaver(configHandler) externalMCPHandler := handler.NewExternalMCPHandler(externalMCPMgr, cfg, configPath, log.Logger) + externalMCPHandler.SetAudit(auditSvc) roleHandler := handler.NewRoleHandler(cfg, configPath, log.Logger) + roleHandler.SetAudit(auditSvc) skillsHandler := handler.NewSkillsHandler(cfg, configPath, log.Logger) + skillsHandler.SetAudit(auditSvc) fofaHandler := handler.NewFofaHandler(cfg, log.Logger) terminalHandler := handler.NewTerminalHandler(log.Logger) if db != nil { @@ -357,9 +375,12 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error registerC2Tools(mcpServer, c2Manager, log.Logger, cfg.Server.Port) } c2Handler := handler.NewC2Handler(c2Manager, log.Logger) + c2Handler.SetAudit(auditSvc) // 创建OpenAPI处理器 conversationHandler := handler.NewConversationHandler(db, log.Logger) + conversationHandler.SetAudit(auditSvc) + auditHandler := handler.NewAuditHandler(db, auditSvc, log.Logger) robotHandler := handler.NewRobotHandler(cfg, db, agentHandler, log.Logger) openAPIHandler := handler.NewOpenAPIHandler(db, log.Logger, resultStorage, conversationHandler, agentHandler) @@ -385,6 +406,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error c2Watchdog: c2Watchdog, c2WatchdogCancel: watchdogCancel, c2Handler: c2Handler, + auditSvc: auditSvc, } // 飞书/钉钉长连接(无需公网),启用时在后台启动;后续前端应用配置时会通过 RestartRobotConnections 重启 app.startRobotConnections() @@ -487,6 +509,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error fofaHandler, terminalHandler, app.c2Handler, + auditHandler, mcpServer, authManager, openAPIHandler, @@ -731,6 +754,7 @@ func setupRoutes( fofaHandler *handler.FofaHandler, terminalHandler *handler.TerminalHandler, c2Handler *handler.C2Handler, + auditHandler *handler.AuditHandler, mcpServer *mcp.Server, authManager *security.AuthManager, openAPIHandler *handler.OpenAPIHandler, @@ -867,6 +891,13 @@ func setupRoutes( protected.POST("/terminal/run/stream", terminalHandler.RunCommandStream) protected.GET("/terminal/ws", terminalHandler.RunCommandWS) + // 平台审计日志 + protected.GET("/audit/meta", auditHandler.Meta) + protected.GET("/audit/summary", auditHandler.Summary) + protected.GET("/audit/logs", auditHandler.ListLogs) + protected.GET("/audit/logs/export", auditHandler.ExportLogs) + protected.GET("/audit/logs/:id", auditHandler.GetLog) + // 外部MCP管理 protected.GET("/external-mcp", externalMCPHandler.GetExternalMCPs) protected.GET("/external-mcp/stats", externalMCPHandler.GetExternalMCPStats) @@ -1928,6 +1959,9 @@ func initializeKnowledge( // 创建知识库API处理器 knowledgeHandler := handler.NewKnowledgeHandler(knowledgeManager, knowledgeRetriever, knowledgeIndexer, db, logger) + if app != nil && app.auditSvc != nil { + knowledgeHandler.SetAudit(app.auditSvc) + } logger.Info("知识库模块初始化完成", zap.Bool("handler_created", knowledgeHandler != nil)) // 设置知识库管理器到AgentHandler以便记录检索日志 diff --git a/internal/audit/meta.go b/internal/audit/meta.go new file mode 100644 index 00000000..33649e0c --- /dev/null +++ b/internal/audit/meta.go @@ -0,0 +1,9 @@ +package audit + +// RetentionDays returns configured retention; 0 means keep forever. +func (s *Service) RetentionDays() int { + if s == nil || s.cfg == nil { + return 0 + } + return s.cfg.Audit.RetentionDaysEffective() +} diff --git a/internal/audit/record.go b/internal/audit/record.go new file mode 100644 index 00000000..b1c1ad40 --- /dev/null +++ b/internal/audit/record.go @@ -0,0 +1,29 @@ +package audit + +import "github.com/gin-gonic/gin" + +// RecordAction writes a platform audit row with common defaults. +func (s *Service) RecordAction(c *gin.Context, category, action, result, message, resourceType, resourceID string, detail map[string]interface{}) { + if s == nil { + return + } + s.Record(c, Entry{ + Category: category, + Action: action, + Result: result, + Message: message, + ResourceType: resourceType, + ResourceID: resourceID, + Detail: detail, + }) +} + +// RecordOK is a shorthand for successful operations. +func (s *Service) RecordOK(c *gin.Context, category, action, message, resourceType, resourceID string, detail map[string]interface{}) { + s.RecordAction(c, category, action, "success", message, resourceType, resourceID, detail) +} + +// RecordFail is a shorthand for failed operations. +func (s *Service) RecordFail(c *gin.Context, category, action, message string, detail map[string]interface{}) { + s.RecordAction(c, category, action, "failure", message, "", "", detail) +} diff --git a/internal/audit/retention.go b/internal/audit/retention.go new file mode 100644 index 00000000..83ef05d1 --- /dev/null +++ b/internal/audit/retention.go @@ -0,0 +1,24 @@ +package audit + +import ( + "time" + + "go.uber.org/zap" +) + +// StartRetentionLoop periodically purges expired audit rows. +func StartRetentionLoop(s *Service, logger *zap.Logger) { + if s == nil { + return + } + go func() { + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + for range ticker.C { + s.PurgeExpired() + if logger != nil { + logger.Debug("audit retention tick completed") + } + } + }() +} diff --git a/internal/audit/sanitize.go b/internal/audit/sanitize.go new file mode 100644 index 00000000..34f2b439 --- /dev/null +++ b/internal/audit/sanitize.go @@ -0,0 +1,58 @@ +package audit + +import ( + "encoding/json" + "strings" +) + +var sensitiveKeySubstrings = []string{ + "password", "api_key", "apikey", "secret", "token", "authorization", + "credential", "private_key", "access_key", +} + +// SanitizeDetail redacts sensitive keys and truncates serialized size. +func SanitizeDetail(detail map[string]interface{}, maxBytes int) map[string]interface{} { + if detail == nil { + return nil + } + if maxBytes <= 0 { + maxBytes = 8192 + } + out := sanitizeValue("", detail) + if m, ok := out.(map[string]interface{}); ok { + b, _ := json.Marshal(m) + if len(b) > maxBytes { + return map[string]interface{}{ + "_truncated": true, + "_preview": string(b[:maxBytes]), + } + } + return m + } + return map[string]interface{}{"value": out} +} + +func sanitizeValue(key string, v interface{}) interface{} { + kl := strings.ToLower(key) + for _, sub := range sensitiveKeySubstrings { + if strings.Contains(kl, sub) { + return "***" + } + } + switch t := v.(type) { + case map[string]interface{}: + m := make(map[string]interface{}, len(t)) + for k, val := range t { + m[k] = sanitizeValue(k, val) + } + return m + case []interface{}: + arr := make([]interface{}, len(t)) + for i, val := range t { + arr[i] = sanitizeValue(key, val) + } + return arr + default: + return v + } +} diff --git a/internal/audit/service.go b/internal/audit/service.go new file mode 100644 index 00000000..eb537f33 --- /dev/null +++ b/internal/audit/service.go @@ -0,0 +1,166 @@ +package audit + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "time" + + "cyberstrike-ai/internal/config" + "cyberstrike-ai/internal/database" + "cyberstrike-ai/internal/security" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" +) + +// Service persists platform audit logs. +type Service struct { + db *database.DB + cfg *config.Config + logger *zap.Logger + failThrottle *failureThrottle +} + +// NewService creates an audit service. +func NewService(db *database.DB, cfg *config.Config, logger *zap.Logger) *Service { + return &Service{ + db: db, + cfg: cfg, + logger: logger, + failThrottle: newFailureThrottle(), + } +} + +// Enabled reports whether audit persistence is on. +func (s *Service) Enabled() bool { + if s == nil || s.cfg == nil { + return false + } + return s.cfg.Audit.EnabledEffective() +} + +// Record writes one audit row from a Gin request context. +func (s *Service) Record(c *gin.Context, e Entry) { + if s == nil || !s.Enabled() || s.db == nil { + return + } + if strings.TrimSpace(e.Category) == "" || strings.TrimSpace(e.Action) == "" { + return + } + if e.Result == "failure" && !s.allowFailureAudit(c, e) { + return + } + if strings.TrimSpace(e.Result) == "" { + e.Result = "success" + } + if strings.TrimSpace(e.Level) == "" { + if e.Result == "failure" { + e.Level = "warn" + } else { + e.Level = "info" + } + } + if strings.TrimSpace(e.Actor) == "" { + e.Actor = "admin" + } + if e.SessionHint == "" && c != nil { + if token := c.GetString(security.ContextAuthTokenKey); token != "" { + e.SessionHint = sessionHint(token) + } + } + maxDetail := s.cfg.Audit.MaxDetailBytesEffective() + detail := SanitizeDetail(e.Detail, maxDetail) + + row := &database.AuditLog{ + ID: "audit_" + strings.ReplaceAll(uuid.New().String(), "-", ""), + CreatedAt: time.Now(), + Level: e.Level, + Category: e.Category, + Action: e.Action, + Result: e.Result, + Actor: e.Actor, + SessionHint: e.SessionHint, + ClientIP: clientIP(c), + UserAgent: userAgent(c), + ResourceType: e.ResourceType, + ResourceID: e.ResourceID, + Message: e.Message, + Detail: detail, + } + if err := s.db.AppendAuditLog(row); err != nil && s.logger != nil { + s.logger.Warn("写入审计日志失败", + zap.String("action", e.Action), + zap.Error(err), + ) + } +} + +// RecordSystem writes an audit row without HTTP context (e.g. retention cleanup). +func (s *Service) RecordSystem(e Entry) { + s.Record(nil, e) +} + +// PurgeExpired deletes rows older than retention_days when configured. +func (s *Service) PurgeExpired() { + if s == nil || s.db == nil || s.cfg == nil { + return + } + days := s.cfg.Audit.RetentionDaysEffective() + if days <= 0 { + return + } + cutoff := time.Now().AddDate(0, 0, -days) + n, err := s.db.DeleteAuditLogsBefore(cutoff) + if err != nil { + if s.logger != nil { + s.logger.Warn("清理过期审计日志失败", zap.Error(err)) + } + return + } + if n > 0 && s.logger != nil { + s.logger.Info("已清理过期审计日志", zap.Int64("deleted", n)) + } +} + +// HintFromToken returns a short stable hash prefix for a session token. +func HintFromToken(token string) string { + return sessionHint(token) +} + +func sessionHint(token string) string { + token = strings.TrimSpace(token) + if token == "" { + return "" + } + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:4]) +} + +func (s *Service) allowFailureAudit(c *gin.Context, e Entry) bool { + if !isAuthFailureThrottled(e.Category, e.Action) { + return true + } + cooldown := time.Duration(s.cfg.Audit.AuthFailureCooldownEffective()) * time.Second + key := authFailureThrottleKey(e.Category, e.Action, clientIP(c)) + return s.failThrottle.allow(key, cooldown) +} + +func clientIP(c *gin.Context) string { + if c == nil { + return "" + } + return c.ClientIP() +} + +func userAgent(c *gin.Context) string { + if c == nil { + return "" + } + ua := c.GetHeader("User-Agent") + if len(ua) > 512 { + return ua[:512] + } + return ua +} diff --git a/internal/audit/throttle.go b/internal/audit/throttle.go new file mode 100644 index 00000000..7364e07d --- /dev/null +++ b/internal/audit/throttle.go @@ -0,0 +1,55 @@ +package audit + +import ( + "sync" + "time" +) + +// failureThrottle deduplicates high-frequency failure audit rows (e.g. wrong password). +type failureThrottle struct { + mu sync.Mutex + last map[string]time.Time +} + +func newFailureThrottle() *failureThrottle { + return &failureThrottle{last: make(map[string]time.Time)} +} + +// allow reports whether a row with the given key may be written now. +func (t *failureThrottle) allow(key string, cooldown time.Duration) bool { + if t == nil || cooldown <= 0 || key == "" { + return true + } + now := time.Now() + t.mu.Lock() + defer t.mu.Unlock() + if prev, ok := t.last[key]; ok && now.Sub(prev) < cooldown { + return false + } + t.last[key] = now + if len(t.last) > 4096 { + for k, ts := range t.last { + if now.Sub(ts) > cooldown*2 { + delete(t.last, k) + } + } + } + return true +} + +// authFailureThrottleKey builds a per-IP key for auth failure deduplication. +func authFailureThrottleKey(category, action, clientIP string) string { + return category + ":" + action + ":" + clientIP +} + +func isAuthFailureThrottled(category, action string) bool { + if category != "auth" { + return false + } + switch action { + case "login", "change_password": + return true + default: + return false + } +} diff --git a/internal/audit/types.go b/internal/audit/types.go new file mode 100644 index 00000000..7876e2f7 --- /dev/null +++ b/internal/audit/types.go @@ -0,0 +1,15 @@ +package audit + +// Entry describes one platform audit record (not chat/tool execution bodies). +type Entry struct { + Level string + Category string + Action string + Result string // success | failure + Actor string + SessionHint string + ResourceType string + ResourceID string + Message string + Detail map[string]interface{} +} diff --git a/internal/handler/agent.go b/internal/handler/agent.go index 4af89989..e285e7f7 100644 --- a/internal/handler/agent.go +++ b/internal/handler/agent.go @@ -17,6 +17,7 @@ import ( "unicode/utf8" "cyberstrike-ai/internal/agent" + "cyberstrike-ai/internal/audit" "cyberstrike-ai/internal/config" "cyberstrike-ai/internal/database" "cyberstrike-ai/internal/reasoning" @@ -131,6 +132,12 @@ type AgentHandler struct { batchRunning map[string]struct{} // hitlWhitelistSaver 侧栏「应用」HITL 时将会话增量白名单合并写入 config.yaml(可选) hitlWhitelistSaver HitlToolWhitelistSaver + audit *audit.Service +} + +// SetAudit wires platform audit logging. +func (h *AgentHandler) SetAudit(s *audit.Service) { + h.audit = s } // HitlToolWhitelistSaver 合并 HITL 免审批工具到全局配置并落盘 @@ -2039,6 +2046,11 @@ func (h *AgentHandler) CreateBatchQueue(c *gin.Context) { queue = refreshed } } + if h.audit != nil { + h.audit.RecordOK(c, "task", "create_queue", "创建批量任务队列", "batch_queue", queue.ID, map[string]interface{}{ + "task_count": len(validTasks), "started": started, + }) + } c.JSON(http.StatusOK, gin.H{ "queueId": queue.ID, "queue": queue, @@ -2146,6 +2158,9 @@ func (h *AgentHandler) StartBatchQueue(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在"}) return } + if h.audit != nil { + h.audit.RecordOK(c, "task", "start_queue", "启动批量任务队列", "batch_queue", queueID, nil) + } c.JSON(http.StatusOK, gin.H{"message": "批量任务已开始执行", "queueId": queueID}) } @@ -2174,6 +2189,9 @@ func (h *AgentHandler) RerunBatchQueue(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "启动失败"}) return } + if h.audit != nil { + h.audit.RecordOK(c, "task", "rerun_queue", "重跑批量任务队列", "batch_queue", queueID, nil) + } c.JSON(http.StatusOK, gin.H{"message": "批量任务已重新开始执行", "queueId": queueID}) } @@ -2185,6 +2203,9 @@ func (h *AgentHandler) PauseBatchQueue(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在或无法暂停"}) return } + if h.audit != nil { + h.audit.RecordOK(c, "task", "pause_queue", "暂停批量任务队列", "batch_queue", queueID, nil) + } c.JSON(http.StatusOK, gin.H{"message": "批量任务已暂停"}) } @@ -2280,6 +2301,16 @@ func (h *AgentHandler) DeleteBatchQueue(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在"}) return } + if h.audit != nil { + h.audit.Record(c, audit.Entry{ + Category: "task", + Action: "delete_queue", + Result: "success", + ResourceType: "batch_queue", + ResourceID: queueID, + Message: "删除批量任务队列", + }) + } c.JSON(http.StatusOK, gin.H{"message": "批量任务队列已删除"}) } @@ -2365,6 +2396,11 @@ func (h *AgentHandler) DeleteBatchTask(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在"}) return } + if h.audit != nil { + h.audit.RecordOK(c, "task", "delete_batch_task", "删除批量子任务", "batch_task", taskID, map[string]interface{}{ + "batch_queue_id": queueID, + }) + } c.JSON(http.StatusOK, gin.H{"message": "任务已删除", "queue": queue}) } diff --git a/internal/handler/audit.go b/internal/handler/audit.go new file mode 100644 index 00000000..5649324b --- /dev/null +++ b/internal/handler/audit.go @@ -0,0 +1,146 @@ +package handler + +import ( + "net/http" + "time" + + "cyberstrike-ai/internal/audit" + "cyberstrike-ai/internal/database" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// AuditHandler serves platform audit log APIs. +type AuditHandler struct { + db *database.DB + audit *audit.Service + logger *zap.Logger +} + +// NewAuditHandler creates an audit log handler. +func NewAuditHandler(db *database.DB, auditSvc *audit.Service, logger *zap.Logger) *AuditHandler { + return &AuditHandler{db: db, audit: auditSvc, logger: logger} +} + +// Meta GET /api/audit/meta +func (h *AuditHandler) Meta(c *gin.Context) { + enabled := false + retentionDays := 0 + if h.audit != nil { + enabled = h.audit.Enabled() + retentionDays = h.audit.RetentionDays() + } + c.JSON(http.StatusOK, gin.H{ + "enabled": enabled, + "retention_days": retentionDays, + "default_page_size": 20, + "max_page_size": 100, + "max_export": 5000, + }) +} + +// Summary GET /api/audit/summary +func (h *AuditHandler) Summary(c *gin.Context) { + if h.db == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database unavailable"}) + return + } + base := auditFilterFromQuery(c) + total, err := h.db.CountAuditLogs(base) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + failFilter := base + failFilter.Result = "failure" + failures, err := h.db.CountAuditLogs(failFilter) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + since := time.Now().AddDate(0, 0, -7) + recentFilter := base + recentFilter.Since = &since + recent7d, err := h.db.CountAuditLogs(recentFilter) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "total": total, + "failures": failures, + "recent_7d": recent7d, + "has_filters": c.Query("category") != "" || c.Query("action") != "" || c.Query("result") != "" || + c.Query("q") != "" || c.Query("since") != "" || c.Query("until") != "", + }) +} + +// ListLogs GET /api/audit/logs +func (h *AuditHandler) ListLogs(c *gin.Context) { + if h.db == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database unavailable"}) + return + } + filter := auditFilterFromQuery(c) + page, pageSize := auditPaginationFromQuery(c) + filter.Limit = pageSize + filter.Offset = (page - 1) * pageSize + + logs, err := h.db.ListAuditLogs(filter) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + total, err := h.db.CountAuditLogs(filter) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "logs": logs, + "total": total, + "page": page, + "page_size": pageSize, + }) +} + +// GetLog GET /api/audit/logs/:id +func (h *AuditHandler) GetLog(c *gin.Context) { + if h.db == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database unavailable"}) + return + } + row, err := h.db.GetAuditLogByID(c.Param("id")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "审计记录不存在"}) + return + } + c.JSON(http.StatusOK, gin.H{"log": row}) +} + +// ExportLogs GET /api/audit/logs/export — JSON or CSV (?format=csv), max 5000 rows. +func (h *AuditHandler) ExportLogs(c *gin.Context) { + if h.db == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database unavailable"}) + return + } + filter := auditFilterFromQuery(c) + filter.Limit = 5000 + filter.Offset = 0 + + logs, err := h.db.ListAuditLogs(filter) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if c.Query("format") == "csv" { + writeAuditLogsCSV(c, logs) + return + } + c.Header("Content-Disposition", `attachment; filename="audit-logs.json"`) + c.JSON(http.StatusOK, gin.H{ + "exported_at": time.Now().UTC().Format(time.RFC3339), + "logs": logs, + }) +} diff --git a/internal/handler/audit_export_csv.go b/internal/handler/audit_export_csv.go new file mode 100644 index 00000000..debf10c9 --- /dev/null +++ b/internal/handler/audit_export_csv.go @@ -0,0 +1,42 @@ +package handler + +import ( + "encoding/csv" + "fmt" + "time" + + "cyberstrike-ai/internal/database" + + "github.com/gin-gonic/gin" +) + +func writeAuditLogsCSV(c *gin.Context, logs []*database.AuditLog) { + c.Header("Content-Type", "text/csv; charset=utf-8") + c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="audit-logs-%s.csv"`, time.Now().Format("20060102"))) + + w := csv.NewWriter(c.Writer) + _ = w.Write([]string{ + "id", "created_at", "level", "category", "action", "result", "actor", + "session_hint", "client_ip", "resource_type", "resource_id", "message", + }) + for _, row := range logs { + if row == nil { + continue + } + _ = w.Write([]string{ + row.ID, + row.CreatedAt.UTC().Format(time.RFC3339), + row.Level, + row.Category, + row.Action, + row.Result, + row.Actor, + row.SessionHint, + row.ClientIP, + row.ResourceType, + row.ResourceID, + row.Message, + }) + } + w.Flush() +} diff --git a/internal/handler/audit_query.go b/internal/handler/audit_query.go new file mode 100644 index 00000000..ba097c03 --- /dev/null +++ b/internal/handler/audit_query.go @@ -0,0 +1,48 @@ +package handler + +import ( + "strconv" + "time" + + "cyberstrike-ai/internal/database" + + "github.com/gin-gonic/gin" +) + +func auditFilterFromQuery(c *gin.Context) database.ListAuditLogsFilter { + filter := database.ListAuditLogsFilter{ + Level: c.Query("level"), + Category: c.Query("category"), + Action: c.Query("action"), + Result: c.Query("result"), + Query: c.Query("q"), + ResourceType: c.Query("resource_type"), + ResourceID: c.Query("resource_id"), + } + if since := c.Query("since"); since != "" { + if t, err := time.Parse(time.RFC3339, since); err == nil { + filter.Since = &t + } + } + if until := c.Query("until"); until != "" { + if t, err := time.Parse(time.RFC3339, until); err == nil { + filter.Until = &t + } + } + return filter +} + +func auditPaginationFromQuery(c *gin.Context) (page, pageSize int) { + page = 1 + pageSize = 20 + if p, err := strconv.Atoi(c.DefaultQuery("page", "1")); err == nil && p > 0 { + page = p + } + if ps, err := strconv.Atoi(c.DefaultQuery("page_size", "20")); err == nil && ps > 0 { + pageSize = ps + if pageSize > 100 { + pageSize = 100 + } + } + return page, pageSize +} diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 508553c1..a0e940d2 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "cyberstrike-ai/internal/audit" "cyberstrike-ai/internal/config" "cyberstrike-ai/internal/security" @@ -18,6 +19,12 @@ type AuthHandler struct { config *config.Config configPath string logger *zap.Logger + audit *audit.Service +} + +// SetAudit wires platform audit logging. +func (h *AuthHandler) SetAudit(s *audit.Service) { + h.audit = s } // NewAuthHandler creates a new AuthHandler. @@ -49,10 +56,32 @@ func (h *AuthHandler) Login(c *gin.Context) { token, expiresAt, err := h.manager.Authenticate(req.Password) if err != nil { + if h.audit != nil { + h.audit.Record(c, audit.Entry{ + Level: "warn", + Category: "auth", + Action: "login", + Result: "failure", + Message: "登录失败:密码错误", + }) + } c.JSON(http.StatusUnauthorized, gin.H{"error": "密码错误"}) return } + if h.audit != nil { + h.audit.Record(c, audit.Entry{ + Category: "auth", + Action: "login", + Result: "success", + SessionHint: audit.HintFromToken(token), + Message: "登录成功", + Detail: map[string]interface{}{ + "expires_at": expiresAt.UTC().Format(time.RFC3339), + }, + }) + } + c.JSON(http.StatusOK, gin.H{ "token": token, "expires_at": expiresAt.UTC().Format(time.RFC3339), @@ -73,6 +102,14 @@ func (h *AuthHandler) Logout(c *gin.Context) { } h.manager.RevokeToken(token) + if h.audit != nil { + h.audit.Record(c, audit.Entry{ + Category: "auth", + Action: "logout", + Result: "success", + Message: "退出登录", + }) + } c.JSON(http.StatusOK, gin.H{"message": "已退出登录"}) } @@ -103,6 +140,15 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) { } if !h.manager.CheckPassword(oldPassword) { + if h.audit != nil { + h.audit.Record(c, audit.Entry{ + Level: "warn", + Category: "auth", + Action: "change_password", + Result: "failure", + Message: "修改密码失败:当前密码不正确", + }) + } c.JSON(http.StatusBadRequest, gin.H{"error": "当前密码不正确"}) return } @@ -132,6 +178,15 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) { h.logger.Info("登录密码已更新,所有会话已失效") } + if h.audit != nil { + h.audit.Record(c, audit.Entry{ + Category: "auth", + Action: "change_password", + Result: "success", + Message: "登录密码已修改", + }) + } + c.JSON(http.StatusOK, gin.H{"message": "密码已更新,请使用新密码重新登录"}) } diff --git a/internal/handler/c2.go b/internal/handler/c2.go index 22639b50..78d48b32 100644 --- a/internal/handler/c2.go +++ b/internal/handler/c2.go @@ -13,6 +13,7 @@ import ( "sync/atomic" "time" + "cyberstrike-ai/internal/audit" "cyberstrike-ai/internal/c2" "cyberstrike-ai/internal/database" @@ -25,6 +26,12 @@ import ( type C2Handler struct { mgrPtr atomic.Pointer[c2.Manager] logger *zap.Logger + audit *audit.Service +} + +// SetAudit wires platform audit logging. +func (h *C2Handler) SetAudit(s *audit.Service) { + h.audit = s } // NewC2Handler 创建 C2 处理器;manager 可为 nil(功能关闭时) @@ -104,6 +111,11 @@ func (h *C2Handler) CreateListener(c *gin.Context) { implantToken := listener.ImplantToken listener.EncryptionKey = "" listener.ImplantToken = "" + if h.audit != nil { + h.audit.RecordOK(c, "c2", "listener_create", "创建 C2 监听器", "c2_listener", listener.ID, map[string]interface{}{ + "name": listener.Name, "bind": listener.BindHost, "port": listener.BindPort, + }) + } c.JSON(http.StatusOK, gin.H{"listener": listener, "implant_token": implantToken}) } @@ -205,6 +217,9 @@ func (h *C2Handler) DeleteListener(c *gin.Context) { c.JSON(code, gin.H{"error": err.Error()}) return } + if h.audit != nil { + h.audit.RecordOK(c, "c2", "listener_delete", "删除 C2 监听器", "c2_listener", id, nil) + } c.JSON(http.StatusOK, gin.H{"deleted": true}) } @@ -222,6 +237,9 @@ func (h *C2Handler) StartListener(c *gin.Context) { } listener.EncryptionKey = "" listener.ImplantToken = "" + if h.audit != nil { + h.audit.RecordOK(c, "c2", "listener_start", "启动 C2 监听器", "c2_listener", id, nil) + } c.JSON(http.StatusOK, gin.H{"listener": listener}) } @@ -236,6 +254,9 @@ func (h *C2Handler) StopListener(c *gin.Context) { c.JSON(code, gin.H{"error": err.Error()}) return } + if h.audit != nil { + h.audit.RecordOK(c, "c2", "listener_stop", "停止 C2 监听器", "c2_listener", id, nil) + } c.JSON(http.StatusOK, gin.H{"stopped": true}) } @@ -297,6 +318,9 @@ func (h *C2Handler) DeleteSession(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + if h.audit != nil { + h.audit.RecordOK(c, "c2", "session_delete", "删除 C2 会话", "c2_session", id, nil) + } c.JSON(http.StatusOK, gin.H{"deleted": true}) } @@ -407,6 +431,11 @@ func (h *C2Handler) DeleteTasks(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + if h.audit != nil { + h.audit.RecordOK(c, "c2", "task_delete", "批量删除 C2 任务", "c2_task", "", map[string]interface{}{ + "count": n, "ids": req.IDs, + }) + } c.JSON(http.StatusOK, gin.H{"deleted": n}) } @@ -457,6 +486,11 @@ func (h *C2Handler) CreateTask(c *gin.Context) { c.JSON(code, gin.H{"error": err.Error()}) return } + if h.audit != nil { + h.audit.RecordOK(c, "c2", "task_create", "创建 C2 任务", "c2_task", task.ID, map[string]interface{}{ + "session_id": req.SessionID, "task_type": req.TaskType, + }) + } c.JSON(http.StatusOK, gin.H{"task": task}) } @@ -471,6 +505,9 @@ func (h *C2Handler) CancelTask(c *gin.Context) { c.JSON(code, gin.H{"error": err.Error()}) return } + if h.audit != nil { + h.audit.RecordOK(c, "c2", "task_cancel", "取消 C2 任务", "c2_task", id, nil) + } c.JSON(http.StatusOK, gin.H{"cancelled": true}) } diff --git a/internal/handler/chat_uploads.go b/internal/handler/chat_uploads.go index c3e25fec..7ca91ebc 100644 --- a/internal/handler/chat_uploads.go +++ b/internal/handler/chat_uploads.go @@ -12,6 +12,8 @@ import ( "time" "unicode/utf8" + "cyberstrike-ai/internal/audit" + "github.com/gin-gonic/gin" "go.uber.org/zap" ) @@ -24,6 +26,12 @@ const ( // ChatUploadsHandler 对话中上传附件(chat_uploads 目录)的管理 API type ChatUploadsHandler struct { logger *zap.Logger + audit *audit.Service +} + +// SetAudit wires platform audit logging. +func (h *ChatUploadsHandler) SetAudit(s *audit.Service) { + h.audit = s } // NewChatUploadsHandler 创建处理器 @@ -230,6 +238,9 @@ func (h *ChatUploadsHandler) Delete(c *gin.Context) { return } } + if h.audit != nil { + h.audit.RecordOK(c, "file", "delete", "删除对话附件", "chat_upload", body.Path, nil) + } c.JSON(http.StatusOK, gin.H{"ok": true}) } @@ -503,6 +514,11 @@ func (h *ChatUploadsHandler) Upload(c *gin.Context) { } rel, _ := filepath.Rel(root, fullPath) absSaved, _ := filepath.Abs(fullPath) + if h.audit != nil { + h.audit.RecordOK(c, "file", "upload", "上传对话附件", "chat_upload", filepath.ToSlash(rel), map[string]interface{}{ + "name": unique, + }) + } c.JSON(http.StatusOK, gin.H{ "ok": true, "relativePath": filepath.ToSlash(rel), diff --git a/internal/handler/config.go b/internal/handler/config.go index f2892aea..21239f7d 100644 --- a/internal/handler/config.go +++ b/internal/handler/config.go @@ -14,6 +14,7 @@ import ( "time" "cyberstrike-ai/internal/agents" + "cyberstrike-ai/internal/audit" "cyberstrike-ai/internal/config" "cyberstrike-ai/internal/knowledge" "cyberstrike-ai/internal/mcp" @@ -87,6 +88,7 @@ type ConfigHandler struct { knowledgeInitializer KnowledgeInitializer // 知识库初始化器(可选) appUpdater AppUpdater // App更新器(可选) robotRestarter RobotRestarter // 机器人连接重启器(可选),ApplyConfig 时重启钉钉/飞书 + audit *audit.Service logger *zap.Logger mu sync.RWMutex lastEmbeddingConfig *config.EmbeddingConfig // 上一次的嵌入模型配置(用于检测变更) @@ -206,6 +208,13 @@ func (h *ConfigHandler) SetRobotRestarter(restarter RobotRestarter) { h.robotRestarter = restarter } +// SetAudit wires platform audit logging. +func (h *ConfigHandler) SetAudit(s *audit.Service) { + h.mu.Lock() + defer h.mu.Unlock() + h.audit = s +} + // ApplyWechatRobotBinding 微信 iLink 扫码绑定成功后写入配置并重启机器人连接 func (h *ConfigHandler) ApplyWechatRobotBinding(wc config.RobotWechatConfig) error { h.mu.Lock() @@ -903,6 +912,9 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) { return } + if h.audit != nil { + h.audit.RecordOK(c, "config", "update", "更新内存配置", "config", "", nil) + } c.JSON(http.StatusOK, gin.H{"message": "配置已更新"}) } @@ -1033,6 +1045,9 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) { h.logger.Info("检测到知识库从禁用变为启用,开始动态初始化知识库组件") if _, err := knowledgeInitializer(); err != nil { h.logger.Error("动态初始化知识库失败", zap.Error(err)) + if h.audit != nil { + h.audit.RecordFail(c, "config", "apply", "应用配置失败:初始化知识库", map[string]interface{}{"error": err.Error()}) + } c.JSON(http.StatusInternalServerError, gin.H{"error": "初始化知识库失败: " + err.Error()}) return } @@ -1067,6 +1082,9 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) { h.logger.Info("开始重新初始化知识库组件(嵌入模型配置已变更)") if _, err := reinitKnowledgeInitializer(); err != nil { h.logger.Error("重新初始化知识库失败", zap.Error(err)) + if h.audit != nil { + h.audit.RecordFail(c, "config", "apply", "应用配置失败:重新初始化知识库", map[string]interface{}{"error": err.Error()}) + } c.JSON(http.StatusInternalServerError, gin.H{"error": "重新初始化知识库失败: " + err.Error()}) return } @@ -1080,6 +1098,9 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) { if c2Rt != nil { if err := c2Rt.ReconcileC2AfterConfigApply(); err != nil { h.logger.Error("C2 配置应用失败", zap.Error(err)) + if h.audit != nil { + h.audit.RecordFail(c, "config", "apply", "应用配置失败:C2", map[string]interface{}{"error": err.Error()}) + } c.JSON(http.StatusInternalServerError, gin.H{"error": "C2 启动失败: " + err.Error()}) return } @@ -1221,6 +1242,20 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) { zap.Int("tools_count", len(h.config.Security.Tools)), ) + if h.audit != nil { + h.audit.Record(c, audit.Entry{ + Category: "config", + Action: "apply", + Result: "success", + Message: "配置已应用", + Detail: map[string]interface{}{ + "tools_count": len(h.config.Security.Tools), + "knowledge_enabled": h.config.Knowledge.Enabled, + "c2_enabled": h.config.C2.EnabledEffective(), + }, + }) + } + c.JSON(http.StatusOK, gin.H{ "message": "配置已应用", "tools_count": len(h.config.Security.Tools), diff --git a/internal/handler/conversation.go b/internal/handler/conversation.go index 2bb5c920..840b31e0 100644 --- a/internal/handler/conversation.go +++ b/internal/handler/conversation.go @@ -5,6 +5,7 @@ import ( "net/http" "strconv" + "cyberstrike-ai/internal/audit" "cyberstrike-ai/internal/database" "github.com/gin-gonic/gin" "go.uber.org/zap" @@ -14,6 +15,12 @@ import ( type ConversationHandler struct { db *database.DB logger *zap.Logger + audit *audit.Service +} + +// SetAudit wires platform audit logging. +func (h *ConversationHandler) SetAudit(s *audit.Service) { + h.audit = s } // NewConversationHandler 创建新的对话处理器 @@ -189,6 +196,17 @@ func (h *ConversationHandler) DeleteConversation(c *gin.Context) { return } + if h.audit != nil { + h.audit.Record(c, audit.Entry{ + Category: "conversation", + Action: "delete", + Result: "success", + ResourceType: "conversation", + ResourceID: id, + Message: "删除对话", + }) + } + c.JSON(http.StatusOK, gin.H{"message": "删除成功"}) } @@ -227,6 +245,12 @@ func (h *ConversationHandler) DeleteConversationTurn(c *gin.Context) { return } + if h.audit != nil { + h.audit.RecordOK(c, "conversation", "delete_turn", "删除对话轮次", "conversation", conversationID, map[string]interface{}{ + "message_id": req.MessageID, + "deleted": len(deletedIDs), + }) + } c.JSON(http.StatusOK, gin.H{ "deletedMessageIds": deletedIDs, "message": "ok", diff --git a/internal/handler/external_mcp.go b/internal/handler/external_mcp.go index e1fcab1e..662b7463 100644 --- a/internal/handler/external_mcp.go +++ b/internal/handler/external_mcp.go @@ -6,6 +6,7 @@ import ( "os" "sync" + "cyberstrike-ai/internal/audit" "cyberstrike-ai/internal/config" "cyberstrike-ai/internal/mcp" @@ -20,9 +21,15 @@ type ExternalMCPHandler struct { config *config.Config configPath string logger *zap.Logger + audit *audit.Service mu sync.RWMutex } +// SetAudit wires platform audit logging. +func (h *ExternalMCPHandler) SetAudit(s *audit.Service) { + h.audit = s +} + // NewExternalMCPHandler 创建外部MCP处理器 func NewExternalMCPHandler(manager *mcp.ExternalMCPManager, cfg *config.Config, configPath string, logger *zap.Logger) *ExternalMCPHandler { return &ExternalMCPHandler{ @@ -180,6 +187,16 @@ func (h *ExternalMCPHandler) AddOrUpdateExternalMCP(c *gin.Context) { } h.logger.Info("外部MCP配置已更新", zap.String("name", name)) + if h.audit != nil { + h.audit.Record(c, audit.Entry{ + Category: "external_mcp", + Action: "upsert", + Result: "success", + ResourceType: "external_mcp", + ResourceID: name, + Message: "更新外部 MCP 配置", + }) + } c.JSON(http.StatusOK, gin.H{"message": "配置已更新"}) } @@ -209,6 +226,16 @@ func (h *ExternalMCPHandler) DeleteExternalMCP(c *gin.Context) { } h.logger.Info("外部MCP配置已删除", zap.String("name", name)) + if h.audit != nil { + h.audit.Record(c, audit.Entry{ + Category: "external_mcp", + Action: "delete", + Result: "success", + ResourceType: "external_mcp", + ResourceID: name, + Message: "删除外部 MCP 配置", + }) + } c.JSON(http.StatusOK, gin.H{"message": "配置已删除"}) } diff --git a/internal/handler/hitl.go b/internal/handler/hitl.go index 8d6e3469..96fc9fa9 100644 --- a/internal/handler/hitl.go +++ b/internal/handler/hitl.go @@ -616,6 +616,11 @@ func (h *AgentHandler) DecideHITLInterrupt(c *gin.Context) { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) return } + if h.audit != nil { + h.audit.RecordOK(c, "hitl", "decision", "HITL 审批决策", "hitl_interrupt", req.InterruptID, map[string]interface{}{ + "decision": req.Decision, + }) + } c.JSON(http.StatusOK, gin.H{"ok": true}) } diff --git a/internal/handler/knowledge.go b/internal/handler/knowledge.go index 76d7b974..eee106ac 100644 --- a/internal/handler/knowledge.go +++ b/internal/handler/knowledge.go @@ -6,6 +6,7 @@ import ( "net/http" "time" + "cyberstrike-ai/internal/audit" "cyberstrike-ai/internal/database" "cyberstrike-ai/internal/knowledge" @@ -20,6 +21,12 @@ type KnowledgeHandler struct { indexer *knowledge.Indexer db *database.DB logger *zap.Logger + audit *audit.Service +} + +// SetAudit wires platform audit logging. +func (h *KnowledgeHandler) SetAudit(s *audit.Service) { + h.audit = s } // NewKnowledgeHandler 创建新的知识库处理器 @@ -303,6 +310,9 @@ func (h *KnowledgeHandler) DeleteItem(c *gin.Context) { return } + if h.audit != nil { + h.audit.RecordOK(c, "knowledge", "item_delete", "删除知识项", "knowledge_item", id, nil) + } c.JSON(http.StatusOK, gin.H{"message": "删除成功"}) } @@ -316,6 +326,9 @@ func (h *KnowledgeHandler) RebuildIndex(c *gin.Context) { } }() + if h.audit != nil { + h.audit.RecordOK(c, "knowledge", "index_rebuild", "重建知识库索引", "knowledge", "", nil) + } c.JSON(http.StatusOK, gin.H{"message": "索引重建已开始,将在后台进行"}) } diff --git a/internal/handler/markdown_agents.go b/internal/handler/markdown_agents.go index bc7abb47..70ba216d 100644 --- a/internal/handler/markdown_agents.go +++ b/internal/handler/markdown_agents.go @@ -9,6 +9,7 @@ import ( "strings" "cyberstrike-ai/internal/agents" + "cyberstrike-ai/internal/audit" "cyberstrike-ai/internal/config" "github.com/gin-gonic/gin" @@ -18,7 +19,8 @@ var markdownAgentFilenameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]*\.m // MarkdownAgentsHandler 管理 agents 目录下子代理 Markdown(增删改查)。 type MarkdownAgentsHandler struct { - dir string + dir string + audit *audit.Service } // NewMarkdownAgentsHandler dir 须为已解析的绝对路径。 @@ -26,6 +28,11 @@ func NewMarkdownAgentsHandler(dir string) *MarkdownAgentsHandler { return &MarkdownAgentsHandler{dir: strings.TrimSpace(dir)} } +// SetAudit wires platform audit logging. +func (h *MarkdownAgentsHandler) SetAudit(s *audit.Service) { + h.audit = s +} + func (h *MarkdownAgentsHandler) safeJoin(filename string) (string, error) { filename = strings.TrimSpace(filename) if filename == "" || !markdownAgentFilenameRe.MatchString(filename) { @@ -227,6 +234,9 @@ func (h *MarkdownAgentsHandler) CreateMarkdownAgent(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + if h.audit != nil { + h.audit.RecordOK(c, "agent", "markdown_create", "创建 Markdown 子代理", "markdown_agent", filepath.Base(path), nil) + } c.JSON(http.StatusOK, gin.H{"filename": filepath.Base(path), "message": "已创建"}) } @@ -294,6 +304,9 @@ func (h *MarkdownAgentsHandler) UpdateMarkdownAgent(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + if h.audit != nil { + h.audit.RecordOK(c, "agent", "markdown_update", "更新 Markdown 子代理", "markdown_agent", filename, nil) + } c.JSON(http.StatusOK, gin.H{"message": "已保存"}) } @@ -313,5 +326,8 @@ func (h *MarkdownAgentsHandler) DeleteMarkdownAgent(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + if h.audit != nil { + h.audit.RecordOK(c, "agent", "markdown_delete", "删除 Markdown 子代理", "markdown_agent", filename, nil) + } c.JSON(http.StatusOK, gin.H{"message": "已删除"}) } diff --git a/internal/handler/monitor.go b/internal/handler/monitor.go index a9ba7119..334494f9 100644 --- a/internal/handler/monitor.go +++ b/internal/handler/monitor.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "cyberstrike-ai/internal/audit" "cyberstrike-ai/internal/database" "cyberstrike-ai/internal/mcp" "cyberstrike-ai/internal/security" @@ -23,6 +24,12 @@ type MonitorHandler struct { executor *security.Executor db *database.DB logger *zap.Logger + audit *audit.Service +} + +// SetAudit wires platform audit logging. +func (h *MonitorHandler) SetAudit(s *audit.Service) { + h.audit = s } // NewMonitorHandler 创建新的监控处理器 @@ -365,6 +372,11 @@ func (h *MonitorHandler) DeleteExecution(c *gin.Context) { } h.logger.Info("执行记录已从数据库删除", zap.String("executionId", id), zap.String("toolName", exec.ToolName)) + if h.audit != nil { + h.audit.RecordOK(c, "tool", "execution_delete", "删除工具执行记录", "tool_execution", id, map[string]interface{}{ + "tool_name": exec.ToolName, + }) + } c.JSON(http.StatusOK, gin.H{"message": "执行记录已删除"}) return } @@ -440,6 +452,11 @@ func (h *MonitorHandler) DeleteExecutions(c *gin.Context) { } h.logger.Info("批量删除执行记录成功", zap.Int("count", len(request.IDs))) + if h.audit != nil { + h.audit.RecordOK(c, "tool", "execution_delete_batch", "批量删除工具执行记录", "tool_execution", "", map[string]interface{}{ + "count": len(request.IDs), + }) + } c.JSON(http.StatusOK, gin.H{"message": "成功删除执行记录", "deleted": len(executions)}) return } diff --git a/internal/handler/role.go b/internal/handler/role.go index 85411b19..1c061256 100644 --- a/internal/handler/role.go +++ b/internal/handler/role.go @@ -8,6 +8,7 @@ import ( "regexp" "strings" + "cyberstrike-ai/internal/audit" "cyberstrike-ai/internal/config" "gopkg.in/yaml.v3" @@ -21,6 +22,12 @@ type RoleHandler struct { config *config.Config configPath string logger *zap.Logger + audit *audit.Service +} + +// SetAudit wires platform audit logging. +func (h *RoleHandler) SetAudit(s *audit.Service) { + h.audit = s } // NewRoleHandler 创建新的角色处理器 @@ -174,6 +181,9 @@ func (h *RoleHandler) UpdateRole(c *gin.Context) { } h.logger.Info("更新角色", zap.String("oldKey", roleName), zap.String("newKey", finalKey), zap.String("name", req.Name)) + if h.audit != nil { + h.audit.RecordOK(c, "role", "update", "更新角色", "role", finalKey, map[string]interface{}{"name": req.Name}) + } c.JSON(http.StatusOK, gin.H{ "message": "角色已更新", "role": req, @@ -219,6 +229,9 @@ func (h *RoleHandler) CreateRole(c *gin.Context) { } h.logger.Info("创建角色", zap.String("roleName", req.Name)) + if h.audit != nil { + h.audit.RecordOK(c, "role", "create", "创建角色", "role", req.Name, nil) + } c.JSON(http.StatusOK, gin.H{ "message": "角色已创建", "role": req, @@ -287,6 +300,9 @@ func (h *RoleHandler) DeleteRole(c *gin.Context) { } h.logger.Info("删除角色", zap.String("roleName", roleName)) + if h.audit != nil { + h.audit.RecordOK(c, "role", "delete", "删除角色", "role", roleName, nil) + } c.JSON(http.StatusOK, gin.H{ "message": "角色已删除", }) diff --git a/internal/handler/skills.go b/internal/handler/skills.go index 52f2dc99..4246c297 100644 --- a/internal/handler/skills.go +++ b/internal/handler/skills.go @@ -8,6 +8,7 @@ import ( "regexp" "strings" + "cyberstrike-ai/internal/audit" "cyberstrike-ai/internal/config" "cyberstrike-ai/internal/database" "cyberstrike-ai/internal/skillpackage" @@ -23,6 +24,12 @@ type SkillsHandler struct { configPath string logger *zap.Logger db *database.DB // 数据库连接(遗留统计;MCP list/read 已移除) + audit *audit.Service +} + +// SetAudit wires platform audit logging. +func (h *SkillsHandler) SetAudit(s *audit.Service) { + h.audit = s } // NewSkillsHandler 创建新的Skills处理器 @@ -365,6 +372,9 @@ func (h *SkillsHandler) CreateSkill(c *gin.Context) { } h.logger.Info("创建skill成功", zap.String("skill", req.Name)) + if h.audit != nil { + h.audit.RecordOK(c, "skill", "create", "创建 Skill", "skill", req.Name, nil) + } c.JSON(http.StatusOK, gin.H{ "message": "skill已创建", "skill": map[string]interface{}{ @@ -425,6 +435,9 @@ func (h *SkillsHandler) UpdateSkill(c *gin.Context) { } h.logger.Info("更新skill成功", zap.String("skill", skillName)) + if h.audit != nil { + h.audit.RecordOK(c, "skill", "update", "更新 Skill", "skill", skillName, nil) + } c.JSON(http.StatusOK, gin.H{ "message": "skill已更新", }) @@ -459,6 +472,11 @@ func (h *SkillsHandler) DeleteSkill(c *gin.Context) { } h.logger.Info("删除skill成功", zap.String("skill", skillName)) + if h.audit != nil { + h.audit.RecordOK(c, "skill", "delete", "删除 Skill", "skill", skillName, map[string]interface{}{ + "affected_roles": affectedRoles, + }) + } c.JSON(http.StatusOK, gin.H{ "message": responseMsg, "affected_roles": affectedRoles, diff --git a/internal/handler/terminal.go b/internal/handler/terminal.go index a17d361d..3c3c53fb 100644 --- a/internal/handler/terminal.go +++ b/internal/handler/terminal.go @@ -253,5 +253,5 @@ func (h *TerminalHandler) RunCommandStream(c *gin.Context) { flusher.Flush() } - runCommandStreamImpl(cmd, sendEvent, ctx) + _ = runCommandStreamImpl(cmd, sendEvent, ctx) } diff --git a/internal/handler/terminal_stream_unix.go b/internal/handler/terminal_stream_unix.go index 9b543b6c..e8ab8c47 100644 --- a/internal/handler/terminal_stream_unix.go +++ b/internal/handler/terminal_stream_unix.go @@ -15,11 +15,11 @@ const ptyCols = 256 const ptyRows = 40 // runCommandStreamImpl 在 Unix 下用 PTY 执行,使 ping 等命令按终端宽度排版(isatty 为真) -func runCommandStreamImpl(cmd *exec.Cmd, sendEvent func(streamEvent), ctx context.Context) { +func runCommandStreamImpl(cmd *exec.Cmd, sendEvent func(streamEvent), ctx context.Context) int { ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: ptyCols, Rows: ptyRows}) if err != nil { sendEvent(streamEvent{T: "exit", C: -1}) - return + return -1 } defer ptmx.Close() @@ -43,4 +43,5 @@ func runCommandStreamImpl(cmd *exec.Cmd, sendEvent func(streamEvent), ctx contex exitCode = -1 } sendEvent(streamEvent{T: "exit", C: exitCode}) + return exitCode } diff --git a/internal/handler/terminal_stream_windows.go b/internal/handler/terminal_stream_windows.go index 9f69303c..24e430a5 100644 --- a/internal/handler/terminal_stream_windows.go +++ b/internal/handler/terminal_stream_windows.go @@ -11,20 +11,20 @@ import ( ) // runCommandStreamImpl 在 Windows 下用 stdout/stderr 管道执行 -func runCommandStreamImpl(cmd *exec.Cmd, sendEvent func(streamEvent), ctx context.Context) { +func runCommandStreamImpl(cmd *exec.Cmd, sendEvent func(streamEvent), ctx context.Context) int { stdoutPipe, err := cmd.StdoutPipe() if err != nil { sendEvent(streamEvent{T: "exit", C: -1}) - return + return -1 } stderrPipe, err := cmd.StderrPipe() if err != nil { sendEvent(streamEvent{T: "exit", C: -1}) - return + return -1 } if err := cmd.Start(); err != nil { sendEvent(streamEvent{T: "exit", C: -1}) - return + return -1 } normalize := func(s string) string { @@ -62,4 +62,5 @@ func runCommandStreamImpl(cmd *exec.Cmd, sendEvent func(streamEvent), ctx contex exitCode = -1 } sendEvent(streamEvent{T: "exit", C: exitCode}) + return exitCode } diff --git a/internal/handler/vulnerability.go b/internal/handler/vulnerability.go index f9a578bd..fd8f7819 100644 --- a/internal/handler/vulnerability.go +++ b/internal/handler/vulnerability.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "cyberstrike-ai/internal/audit" "cyberstrike-ai/internal/database" "github.com/gin-gonic/gin" "go.uber.org/zap" @@ -16,6 +17,12 @@ import ( type VulnerabilityHandler struct { db *database.DB logger *zap.Logger + audit *audit.Service +} + +// SetAudit wires platform audit logging. +func (h *VulnerabilityHandler) SetAudit(s *audit.Service) { + h.audit = s } // NewVulnerabilityHandler 创建新的漏洞处理器 @@ -72,6 +79,11 @@ func (h *VulnerabilityHandler) CreateVulnerability(c *gin.Context) { return } + if h.audit != nil { + h.audit.RecordOK(c, "vulnerability", "create", "创建漏洞记录", "vulnerability", created.ID, map[string]interface{}{ + "severity": created.Severity, "title": created.Title, + }) + } c.JSON(http.StatusOK, created) } @@ -249,6 +261,11 @@ func (h *VulnerabilityHandler) UpdateVulnerability(c *gin.Context) { return } + if h.audit != nil { + h.audit.RecordOK(c, "vulnerability", "update", "更新漏洞记录", "vulnerability", id, map[string]interface{}{ + "severity": updated.Severity, "status": updated.Status, + }) + } c.JSON(http.StatusOK, updated) } @@ -262,6 +279,17 @@ func (h *VulnerabilityHandler) DeleteVulnerability(c *gin.Context) { return } + if h.audit != nil { + h.audit.Record(c, audit.Entry{ + Category: "vulnerability", + Action: "delete", + Result: "success", + ResourceType: "vulnerability", + ResourceID: id, + Message: "删除漏洞记录", + }) + } + c.JSON(http.StatusOK, gin.H{"message": "删除成功"}) } diff --git a/internal/handler/webshell.go b/internal/handler/webshell.go index f94a564e..3b95b896 100644 --- a/internal/handler/webshell.go +++ b/internal/handler/webshell.go @@ -12,6 +12,7 @@ import ( "time" "unicode/utf8" + "cyberstrike-ai/internal/audit" "cyberstrike-ai/internal/database" "github.com/gin-gonic/gin" @@ -304,6 +305,12 @@ type WebShellHandler struct { logger *zap.Logger client *http.Client db *database.DB + audit *audit.Service +} + +// SetAudit wires platform audit logging. +func (h *WebShellHandler) SetAudit(s *audit.Service) { + h.audit = s } // NewWebShellHandler 创建 WebShell 处理器,db 可为 nil(连接配置接口将不可用) @@ -403,6 +410,15 @@ func (h *WebShellHandler) CreateConnection(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + if h.audit != nil { + host := req.URL + if u, err := url.Parse(req.URL); err == nil { + host = u.Host + } + h.audit.RecordOK(c, "webshell", "connection_create", "创建 WebShell 连接", "webshell_connection", conn.ID, map[string]interface{}{ + "host": host, "type": shellType, + }) + } c.JSON(http.StatusOK, conn) } @@ -485,6 +501,9 @@ func (h *WebShellHandler) DeleteConnection(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + if h.audit != nil { + h.audit.RecordOK(c, "webshell", "connection_delete", "删除 WebShell 连接", "webshell_connection", id, nil) + } c.JSON(http.StatusOK, gin.H{"ok": true}) } @@ -714,8 +733,9 @@ func (h *WebShellHandler) Exec(c *gin.Context) { output := decodeWebshellOutput(out, req.Encoding) httpCode := resp.StatusCode + ok := resp.StatusCode == http.StatusOK c.JSON(http.StatusOK, ExecResponse{ - OK: resp.StatusCode == http.StatusOK, + OK: ok, Output: output, HTTPCode: httpCode, })