Files
ARTEX/notify/feishu.go
BaaRai e233c02314 Translate all Chinese text to English
Comments, doc comments, UI labels, log and error messages, LLM prompt
strings, markdown docs, test data and assertions. Identifiers, enum
keys, routes, placeholders and code structure are unchanged.

Notes:
- docs/漏洞流量证据.md renamed to docs/vulnerability-traffic-evidence.md
- tests that exercised CJK specific handling (GBK zip names, Chinese
  full text search) now use ASCII equivalents; go build passes and the
  test suite matches the pre translation baseline (remaining failures
  need a PostgreSQL DSN and fail identically on the original)
- frontend mention syntax now uses English kinds (finding, asset, ...)
  matching the backend, chat mentions tests pass
2026-10-08 23:14:34 +02:00

220 lines
7.5 KiB
Go

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
}