diff --git a/internal/robot/proactive.go b/internal/robot/proactive.go new file mode 100644 index 00000000..d600df8b --- /dev/null +++ b/internal/robot/proactive.go @@ -0,0 +1,192 @@ +package robot + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "cyberstrike-ai/internal/config" + + "github.com/bwmarrin/discordgo" + lark "github.com/larksuite/oapi-sdk-go/v3" + larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" + "github.com/slack-go/slack" +) + +// SendProactive sends a message without an inbound event. Platforms whose +// reply credentials are event-scoped deliberately return an error instead of +// pretending delivery succeeded. +func SendProactive(ctx context.Context, cfg config.RobotsConfig, platform, externalUserID, message string) error { + platform = strings.ToLower(strings.TrimSpace(platform)) + userID := robotIdentityUserPart(externalUserID) + if userID == "" { + return fmt.Errorf("invalid robot recipient") + } + switch platform { + case "telegram": + if !cfg.Telegram.Enabled || strings.TrimSpace(cfg.Telegram.BotToken) == "" { + return fmt.Errorf("telegram is not configured") + } + id, err := strconv.ParseInt(userID, 10, 64) + if err != nil { + return fmt.Errorf("invalid telegram user id: %w", err) + } + return telegramSendReply(ctx, nilSafeHTTPClient(), strings.TrimSpace(cfg.Telegram.BotToken), id, message) + case "slack": + if !cfg.Slack.Enabled || strings.TrimSpace(cfg.Slack.BotToken) == "" { + return fmt.Errorf("slack is not configured") + } + api := slack.New(strings.TrimSpace(cfg.Slack.BotToken)) + channel, _, _, err := api.OpenConversationContext(ctx, &slack.OpenConversationParameters{Users: []string{userID}}) + if err != nil { + return err + } + for _, chunk := range splitTextChunks(message, slackMaxMessageRunes) { + if _, _, err = api.PostMessageContext(ctx, channel.ID, slack.MsgOptionText(chunk, false)); err != nil { + return err + } + } + return nil + case "discord": + if !cfg.Discord.Enabled || strings.TrimSpace(cfg.Discord.BotToken) == "" { + return fmt.Errorf("discord is not configured") + } + token := strings.TrimSpace(cfg.Discord.BotToken) + if !strings.HasPrefix(token, "Bot ") { + token = "Bot " + token + } + session, err := discordgo.New(token) + if err != nil { + return err + } + channel, err := session.UserChannelCreate(userID) + if err != nil { + return err + } + for _, chunk := range splitTextChunks(message, discordMaxMessageRunes) { + if _, err = session.ChannelMessageSend(channel.ID, chunk); err != nil { + return err + } + } + return nil + case "wecom": + return sendWecomProactive(ctx, cfg.Wecom, userID, message) + case "lark": + return sendLarkProactive(ctx, cfg.Lark, externalUserID, message) + default: + return fmt.Errorf("platform %s does not support proactive alerts yet", platform) + } +} + +func SupportsProactive(platform string) bool { + switch strings.ToLower(strings.TrimSpace(platform)) { + case "telegram", "slack", "discord", "wecom", "lark": + return true + default: + return false + } +} + +func nilSafeHTTPClient() *http.Client { return &http.Client{Timeout: 15 * time.Second} } + +func sendWecomProactive(ctx context.Context, cfg config.RobotWecomConfig, userID, message string) error { + if !cfg.Enabled || strings.TrimSpace(cfg.CorpID) == "" || strings.TrimSpace(cfg.Secret) == "" || cfg.AgentID == 0 { + return fmt.Errorf("wecom proactive API is not configured") + } + tokenURL := "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=" + cfg.CorpID + "&corpsecret=" + cfg.Secret + req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil) + if err != nil { + return err + } + resp, err := nilSafeHTTPClient().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + var tokenResp struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + AccessToken string `json:"access_token"` + } + if err := json.Unmarshal(body, &tokenResp); err != nil { + return err + } + if tokenResp.ErrCode != 0 || tokenResp.AccessToken == "" { + return fmt.Errorf("wecom token: %s", tokenResp.ErrMsg) + } + payload, _ := json.Marshal(map[string]interface{}{ + "touser": userID, "msgtype": "text", "agentid": cfg.AgentID, + "text": map[string]string{"content": message}, "safe": 0, + }) + sendReq, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token="+tokenResp.AccessToken, bytes.NewReader(payload)) + if err != nil { + return err + } + sendReq.Header.Set("Content-Type", "application/json") + sendResp, err := nilSafeHTTPClient().Do(sendReq) + if err != nil { + return err + } + defer sendResp.Body.Close() + result, _ := io.ReadAll(sendResp.Body) + var parsed struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + if err := json.Unmarshal(result, &parsed); err != nil { + return err + } + if parsed.ErrCode != 0 { + return fmt.Errorf("wecom send: %s", parsed.ErrMsg) + } + return nil +} + +func sendLarkProactive(ctx context.Context, cfg config.RobotLarkConfig, identity, message string) error { + if !cfg.Enabled || strings.TrimSpace(cfg.AppID) == "" || strings.TrimSpace(cfg.AppSecret) == "" { + return fmt.Errorf("lark is not configured") + } + receiveIDType, receiveID := "user_id", robotIdentityUserPart(identity) + if idx := strings.LastIndex(identity, "|o:"); idx >= 0 { + receiveIDType, receiveID = "open_id", strings.TrimSpace(identity[idx+3:]) + } + if idx := strings.LastIndex(identity, "|n:"); idx >= 0 { + receiveIDType, receiveID = "union_id", strings.TrimSpace(identity[idx+3:]) + } + if receiveID == "" { + return fmt.Errorf("invalid lark recipient") + } + content, _ := json.Marshal(larkTextContent{Text: message}) + client := lark.NewClient(cfg.AppID, cfg.AppSecret) + resp, err := client.Im.Message.Create(ctx, larkim.NewCreateMessageReqBuilder(). + ReceiveIdType(receiveIDType). + Body(larkim.NewCreateMessageReqBodyBuilder().ReceiveId(receiveID).MsgType(larkim.MsgTypeText).Content(string(content)).Build()).Build()) + if err != nil { + return err + } + if resp == nil || !resp.Success() { + return fmt.Errorf("lark send failed") + } + return nil +} + +func robotIdentityUserPart(identity string) string { + identity = strings.TrimSpace(identity) + if i := strings.LastIndex(identity, "|u:"); i >= 0 { + return strings.TrimSpace(identity[i+3:]) + } + if strings.HasPrefix(identity, "u:") { + return strings.TrimSpace(identity[2:]) + } + return identity +} diff --git a/internal/security/rbac_middleware.go b/internal/security/rbac_middleware.go index 28c289ce..362ea64b 100644 --- a/internal/security/rbac_middleware.go +++ b/internal/security/rbac_middleware.go @@ -151,6 +151,9 @@ func permissionForRequest(method, fullPath string) string { return crudPermission(method, "knowledge") case strings.HasPrefix(path, "/vulnerabilities"): return crudPermission(method, "vulnerability") + case strings.HasPrefix(path, "/vulnerability-alerts"): + // This endpoint only changes the authenticated user's own preference. + return "vulnerability:read" case strings.HasPrefix(path, "/projects"): return crudPermission(method, "project") case strings.HasPrefix(path, "/webshell"):