mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-15 04:30:55 +08:00
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
220 lines
7.5 KiB
Go
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
|
|
}
|