package notify import ( "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "strconv" "time" ) // feishuChannel implements the Feishu (incl. Lark) custom bot, using interactive // cards. // // Platform traits: // - The signing algorithm is **different** from DingTalk's and extremely easy to // get wrong; see the feishuSign comment. // - Like DingTalk, business errors are stuffed into the body of an HTTP 200 // (code != 0). // - Card headers support color templates; mapping severity to colors lets people // spot severity at a glance in the message list. type feishuChannel struct{} func (feishuChannel) Kind() string { return KindFeishu } // Feishu custom bots allow about 5 requests/second, i.e. 100 per minute. func (feishuChannel) DefaultRatePerMin() int { return 100 } // The last segment of the webhook URL is the bot's unique identifier and counts // as a credential. func (feishuChannel) SecretKeys() []string { return []string{"webhook", "secret"} } // Likewise: changing the webhook URL requires re-declaring the signing secret for // the new address. func (feishuChannel) DestinationKeys() []string { return []string{"webhook"} } func (feishuChannel) Validate(cfg map[string]any) error { hook := cfgString(cfg, "webhook") if hook == "" { return errors.New("missing Webhook URL") } if err := validateHTTPURL(hook); err != nil { return fmt.Errorf("invalid webhook URL: %w", err) } return nil } func (c feishuChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) { if err := c.Validate(cfg); err != nil { return 0, Permanent(err) } card, kept := feishuCard(m) payload := map[string]any{ "msg_type": "interactive", "card": card, } // Signing parameters sit at the same level as the message, and appear only // when a secret is configured. if secret := cfgString(cfg, "secret"); secret != "" { ts := strconv.FormatInt(time.Now().Unix(), 10) payload["timestamp"] = ts payload["sign"] = feishuSign(ts, secret) } raw, err := doJSON(ctx, "POST", cfgString(cfg, "webhook"), nil, payload) if err != nil { return 0, err } var res struct { Code int `json:"code"` Msg string `json:"msg"` // Some versions of the Feishu hook use this set of field names; support both. StatusCode int `json:"StatusCode"` StatusMessage string `json:"StatusMessage"` } if err := json.Unmarshal(raw, &res); err != nil { return 0, fmt.Errorf("failed to parse Feishu response: %w (%s)", err, snippet(raw)) } if res.Code != 0 { return 0, Permanent(fmt.Errorf("Feishu returned error %d: %s", res.Code, res.Msg)) } if res.StatusCode != 0 { return 0, Permanent(fmt.Errorf("Feishu returned error %d: %s", res.StatusCode, res.StatusMessage)) } return kept, nil } // feishuSign computes the signature per Feishu's official rules. // // This is especially easy to get wrong: the official sample is // // hmac.new(string_to_sign.encode(), digestmod=sha256) // // That is, **key = timestamp + "\n" + secret, with an empty message** — not the // intuitive "key=secret, message=stringToSign", which is exactly DingTalk's // algorithm. The two algorithms are mirror images of each other, and copying the // other platform's implementation always fails signature verification (error // 19021). func feishuSign(timestamp, secret string) string { stringToSign := timestamp + "\n" + secret mac := hmac.New(sha256.New, []byte(stringToSign)) return base64.StdEncoding.EncodeToString(mac.Sum(nil)) } // feishuSeverityTemplate maps vulnerability severity to a card header color // template. Unknown severities use grey — not blue, to avoid confusion with low. func feishuSeverityTemplate(severity string) string { switch severity { case "critical": return "red" case "high": return "orange" case "medium": return "yellow" case "low": return "blue" default: return "grey" } } // feishuMaxCardBytes is a conservative cap on card content. Feishu enforces a // size limit on cards and rejects the whole message when exceeded; pick a value // clearly below the official limit to also cover JSON wrapping overhead. const feishuMaxCardBytes = 24000 // feishuCard builds the interactive card and returns it along with the **number // of items actually written**. kept serves the same purpose as in markdownBody: // only items that actually made it into the card should be marked as sent. func feishuCard(m Message) (map[string]any, int) { elements := []any{} kept := 0 if m.Batch { // Pack whole items first, then assemble the header: the header must state // "the remaining N items will continue in the next message", and N must // come from the number of items that actually fit. kept = packItemCount(m.Items, feishuMaxCardBytes, markdownReservedBytes, "", byteSize, func(it Item, idx int) string { return feishuBatchLine(it, idx+1) }) items := m.Items[:kept] elements = append(elements, feishuMarkdownDiv(markdownBatchIntro(m, items, len(m.Items)))) for i, it := range items { elements = append(elements, feishuMarkdownDiv(feishuBatchLine(it, i+1))) } if m.HomeURL != "" { elements = append(elements, feishuButton("View all on the platform", m.HomeURL)) } } else if len(m.Items) > 0 { kept = 1 it := m.Items[0] elements = append(elements, feishuMarkdownDiv(feishuItemLines(it))) if it.DetailURL != "" { elements = append(elements, feishuButton("View details", it.DetailURL)) } } card := map[string]any{ "config": map[string]any{"wide_screen_mode": true}, "header": map[string]any{"title": map[string]any{"tag": "plain_text", "content": markdownTitle(m)}}, "elements": elements, } if len(m.Items) > 0 { card["header"].(map[string]any)["template"] = feishuSeverityTemplate(m.Items[0].Severity) } return card, kept } func feishuMarkdownDiv(content string) map[string]any { return map[string]any{"tag": "div", "text": map[string]any{"tag": "lark_md", "content": content}} } func feishuButton(label, url string) map[string]any { return map[string]any{ "tag": "action", "actions": []any{map[string]any{ "tag": "button", "text": map[string]any{"tag": "lark_md", "content": label}, "url": url, "type": "primary", }}, } } // feishuItemLines renders the lark_md body of a single vulnerability. // // lark_md and markdown are sibling text formats that also parse links and // emphasis, so every field coming from outside goes through markdownText // (single-lining + escaping) — otherwise a single vulnerability title could turn // into a clickable external link inside Feishu. func feishuItemLines(it Item) string { out := fmt.Sprintf("**%s · %s**", SeverityLabel(it.Severity), markdownText(it.Title(), 0)) if it.IsStatusChange() { out += fmt.Sprintf("\n**Status change**: %s → %s", markdownText(StatusLabel(it.FromStatus), 0), markdownText(StatusLabel(it.ToStatus), 0)) } if it.VulnClass != "" && it.VulnClass != it.Title() { out += fmt.Sprintf("\n**Type**: %s", markdownText(it.VulnClass, 0)) } if a := assetLine(it.Assets, maxAssetsShown); a != "" { out += fmt.Sprintf("\n**Assets**: %s", markdownText(a, 0)) } if it.Summary != "" { if s := markdownText(it.Summary, maxSummaryRunes); s != "" { out += fmt.Sprintf("\n**Summary**: %s", s) } } return out } // feishuBatchLine renders one entry of a digest card. func feishuBatchLine(it Item, index int) string { line := fmt.Sprintf("**%d. %s · %s**", index, SeverityLabel(it.Severity), markdownText(it.Title(), 0)) if a := assetLine(it.Assets, maxAssetsShown); a != "" { line += " — " + markdownText(a, 0) } return line }