mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 01:03:50 +08:00
对推送功能做了一轮安全审计,每条都先复现、再修、再补回归测试。 以下按严重程度排列。 【严重】掩码可被「改目标地址、保留凭据」绕过 目标地址与凭据是两套独立字段,而配置合并对「未提及的键」保留原值。于是 只改地址、对凭据避而不谈,服务端就会把库里的真凭据发到任意地址。已实测 复现四个渠道:通用 Webhook 的 Authorization 头、Telegram 的 Bot Token (进请求路径)、邮件的 SMTP 密码(STARTTLS 后交给对端)、钉钉/飞书的加签 密钥(该项不可利用,一并列出仅为说明面)。完全静默、不依赖重定向,所以 上一轮加的「拒绝跨主机跳转」挡不住它。 修法:新增 Channel.DestinationKeys() 声明「消息发往哪」,PrepareConfigUpdate 在地址变化时要求对**每一个**凭据字段显式表态(给新值,或显式留空表示不再 需要)——原样回传掩码值等于「沿用旧凭据」,正是攻击形状,一并拒绝。 刻意不做「自动丢弃凭据」:对可选字段(如 headers)那会变成「鉴权静默没了 但接口返回成功」,比报错更难排查。 【中】markdown 系三渠道对不可信内容零转义 钉钉/企微/飞书此前完全不转义(Telegram 与邮件都做了),而标题与摘要来自 模型输出、资产 url 来自被测目标。一条标题里的 `[文字](http://evil)` 会在 工程师的钉钉里变成可点击外链;`` 会被客户端拉取, 泄露阅读者 IP 并暴露「这条已被看过」。现统一单行化 + markdown 元字符转义。 修的过程中还纠了自己一个设计错误:转义一度被放进 markdownTitle,而它被 markdown 正文、Telegram 的 HTML、飞书卡片、Webhook 的 JSON 与邮件主题四种 语境共用,markdown 的反斜杠泄漏到了 Telegram 消息里(用户会看到 `\(1\)`)。 转义必须发生在各自的渲染出口——测试直接抓到了这个回归。 【中】投递地址的 SSRF 面 此前只校验 scheme 与 host,云元数据 169.254.169.254、环回、内网一律可投递; 而失败响应体前 200 字节会进 last_error 并被投递历史接口回显,构成半盲读 原语。现在在**拨号阶段**设防(最终生效点,同时覆盖 DNS 重绑定与同主机 重定向)。环回/链路本地需显式 ARTEX_NOTIFY_ALLOW_LOCAL=1 才放行(本机 SMTP 中继是合法配置);RFC1918 私网**刻意放行**——内网自建 Mattermost / SMTP 很常见,做到那个程度会把正常部署一起废掉,这条取舍写进了测试断言。 【中】汇总消息被截断后整批标记已送达 = 静默丢失 渠道都有长度上限(企微 4096 字节最紧)。此前渲染完整篇再截断、然后整批 标记成功:被截掉的那些既不在消息里、也不在失败列表里,投递历史还显示 成功。现改为按**整条**打包,只标记真正装下的,其余回队续发,消息头部 如实写明还有多少条在下一条。被推迟的条目**不消耗重试次数**(领取时乐观 +1 的那次减回去),否则 500 条的积压会在第三段就把尾部判成失败。 【中】其余修复 - min_severity 打错字让过滤器静默失效(未知级别序数为 0 → rank>=0 恒真, 用户以为「仅高危」实际全推)。改为写入时校验取值。 - rate_per_min=0(不限流)不可达:文档/UI/令牌桶都按 0=不限流解释,唯独 写库那层 `if <=0 取默认值` 把它改成 20 或 100。默认值改到接口层填。 - digest 渠道完全绕过令牌桶(allow 被扣却没人用)。 - 失败处置按整批 max(attempts) 判断,让老投递连坐同批的新投递。 - 汇总批次里快照无法解析的投递被静默标记成功。 - url.Parse 失败时错误文本仍带完整地址(上一轮只修了 http.Client.Do 那侧; 且当时的用例全走 scheme 分支、没覆盖解析失败路径,是假保证)。 - 单渠道每轮投递条数可能超出租约时长(多实例下重复发送)。加了防漂移 断言,写的时候立刻发现原取值 6 恰好用满租约、零余量,已改 5。 - Telegram 截断可能切断 HTML 实体,导致整条被拒收。 - 邮件把 SMTP 4xx(灰名单等临时失败)判成永久失败,最该重试的场景从不重试。 - 提交嵌套结构时会把掩码字面量真的写进库,导致鉴权静默失效。 测试:notify 包从 82% 提到 86%,新增 SSRF 守卫、掩码绕过(用攻击形状的 输入而非「防御逻辑的正确输入」)、分段投递收敛、markdown 转义、SMTP 4xx/5xx 分类等回归用例;端到端补了一条「60 条长标题跨多轮发完且不卡死」的收敛测试。 Co-Authored-By: Claude Code <[email protected]>
207 lines
6.9 KiB
Go
207 lines
6.9 KiB
Go
package notify
|
||
|
||
import (
|
||
"context"
|
||
"crypto/hmac"
|
||
"crypto/sha256"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"strconv"
|
||
"time"
|
||
)
|
||
|
||
// feishuChannel 实现飞书(含 Lark)自定义机器人,走交互式卡片。
|
||
//
|
||
// 平台特性:
|
||
// - 加签算法与钉钉**不同**,且极易写错,见 feishuSign 注释。
|
||
// - 与钉钉一样把业务错误塞在 HTTP 200 的 body 里(code != 0)。
|
||
// - 卡片 header 支持颜色模板,用级别映射配色,让人在消息列表里一眼看出严重程度。
|
||
type feishuChannel struct{}
|
||
|
||
func (feishuChannel) Kind() string { return KindFeishu }
|
||
|
||
// 飞书自定义机器人约 5 次/秒,折合 100 次/分钟。
|
||
func (feishuChannel) DefaultRatePerMin() int { return 100 }
|
||
|
||
// Webhook 地址末段即机器人唯一标识,属凭据。
|
||
func (feishuChannel) SecretKeys() []string { return []string{"webhook", "secret"} }
|
||
|
||
// 同理:改 Webhook 地址必须对新地址重新表态签名密钥。
|
||
func (feishuChannel) DestinationKeys() []string { return []string{"webhook"} }
|
||
|
||
func (feishuChannel) Validate(cfg map[string]any) error {
|
||
hook := cfgString(cfg, "webhook")
|
||
if hook == "" {
|
||
return errors.New("缺少 Webhook 地址")
|
||
}
|
||
if err := validateHTTPURL(hook); err != nil {
|
||
return fmt.Errorf("Webhook 地址无效: %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,
|
||
}
|
||
// 加签参数与消息同层,且只在配置了 secret 时出现。
|
||
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"`
|
||
// 部分版本的飞书 hook 用这套字段名,一并兼容。
|
||
StatusCode int `json:"StatusCode"`
|
||
StatusMessage string `json:"StatusMessage"`
|
||
}
|
||
if err := json.Unmarshal(raw, &res); err != nil {
|
||
return 0, fmt.Errorf("解析飞书响应失败: %w (%s)", err, snippet(raw))
|
||
}
|
||
if res.Code != 0 {
|
||
return 0, Permanent(fmt.Errorf("飞书返回错误 %d: %s", res.Code, res.Msg))
|
||
}
|
||
if res.StatusCode != 0 {
|
||
return 0, Permanent(fmt.Errorf("飞书返回错误 %d: %s", res.StatusCode, res.StatusMessage))
|
||
}
|
||
return kept, nil
|
||
}
|
||
|
||
// feishuSign 按飞书官方规则计算签名。
|
||
//
|
||
// 这里特别容易踩坑:官方样例是
|
||
//
|
||
// hmac.new(string_to_sign.encode(), digestmod=sha256)
|
||
//
|
||
// 也就是 **key = timestamp + "\n" + secret,message 为空**,而不是直觉上的
|
||
// 「key=secret, message=stringToSign」——那正是钉钉的算法。两边算法刚好反过来,
|
||
// 照着另一家的实现写必然签名校验失败(报 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 把漏洞级别映射到卡片 header 配色模板。
|
||
// 未知级别用 grey——不用 blue,免得和 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 是卡片内容的保守上限。飞书对卡片有体积限制,超了整条被拒;
|
||
// 取一个明显低于官方上限的值,把 JSON 包装开销也算进来。
|
||
const feishuMaxCardBytes = 24000
|
||
|
||
// feishuCard 构造交互式卡片,返回卡片与**实际写入的条目数**。
|
||
// kept 的用途同 markdownBody:只有真正进了卡片的条目才该被标记为已送达。
|
||
func feishuCard(m Message) (map[string]any, int) {
|
||
elements := []any{}
|
||
kept := 0
|
||
if m.Batch {
|
||
// 先按整条打包再拼头部:头部要写「其余 N 条将在下一条消息继续」,
|
||
// N 必须来自实际装下的条数。
|
||
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("在平台中查看全部", 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("查看详情", 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 渲染单个漏洞的 lark_md 正文。
|
||
//
|
||
// lark_md 与 markdown 是同族的文本格式,同样会解析链接与强调,所以来自外部
|
||
// 的字段一律过 markdownText(单行化 + 转义)——否则一条漏洞标题就能在
|
||
// 飞书里变成可点击的外链。
|
||
func feishuItemLines(it Item) string {
|
||
out := fmt.Sprintf("**%s · %s**", SeverityLabel(it.Severity), markdownText(it.Title(), 0))
|
||
if it.IsStatusChange() {
|
||
out += fmt.Sprintf("\n**状态变更**:%s → %s",
|
||
markdownText(StatusLabel(it.FromStatus), 0), markdownText(StatusLabel(it.ToStatus), 0))
|
||
}
|
||
if it.VulnClass != "" && it.VulnClass != it.Title() {
|
||
out += fmt.Sprintf("\n**类型**:%s", markdownText(it.VulnClass, 0))
|
||
}
|
||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||
out += fmt.Sprintf("\n**资产**:%s", markdownText(a, 0))
|
||
}
|
||
if it.Summary != "" {
|
||
if s := markdownText(it.Summary, maxSummaryRunes); s != "" {
|
||
out += fmt.Sprintf("\n**摘要**:%s", s)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// feishuBatchLine 渲染汇总卡片里的一条。
|
||
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
|
||
}
|