Merge pull request #185 from lucksec/feat/vuln-im-push
feat(notify): 漏洞发现支持推送到钉钉/飞书/企业微信等六种渠道
This commit is contained in:
49 files changed
+10262
-9
No files matched your search
@@ -9,6 +9,48 @@
|
||||
#### 新增的功能
|
||||
|
||||
- **新增内置拦截规则「删除类接口路径」**:此前内置的 HTTP 破坏性规则只认 DELETE **方法**(`curl -X DELETE`、`requests.delete(`、`method:'DELETE'`),路径类规则的词表又只有 `/clear /wipe /flush /purge /truncate /drop /destroy /factory-reset /reset-all`——而多数应用的删除接口用 GET/POST 就能触发,于是 `curl 'http://t/api/user/delete?id=1'` 这类调用不命中任何内置规则,会真实删掉目标数据。现补一条 `deny` 规则覆盖 `/delete /del /remove /unlink /erase /destroy`(允许 `/deleteAll`、`/delete_user`、`/delete-user` 这类后缀形式),动词后必须跟分隔符,`/delivery`、`/details`、`/delta`、`/delegate` 不会被误拦。规则走独立的种子标记位,**已有实例升级后也会拿到**;和其余内置规则一样可在「系统 → 命令拦截」里停用或删除。
|
||||
### 漏洞推送
|
||||
|
||||
#### 新增的功能
|
||||
|
||||
- **漏洞发现支持推送到 IM**:新增「系统 → 通知推送」页,可把扫描发现推送到**钉钉、飞书、企业微信、通用 Webhook、Telegram、邮件**六种渠道。同一类型可配任意多个机器人实例(如「应急响应群」「日常播报群」各一个钉钉机器人),每个实例独立设启停、限流与过滤规则。
|
||||
- **推送时机分实时与汇总两种模式**:实时模式命中即逐条发送;汇总模式按全局周期(默认 30 分钟)把一批漏洞合并成一条消息,开头给出「近 N 分钟新增 M 个漏洞」及级别分布,便于一眼判断是否需要立刻处理。想做「高危实时、其余汇总」就建两个渠道分别配置,策略不写死在代码里。
|
||||
- **过滤规则支持四个维度**:最低严重级别、限定任务 / 资产范围、漏洞类型关键词包含与排除(排除优先)、以及是否接收处置状态变更(默认关,因为多数人说的「推送」指发现新漏洞,而不是状态流水账)。单条消息带回「查看详情」按钮跳转漏洞详情,目标地址由全局「回链地址」配置,留空则不带按钮。
|
||||
- **投递历史与手动重发**:通知页列出每条投递的状态、尝试次数、失败原因与所属渠道,可按渠道与状态筛选;失败项可一键重发(重发会清零重试计数,因为人工点重发意味着失败原因已被处理)。
|
||||
- **渠道凭据掩码回显**:Webhook 地址、加签密钥、Bot Token、SMTP 密码等由各渠道自行声明(`SecretKeys()`),接口只回显带尾号提示的掩码值;提交时原样回传即表示「不修改」,清空输入框则删除该字段。
|
||||
|
||||
#### 修复的问题
|
||||
|
||||
> 本节记录的是功能初版完成后一轮安全审计的结论。每条都是先复现、再修、再补回归测试。
|
||||
|
||||
- **修复掩码可被「改目标地址、保留凭据」绕过(严重)**:掩码机制的目的是「凭据不回显给浏览器」,但目标地址与凭据是两套独立字段,而配置合并对「未提及的键」一律保留库中原值。于是**只改地址、对凭据避而不谈**就能让服务端把库里的真凭据发到任意地址——通用 Webhook 的 `Authorization` 头、Telegram 的 Bot Token(进请求路径)、邮件渠道的 SMTP 密码(STARTTLS 后交给对端)全部泄露,且完全静默、不依赖重定向。这条路径实测可行,四个渠道逐一复现过。现在规定:**只要目标地址发生变化,调用方就必须对每一个凭据字段显式表态**(给新值,或显式留空表示不再需要)——原样回传掩码值等于「沿用旧凭据」,恰恰是攻击形状,一并拒绝。刻意不做「自动丢弃凭据」,因为对可选凭据字段(如 `headers`)那会变成「鉴权静默没了但接口返回成功」,比报错更难排查。
|
||||
- **修复 markdown 系渠道对不可信内容零转义**:钉钉、企业微信、飞书三个渠道此前完全不做转义(Telegram 与邮件都做了)。漏洞标题与摘要来自模型输出(模型读的是被测目标的响应),资产的 `url` 则是扫描得到的、含目标可控查询串的完整 URL。一条标题为 `登录口 SQL 注入\n[紧急:点此验证账号](http://attacker.tld)` 的漏洞,会在安全工程师的钉钉/飞书里渲染成**可点击外链**;`` 则会在渲染时被客户端拉取——等于通报「这条漏洞已被看过」并泄露阅读者 IP。现统一做单行化 + markdown 元字符转义。同时修正了一处相关的设计错误:转义必须发生在各自的渲染出口,不能放进被四种语境共用的标题函数(markdown 转义泄漏到 Telegram 的 HTML 里会留下可见反斜杠)。
|
||||
- **修复投递地址的 SSRF 面**:此前只校验 scheme 与 host,`169.254.169.254`(云元数据,可读出实例凭据)、环回地址、内网地址一律可投递;而投递失败时响应体前 200 字节会进 `last_error` 并被投递历史接口回显,构成一条半盲读原语(可读任意内网 HTTP 端点响应的前 200 字节)。现在在**拨号阶段**设防(而非只在保存配置时校验)——那里才是最终生效点,同时覆盖 DNS 重绑定与同主机重定向,并拒绝跨主机重定向(这几家的凭据就在 URL 里,跟随跳转等于交给跳转目标)。环回与链路本地地址需要显式设置 `ARTEX_NOTIFY_ALLOW_LOCAL=1` 才放行(本机 SMTP 中继是合法配置,不能一刀切);**RFC1918 私网刻意放行**,因为内网自建 Mattermost / SMTP 中继很常见,把防护做到那个程度会把正常部署一起废掉。
|
||||
- **修复推送凭据经错误信息外泄**:投递失败时 `http.Client.Do` 返回的 `*url.Error` 会把**完整 URL** 打进错误文本,而钉钉的 `access_token`、企业微信的 `key`、飞书的 hook id、Telegram 的 `/bot<token>/` 都**就在 URL 里**。该串因此流入四个出口:`notification_deliveries.last_error` 明文落库、投递历史接口的原样回显(绕过了渠道配置的掩码)、服务端日志、以及测试发送接口回给前端的错误提示。现统一脱敏——错误信息只保留 `scheme://host` 与底层原因(足够定位 DNS / 连通性 / 证书问题),路径与查询一律丢弃。地址校验自身(`url.Parse` 失败)的错误文本同样带完整地址,与上面同一处理;**上一轮只修了前者、漏了后者,且当时的回归用例全都走的是 scheme 分支、根本没覆盖到解析失败路径,属于假保证**——现已补上真正覆盖该分支的用例。
|
||||
- **修复汇总消息被截断后整批标记已送达导致的静默丢失**:渠道都有长度上限(企微 4096 字节最紧),一批装不下时消息会被截断,而发送成功后整批投递都被标记为已送达——被截掉的那些**既不在消息里、也不在失败列表里**,投递历史还显示成功,漏洞就这么消失。现在改为按**整条**打包:装进本条的那些才标记已送达,其余回到队列等下一条继续发,且消息头部如实写明「本条显示前 N 条,其余 M 条将在下一条消息继续」。被推迟的条目**不消耗重试次数**(领取时乐观 +1 的那次会减回去),否则一个 500 条的积压会在第三段就把尾部条目判成失败——而它们从未出过任何错。
|
||||
- **修复最低级别门槛打错字会让过滤器静默失效**:`min_severity` 写成 `hgih` 这类笔误时,未知级别在序数表里为 0,判定退化成 `rank >= 0` 恒真——用户以为限制了「仅高危」,实际把全部漏洞灌进群里,且界面上与配置正确完全无法区分。现于写入路径校验取值,错误信息列出可选值(读取路径仍保持宽容:库里已有的坏值不会让渠道整个读不出来)。
|
||||
- **修复 `rate_per_min = 0`(不限流)不可达**:文档、界面提示与令牌桶都把 0 解释为「不限流」,唯独写库这一层写了 `if RatePerMin <= 0 { 取默认值 }`,把显式 0 悄悄改成 20(钉钉/企微/Telegram)或 100(飞书)——操作者以为放开了限流、实际被卡着且没有任何提示。「未指定」与「显式 0」的区别只有请求体能表达,默认值因此改到接口层在字段缺省时填。
|
||||
- **修复汇总渠道完全绕过令牌桶**:`allow` 被 `takeTokens` 扣掉却没人用,`rate_per_min` 对 digest 模式没有任何作用。现在领取条数同时受「本轮额度」与内存上界两个约束。
|
||||
- **修复失败处置按整批最大尝试次数判断,让老投递连坐新投递**:批次内各条的尝试次数并不相同,一个已重试两次的老投递会把同批里全新的投递一起拖进 failed——新漏洞一次重试都没用上就永久丢失,与「不让老行拖新行下水」的初衷正好相反。现在逐条决定:永久失败立即判死、各自重试次数耗尽的判死、其余按各自的退避档位重排。
|
||||
- **修复汇总批次中快照无法解析的投递被静默标记成功**:这类条目会被渲染阶段跳过(不拖垮整批),但随后整批标记已送达把它们一起算作成功。现在它们被显式判失败并给出原因,投递历史里能查到。
|
||||
- **修复单渠道每轮投递条数可能超出租约时长**:租约 3 分钟,而一轮可串行投递的条数若多到最坏耗时超过租约,多实例部署时对端会把租约过期的行重新领走、重复发送并双份递增尝试次数。现在按「租约 / 单次超时」倒推出每轮上限,并有一条断言把这三个常量的关系钉死(写这条断言时立刻发现原取值 6 恰好用满租约、零余量,已调整为 5)。
|
||||
- **修复 Telegram 截断可能切断 HTML 实体**:截断只避开了半截标签,没避开被切断的 `&` 之类的实体残片,而解析器可能因此拒收**整条**消息——超长汇总消息本来就常见,代价太大。现在同时回避未闭合标签与实体残片。
|
||||
- **修复邮件渠道把临时性 SMTP 失败判成永久失败**:SMTP 的 4xx(如灰名单 `450`)是临时拒绝、正规做法是稍后重试,而此前一律判永久失败——一个启用灰名单的邮件服务器会让**每条**推送都在第一次尝试后落入失败,而这恰恰是自动重试最该起作用的场景。现按应答码首位区分:4xx 可重试、5xx 永久失败,取不到码时按可重试处理。
|
||||
- **修复提交嵌套结构时会真的把掩码字面量写进库**:像 `webhook.headers` 这种对象字段只能整体掩码或整体提交;把掩码哨兵塞进对象内部既表达不了「保持不变」,又会被当真实值存下去,导致后续鉴权静默失效且没有任何报错。现在这种提交被显式拒绝。
|
||||
|
||||
#### 设计说明
|
||||
|
||||
- **写漏洞的事务只做一次盲 INSERT**:`notification_events` 由 `RecordFindingTx` 在**同一事务**内写入,提交即保证「漏洞落库」与「推送任务存在」原子一致。这条 INSERT 刻意不读渠道表、不跑用户的过滤规则——否则一条配错的过滤条件就能污染甚至中止事务,让高危漏洞存不进库。为把失败隔离在这一条语句上(PostgreSQL 中事务内任一语句报错会让整个事务作废、连 `COMMIT` 都失败),它被 `SAVEPOINT` 包住,失败时只记日志、不影响漏洞写入。
|
||||
- **投递用租约领取而非长事务**:`FOR UPDATE SKIP LOCKED` 领取后把行置为 `sending` 并把 `next_attempt_at` 推到未来作为租约,提交事务后再做网络投递,因此投递期间不持有数据库锁;进程崩溃留下的 `sending` 行会在租约到期后被下一轮重新领取,自愈且不会造成无限重试。
|
||||
- **限流不消耗重试预算**:引擎先按渠道令牌桶算出本轮还能发几条,再按这个数量去领取。顺序反过来(先领后弃)会让被限流挡下的投递白计一次尝试次数,三次预算被纯粹的等待耗光后落入失败。超限不会丢消息,只把投递推迟到下一个 tick。
|
||||
|
||||
### 测试基础设施
|
||||
|
||||
#### 修复的问题
|
||||
|
||||
- **修复公司 ICP 归属用例的清理顺序导致的资产永久残留**:该用例的清理注册在 `t.Cleanup` 里,但连接关闭用的是 `defer d.Close()`——`defer` 在函数返回时先执行、`t.Cleanup` 在其后,于是清理语句全部落在**已关闭的连接**上,错误又被 `_, _ =` 丢弃,测试资产与公司永久残留在库里。它用 `MAX(companies.id)+1` 当假 TaskID 给资产打标,一旦该数字与其他用例的任务 id 相撞,那个按「恰好 N 个资产」断言的用例就会莫名失败且极难定位。现改为关闭连接也走 `t.Cleanup` 并注册在前(后进先出,保证清理先跑),同时让清理失败显形而不是被吞掉。同一模式(`defer d.Close()` + 在 `t.Cleanup` 里写库)在 `db` 包另有十余处,本次只修了被实证触发的那一处。
|
||||
|
||||
### 流量
|
||||
|
||||
#### 新增的功能
|
||||
|
||||
|
||||
@@ -233,7 +233,15 @@ func TestCompanyICPAttribution(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
// 关连接必须走 t.Cleanup 且**注册在清理之前**:t.Cleanup 是后进先出,
|
||||
// 先注册关闭 → 关闭最后执行,下面的数据清理才连得上库。
|
||||
// 原先这里是 `defer d.Close()`:defer 在函数返回时先跑,t.Cleanup 在那之后
|
||||
// 才执行,于是清理语句全落在**已关闭的连接**上、错误又被 `_, _ =` 丢弃,
|
||||
// 资产与公司就永久残留在库里。残留本身不会立刻报错,但本用例用
|
||||
// `MAX(companies.id)+1` 当假 TaskID 给资产打标(见下方 suffix),
|
||||
// 一旦这个数字与别的用例的任务 id 撞上,那个用例按「恰好 N 个资产」的断言
|
||||
// 就会莫名失败——排查成本极高。
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
var suffix int64
|
||||
if err := d.QueryRow(`SELECT COALESCE(MAX(id),0)+1 FROM companies`).Scan(&suffix); err != nil {
|
||||
@@ -246,8 +254,13 @@ func TestCompanyICPAttribution(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = d.Exec(`DELETE FROM assets WHERE task_ids @> ARRAY[$1]::bigint[]`, suffix)
|
||||
_, _ = d.Exec(`DELETE FROM companies WHERE id=$1`, companyID)
|
||||
// 不吞错误:清理失败会污染后续用例,必须让它在本次运行里显形。
|
||||
if _, err := d.Exec(`DELETE FROM assets WHERE task_ids @> ARRAY[$1]::bigint[]`, suffix); err != nil {
|
||||
t.Errorf("清理测试资产失败: %v", err)
|
||||
}
|
||||
if _, err := d.Exec(`DELETE FROM companies WHERE id=$1`, companyID); err != nil {
|
||||
t.Errorf("清理测试公司失败: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// A keyword can guide an Agent, but must never claim an asset by its name.
|
||||
|
||||
+10
-1
@@ -237,7 +237,16 @@ func (d *DB) FinishFindingRetest(id int64, status, reason string) error {
|
||||
return err
|
||||
}
|
||||
if finalStatus == "completed" && verdict == "fixed" {
|
||||
if _, err := tx.Exec(`UPDATE findings SET status=$1 WHERE id=$2`, FindingFixed, findingID); err != nil {
|
||||
// 走带通知的版本,与人工在详情页改状态共用同一套语义。
|
||||
//
|
||||
// 此前这里是裸的 UPDATE:复测判「已修复」时状态确实变了,但配了
|
||||
// on_status_change 的渠道完全收不到推送——状态在界面上悄悄变了,
|
||||
// 运维要打开平台才知道。状态更新与推送事件必须一起落库,
|
||||
// SetFindingStatusTx 内部处理了「状态没变就不登记」等细节。
|
||||
// 用 context.Background():本函数整条都是无 ctx 的旧风格(d.Begin()/
|
||||
// tx.QueryRow/tx.Exec),没有可传递的取消信号,硬加一个 ctx 参数会
|
||||
// 牵动 server 侧调用点与多处测试,超出本次改动的范围。
|
||||
if _, _, _, _, err := SetFindingStatusTx(context.Background(), tx, findingID, FindingFixed); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
+19
-2
@@ -10,6 +10,8 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/notify"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -335,7 +337,9 @@ type RecordedFinding struct {
|
||||
Traffic *FindingTraffic `json:"traffic"`
|
||||
}
|
||||
|
||||
func RecordFindingTx(tx *sql.Tx, in RecordFindingInput, prepared []PreparedTrafficEvidence) (*RecordedFinding, error) {
|
||||
// ctx 由调用方传入本次事务所用的上下文(而非在内部取 context.Background):
|
||||
// 事务内新加的推送事件写入同样应受调用方的取消与超时约束。
|
||||
func RecordFindingTx(ctx context.Context, tx *sql.Tx, in RecordFindingInput, prepared []PreparedTrafficEvidence) (*RecordedFinding, error) {
|
||||
if err := LockTaskEvidenceTx(tx, in.TaskID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -382,6 +386,19 @@ VALUES($1,'finding',$2,9,'confirmed',$3) RETURNING id`, in.ExplorationID, string
|
||||
VALUES(NULLIF($1,0),$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`, in.TaskID, out.NodeID, in.VulnClass, in.Name, in.Severity, in.Summary, in.Evidence, in.Worker, string(raw)).Scan(&out.FindingID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 在**同一事务**里登记一条推送事件:提交即保证「漏洞落库」与「推送任务存在」
|
||||
// 原子一致,不存在提交成功却没入队、消息永久丢失的窗口。
|
||||
// 这里的失败被隔离在保存点上、不影响漏洞写入(见函数注释),因此忽略返回值。
|
||||
RecordNotificationEventTx(ctx, tx, notify.EventFindingCreated, out.FindingID, notify.Snapshot{
|
||||
Kind: notify.EventFindingCreated,
|
||||
FindingID: out.FindingID,
|
||||
TaskID: in.TaskID,
|
||||
VulnClass: in.VulnClass,
|
||||
Name: in.Name,
|
||||
Severity: in.Severity,
|
||||
Summary: in.Summary,
|
||||
AssetIDs: assets,
|
||||
})
|
||||
if err := AddFindingTrafficTx(tx, out.FindingID, prepared); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -393,7 +410,7 @@ VALUES(NULLIF($1,0),$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`, in.TaskID, out.NodeI
|
||||
// RecordFinding is the atomic legacy/no-recorder path used by tools and tests.
|
||||
func (s *ExplorationStore) RecordFinding(ctx context.Context, in RecordFindingInput) (out *RecordedFinding, err error) {
|
||||
in.ExplorationID = s.expID
|
||||
err = s.db.WithEvidenceTx(ctx, func(tx *sql.Tx) error { var e error; out, e = RecordFindingTx(tx, in, nil); return e })
|
||||
err = s.db.WithEvidenceTx(ctx, func(tx *sql.Tx) error { var e error; out, e = RecordFindingTx(ctx, tx, in, nil); return e })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -690,6 +690,10 @@ func (d *DB) DeleteFindingsByTask(taskID int64) (int64, error) {
|
||||
}
|
||||
|
||||
// SetFindingStatus updates one finding's triage state. Returns rows affected.
|
||||
//
|
||||
// 底层 setter:只改状态、不登记推送事件。生产代码改状态请走
|
||||
// SetFindingStatusWithNotify —— 直接调本函数会让「状态变更推送」静默失效。
|
||||
// 保留它是为了让不关心通知的用例(参数校验、复测流程)能单独驱动状态。
|
||||
func (d *DB) SetFindingStatus(id int64, status string) (int64, error) {
|
||||
res, err := d.Exec(`UPDATE findings SET status=$1 WHERE id=$2`, status, id)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/notify"
|
||||
)
|
||||
|
||||
// 本文件是 IM 推送的渠道配置与事件层。投递任务的领取与状态流转见
|
||||
// db/notification_delivery.go。
|
||||
//
|
||||
// 两条不变量,改这个文件时务必保持:
|
||||
//
|
||||
// 1. 写漏洞的事务(RecordFindingTx)只调用 InsertNotificationEventTx 做一次盲插,
|
||||
// 不读任何通知相关的表、不做过滤匹配。任何在这里引入的读操作都可能因为
|
||||
// 用户配错的过滤条件而污染甚至中止漏洞写入事务。
|
||||
// 2. 过滤匹配永不报错:配置畸形一律按「命中」处理(见 notify.Match)。宁可多推,
|
||||
// 不可漏推。
|
||||
|
||||
// ErrNotificationChannelNotFound 渠道不存在。
|
||||
var ErrNotificationChannelNotFound = errors.New("通知渠道不存在")
|
||||
|
||||
// 投递状态。
|
||||
const (
|
||||
NotifyStatePending = "pending" // 待发
|
||||
NotifyStateSending = "sending" // 已被某个 dispatcher 领取,租约未到期
|
||||
NotifyStateSent = "sent" // 已送达
|
||||
NotifyStateFailed = "failed" // 重试耗尽或永久失败,可手动重发
|
||||
NotifyStateSkipped = "skipped" // 渠道已停用,不再发送
|
||||
)
|
||||
|
||||
// 推送模式。
|
||||
const (
|
||||
NotifyModeRealtime = "realtime"
|
||||
NotifyModeDigest = "digest"
|
||||
)
|
||||
|
||||
// ValidNotifyMode 白名单校验推送模式(与 findings.status 同理:不用 DB CHECK,
|
||||
// 便于后续扩展)。
|
||||
func ValidNotifyMode(m string) bool {
|
||||
return m == NotifyModeRealtime || m == NotifyModeDigest
|
||||
}
|
||||
|
||||
// NotificationChannel 是一个渠道实例配置。Config 与 Filter 保持原始 JSON,
|
||||
// 解析交给 notify 包——db 层不理解它们的字段含义。
|
||||
type NotificationChannel struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Mode string `json:"mode"`
|
||||
Config json.RawMessage `json:"config"`
|
||||
Filter json.RawMessage `json:"filter"`
|
||||
// Enabled 用指针是为了区分「没传这个字段」与「显式传 false」——
|
||||
// 前端开关控件只提交被改动的字段。
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
RatePerMin int `json:"rate_per_min"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// IsEnabled 返回渠道是否启用;Enabled 为 nil(未加载)时按启用处理。
|
||||
func (c *NotificationChannel) IsEnabled() bool { return c.Enabled == nil || *c.Enabled }
|
||||
|
||||
// NotificationEvent 是一条事件事实。
|
||||
type NotificationEvent struct {
|
||||
ID int64 `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
FindingID int64 `json:"finding_id"`
|
||||
Snapshot json.RawMessage `json:"snapshot"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
const notificationChannelCols = `id, name, kind, enabled, config, mode, filter, rate_per_min, created_at, updated_at`
|
||||
|
||||
func scanNotificationChannel(sc interface{ Scan(...any) error }) (*NotificationChannel, error) {
|
||||
var c NotificationChannel
|
||||
var enabled bool
|
||||
if err := sc.Scan(&c.ID, &c.Name, &c.Kind, &enabled, &c.Config, &c.Mode, &c.Filter, &c.RatePerMin, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Enabled = &enabled
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// ListNotificationChannels 返回全部渠道实例,启用的排在前面、同级按 id。
|
||||
// 排序放在 SQL 里是为了让 UI 与 dispatcher 看到同一个稳定顺序。
|
||||
func (d *DB) ListNotificationChannels(ctx context.Context) ([]*NotificationChannel, error) {
|
||||
rows, err := d.QueryContext(ctx, `SELECT `+notificationChannelCols+` FROM notification_channels
|
||||
ORDER BY enabled DESC, id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []*NotificationChannel{}
|
||||
for rows.Next() {
|
||||
c, err := scanNotificationChannel(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// NotificationChannelByID 取单个渠道。
|
||||
func (d *DB) NotificationChannelByID(ctx context.Context, id int64) (*NotificationChannel, error) {
|
||||
row := d.QueryRowContext(ctx, `SELECT `+notificationChannelCols+` FROM notification_channels WHERE id=$1`, id)
|
||||
c, err := scanNotificationChannel(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrNotificationChannelNotFound
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
// SaveNotificationChannel 新建或更新一个渠道。
|
||||
//
|
||||
// 更新时只覆盖调用方显式给出的字段(非 nil / 非空),这样前端可以提交局部
|
||||
// 修改的抽屉表单,而不必回传 config 里那些它没展示的字段——回传反而会造成
|
||||
// 「掩码值把真实密钥覆盖掉」的事故。
|
||||
func (d *DB) SaveNotificationChannel(ctx context.Context, c *NotificationChannel) (int64, error) {
|
||||
if c.Mode == "" {
|
||||
c.Mode = NotifyModeRealtime
|
||||
}
|
||||
// 这里刻意**不**对 0 做任何加工:0 是合法配置,含义是「不限流」。
|
||||
//
|
||||
// 曾经写成 `if c.RatePerMin <= 0 { c.RatePerMin = 默认值 }`,本意是「未指定时
|
||||
// 给个安全默认」,但那把「显式设成 0」也一起吞掉了——文档、UI 提示与
|
||||
// takeTokens 都把 0 解释为不限流,唯独这里悄悄改成 20(钉钉/企微/Telegram)
|
||||
// 或 100(飞书),操作者以为放开了限流、实际被 20/分钟卡着且没有任何提示。
|
||||
//
|
||||
// 「未指定」与「显式 0」的区别只有调用方知道(请求体里字段缺省 vs 明确传 0),
|
||||
// 所以默认值由 server 层在字段缺省时填,见 notifyCreateChannel。
|
||||
if c.RatePerMin < 0 {
|
||||
return 0, errors.New("限流值不能为负")
|
||||
}
|
||||
if c.Config == nil {
|
||||
c.Config = json.RawMessage(`{}`)
|
||||
}
|
||||
if c.Filter == nil {
|
||||
c.Filter = json.RawMessage(`{}`)
|
||||
}
|
||||
enabled := c.IsEnabled()
|
||||
|
||||
if c.ID == 0 {
|
||||
var id int64
|
||||
err := d.QueryRowContext(ctx, `INSERT INTO notification_channels(name,kind,enabled,config,mode,filter,rate_per_min)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING id`,
|
||||
c.Name, c.Kind, enabled, string(c.Config), c.Mode, string(c.Filter), c.RatePerMin).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
res, err := d.ExecContext(ctx, `UPDATE notification_channels
|
||||
SET name=$2, kind=$3, enabled=$4, config=$5, mode=$6, filter=$7, rate_per_min=$8
|
||||
WHERE id=$1`,
|
||||
c.ID, c.Name, c.Kind, enabled, string(c.Config), c.Mode, string(c.Filter), c.RatePerMin)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return 0, ErrNotificationChannelNotFound
|
||||
}
|
||||
return c.ID, nil
|
||||
}
|
||||
|
||||
// SetNotificationChannelEnabled 切换启停。
|
||||
//
|
||||
// 停用一个渠道时,把它尚未发出的投递一并标记为 skipped:否则重新启用后
|
||||
// 会突然收到一批「停用期间积压」的旧漏洞,时效已失且容易误判为新增。
|
||||
func (d *DB) SetNotificationChannelEnabled(ctx context.Context, id int64, enabled bool) error {
|
||||
return d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
|
||||
res, err := tx.ExecContext(ctx, `UPDATE notification_channels SET enabled=$2 WHERE id=$1`, id, enabled)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotificationChannelNotFound
|
||||
}
|
||||
if !enabled {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE notification_deliveries SET state=$2, last_error=$3
|
||||
WHERE channel_id=$1 AND state IN ($4,$5)`,
|
||||
id, NotifyStateSkipped, "渠道已停用", NotifyStatePending, NotifyStateSending); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteNotificationChannel 删除渠道。其投递历史随外键级联删除
|
||||
// (渠道配置都没了,历史无从解读)。
|
||||
func (d *DB) DeleteNotificationChannel(ctx context.Context, id int64) error {
|
||||
res, err := d.ExecContext(ctx, `DELETE FROM notification_channels WHERE id=$1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotificationChannelNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordNotificationEventTx 在调用方的事务里**尽力**写入一条推送事件。
|
||||
//
|
||||
// 这是漏洞写入路径上唯一的通知相关改动:一次 INSERT,不读任何表、不认识渠道、
|
||||
// 不跑过滤。事务提交即保证「漏洞落库」与「推送任务存在」原子一致,
|
||||
// 不存在提交成功却没入队、消息永久丢失的窗口。
|
||||
//
|
||||
// 两个关键设计,都不是随手写的:
|
||||
//
|
||||
// 1. **为什么用 SAVEPOINT**:PostgreSQL 里事务内任一语句报错会让整个事务进入
|
||||
// aborted 状态,此后所有语句(含 COMMIT)一律失败。所以「忽略这条 INSERT
|
||||
// 的错误、让调用方继续提交」在 PG 里是做不到的——除非用保存点把错误隔离在
|
||||
// 这一条语句上。没有保存点,就只剩「整笔回滚」这一个选项。
|
||||
//
|
||||
// 2. **为什么整笔回滚是错的**:推送是便利功能,漏洞记录才是产品本身。一个通知
|
||||
// 表的问题(旧库未迁移、磁盘瞬时故障)不该让高危漏洞存不进库。所以这里隔离
|
||||
// 错误、记日志、返回 false,让漏洞写入照常提交——代价是丢掉这一条推送。
|
||||
// 返回 bool 而非 error 是刻意的:调用方不该把它当作会影响写入成败的错误。
|
||||
func RecordNotificationEventTx(ctx context.Context, tx *sql.Tx, kind string, findingID int64, snap notify.Snapshot) bool {
|
||||
raw, err := json.Marshal(snap)
|
||||
if err != nil {
|
||||
log.Printf("[notify] 序列化推送事件失败 finding=%d: %v", findingID, err)
|
||||
return false
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `SAVEPOINT notify_event`); err != nil {
|
||||
log.Printf("[notify] 建立保存点失败 finding=%d: %v", findingID, err)
|
||||
return false
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO notification_events(kind,finding_id,snapshot) VALUES($1,$2,$3)`,
|
||||
kind, findingID, string(raw)); err != nil {
|
||||
log.Printf("[notify] 写入推送事件失败 finding=%d(漏洞记录不受影响): %v", findingID, err)
|
||||
// 回滚到保存点,把事务从 aborted 状态里救回来。
|
||||
if _, rbErr := tx.ExecContext(ctx, `ROLLBACK TO SAVEPOINT notify_event`); rbErr != nil {
|
||||
log.Printf("[notify] 回滚到保存点失败 finding=%d: %v", findingID, rbErr)
|
||||
}
|
||||
return false
|
||||
}
|
||||
// 释放保存点,避免长事务里积攒无用的保存点。
|
||||
_, _ = tx.ExecContext(ctx, `RELEASE SAVEPOINT notify_event`)
|
||||
return true
|
||||
}
|
||||
|
||||
// AddNotificationEvent 是 InsertNotificationEventTx 的独立事务版本,供不在
|
||||
// 既有事务里的调用点使用(如渠道的「发送测试消息」,它没有真实 finding)。
|
||||
func (d *DB) AddNotificationEvent(ctx context.Context, kind string, findingID int64, snap notify.Snapshot) (int64, error) {
|
||||
raw, err := json.Marshal(snap)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("序列化通知事件快照失败: %w", err)
|
||||
}
|
||||
var id int64
|
||||
err = d.QueryRowContext(ctx, `INSERT INTO notification_events(kind,finding_id,snapshot) VALUES($1,$2,$3) RETURNING id`,
|
||||
kind, findingID, string(raw)).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// FanOutPendingEvents 把尚未分派的漏洞事件按当前启用的渠道展开成投递任务,
|
||||
// 返回本轮处理的事件数与新建的投递数。
|
||||
//
|
||||
// 整轮操作在一个事务里:事件用 FOR UPDATE SKIP LOCKED 领取,多个进程同时跑
|
||||
// 也各自领到不同的行(项目里归档队列的领取用的是同一套手法,见
|
||||
// db/task_archives.go 的 completeNextArchiveJob)。
|
||||
//
|
||||
// 过滤匹配刻意放在 Go 侧而非 SQL:渠道的过滤条件是一组可选字段的 JSONB,
|
||||
// 用 SQL 表达六种组合的匹配会让查询难以维护,而渠道数量是「人手配的几条」,
|
||||
// 全量加载后在内存里逐条比对更快也更好测。
|
||||
//
|
||||
// 未命中任何渠道的事件同样会被标记 fanned_out ——否则它会永远留在待分派集合里,
|
||||
// 每个 tick 被重扫一遍。
|
||||
func (d *DB) FanOutPendingEvents(ctx context.Context, limit int) (eventCount, deliveryCount int, err error) {
|
||||
if limit <= 0 {
|
||||
limit = 200
|
||||
}
|
||||
tx, err := d.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // 提交成功后是 no-op
|
||||
|
||||
channels, err := listEnabledNotificationChannelsTx(ctx, tx)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id, kind, finding_id, snapshot FROM notification_events
|
||||
WHERE NOT fanned_out ORDER BY id FOR UPDATE SKIP LOCKED LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
var (
|
||||
events []NotificationEvent
|
||||
parsedSnaps []notify.Snapshot
|
||||
)
|
||||
for rows.Next() {
|
||||
var ev NotificationEvent
|
||||
if err := rows.Scan(&ev.ID, &ev.Kind, &ev.FindingID, &ev.Snapshot); err != nil {
|
||||
rows.Close()
|
||||
return 0, 0, err
|
||||
}
|
||||
var snap notify.Snapshot
|
||||
// 快照是我们自己写的,理论上必定可解析;解析失败不阻断投递流程,
|
||||
// 但这条事件会因字段全空而被所有带过滤条件的渠道跳过——宁可少推一条
|
||||
// 也不让一个坏行卡死整个队列。
|
||||
_ = json.Unmarshal(ev.Snapshot, &snap)
|
||||
// kind 以行内值为准:快照里那份是渲染用的副本,可能被旧版本写过。
|
||||
snap.Kind = ev.Kind
|
||||
events = append(events, ev)
|
||||
parsedSnaps = append(parsedSnaps, snap)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if len(events) == 0 {
|
||||
return 0, 0, tx.Commit()
|
||||
}
|
||||
|
||||
type pending struct {
|
||||
eventID int64
|
||||
channelID int64
|
||||
}
|
||||
var toInsert []pending
|
||||
for i, snap := range parsedSnaps {
|
||||
for _, ch := range channels {
|
||||
if !notify.Match(notify.ParseFilter(ch.Filter), snap) {
|
||||
continue
|
||||
}
|
||||
toInsert = append(toInsert, pending{eventID: events[i].ID, channelID: ch.ID})
|
||||
}
|
||||
}
|
||||
if len(toInsert) > 0 {
|
||||
var (
|
||||
vals []string
|
||||
args []any
|
||||
)
|
||||
for _, p := range toInsert {
|
||||
vals = append(vals, fmt.Sprintf("($%d,$%d)", len(args)+1, len(args)+2))
|
||||
args = append(args, p.eventID, p.channelID)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO notification_deliveries(event_id,channel_id) VALUES `+strings.Join(vals, ","), args...); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
}
|
||||
|
||||
// 标记本轮事件已分派。未命中任何渠道的事件也一起标记(见函数注释)。
|
||||
ids := make([]string, 0, len(events))
|
||||
markArgs := make([]any, 0, len(events))
|
||||
for _, ev := range events {
|
||||
markArgs = append(markArgs, ev.ID)
|
||||
ids = append(ids, fmt.Sprintf("$%d", len(markArgs)))
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE notification_events SET fanned_out=true WHERE id IN (`+strings.Join(ids, ",")+`)`, markArgs...); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return len(events), len(toInsert), tx.Commit()
|
||||
}
|
||||
|
||||
// listEnabledNotificationChannelsTx 在事务里取启用中的渠道。数量很少,
|
||||
// 不做分页也不加缓存——缓存会引入「改了配置何时生效」这个额外的时序问题。
|
||||
func listEnabledNotificationChannelsTx(ctx context.Context, tx *sql.Tx) ([]*NotificationChannel, error) {
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id, name, kind, config, mode, filter, rate_per_min
|
||||
FROM notification_channels WHERE enabled ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []*NotificationChannel{}
|
||||
for rows.Next() {
|
||||
var c NotificationChannel
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Kind, &c.Config, &c.Mode, &c.Filter, &c.RatePerMin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// NotificationAssetNames 把资产 id 解析成简短展示名,供推送消息使用。
|
||||
//
|
||||
// 返回顺序与入参一致、长度可能小于入参(不存在的 id 被跳过)。保持入参顺序是
|
||||
// 为了让同一条漏洞的消息在多次投递里资产顺序稳定——否则重试后收到的消息里
|
||||
// 资产次序变了,会被误读成「资产变了」。
|
||||
func (d *DB) NotificationAssetNames(ctx context.Context, ids []int64) ([]string, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
ph, args := placeholders(1, ids)
|
||||
rows, err := d.QueryContext(ctx, `SELECT id, type, domain, ip, url, app_name, bundle_id FROM assets WHERE id IN (`+ph+`)`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
labels := map[int64]string{}
|
||||
for rows.Next() {
|
||||
var (
|
||||
id int64
|
||||
typ string
|
||||
domain, ip, url sql.NullString
|
||||
appName, bundleID sql.NullString
|
||||
)
|
||||
if err := rows.Scan(&id, &typ, &domain, &ip, &url, &appName, &bundleID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
labels[id] = assetDisplayName(typ, domain.String, ip.String, url.String, appName.String, bundleID.String)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]string, 0, len(ids))
|
||||
seen := map[int64]bool{}
|
||||
for _, id := range ids {
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
if label, ok := labels[id]; ok && label != "" {
|
||||
out = append(out, label)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// assetDisplayName 按资产类型挑选最具辨识度的标识。
|
||||
// 兜底返回空串,由调用方决定怎么呈现「名字取不到的资产」——本函数不臆造占位符,
|
||||
// 否则「资产#42」这种噪音会混进推送消息里,读者还以为是真实域名。
|
||||
func assetDisplayName(typ, domain, ip, url, appName, bundleID string) string {
|
||||
pick := func(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
switch typ {
|
||||
case "root_domain", "subdomain":
|
||||
return domain
|
||||
case "ip":
|
||||
return ip
|
||||
case "app":
|
||||
return pick(appName, bundleID)
|
||||
case "service", "endpoint":
|
||||
return pick(url, domain, ip)
|
||||
default:
|
||||
return pick(domain, ip, url, appName)
|
||||
}
|
||||
}
|
||||
|
||||
// SetFindingStatusWithNotify 更新漏洞处置状态,并在同一事务里登记一条状态变更
|
||||
// 推送事件。
|
||||
//
|
||||
// 返回 from=变更前的状态;found=漏洞是否存在;notified=事件是否登记成功。
|
||||
//
|
||||
// 三条刻意的行为:
|
||||
// - 状态未实际变化时不登记事件。前端抽屉重复提交同一个值、或自动化脚本
|
||||
// 幂等重放,都不该产出推送噪音。
|
||||
// - 漏洞不存在时返回 found=false 且不做任何写入,由调用方翻译成 404。
|
||||
// - 事件登记失败不影响状态更新(见 RecordNotificationEventTx 的保存点说明),
|
||||
// 所以 notified=false 时状态已经改成功了,调用方不应因此报错。
|
||||
func (d *DB) SetFindingStatusWithNotify(ctx context.Context, id int64, status string) (from string, found bool, notified bool, err error) {
|
||||
err = d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
|
||||
var txErr error
|
||||
from, found, _, notified, txErr = SetFindingStatusTx(ctx, tx, id, status)
|
||||
return txErr
|
||||
})
|
||||
return from, found, notified, err
|
||||
}
|
||||
|
||||
// SetFindingStatusTx 在**调用方的事务**内更新漏洞状态并登记状态变更推送事件。
|
||||
//
|
||||
// 抽成事务级函数是为了让所有改状态的路径共用同一套语义——此前只有
|
||||
// patchFinding 走带通知的版本,而**复测结论为「已修复」时**(finding_retests
|
||||
// 里那条 `UPDATE findings SET status=...`)是直接写库的,于是配了
|
||||
// `on_status_change` 的渠道对这类状态流转完全收不到推送:界面上状态悄悄变了,
|
||||
// 运维要到打开平台才发现。
|
||||
//
|
||||
// 返回 from=变更前状态、found=漏洞是否存在、changed=状态是否真的变了、
|
||||
// notified=事件是否登记成功(登记失败不影响状态更新,见 RecordNotificationEventTx)。
|
||||
func SetFindingStatusTx(ctx context.Context, tx *sql.Tx, id int64, status string) (from string, found bool, changed bool, notified bool, err error) {
|
||||
var (
|
||||
vulnclass, name, severity, summary string
|
||||
taskID sql.NullInt64
|
||||
assetIDs []byte
|
||||
)
|
||||
scanErr := tx.QueryRowContext(ctx, `SELECT vulnclass, name, severity, summary, task_id, asset_ids, status
|
||||
FROM findings WHERE id=$1 FOR UPDATE`, id).
|
||||
Scan(&vulnclass, &name, &severity, &summary, &taskID, &assetIDs, &from)
|
||||
if scanErr == sql.ErrNoRows {
|
||||
return "", false, false, false, nil
|
||||
}
|
||||
if scanErr != nil {
|
||||
return "", false, false, false, scanErr
|
||||
}
|
||||
found = true
|
||||
if from == status {
|
||||
// 状态没有真的变化就不登记事件:重复提交同一个值、幂等重放都不该
|
||||
// 产生推送噪音。
|
||||
return from, true, false, false, nil
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE findings SET status=$2 WHERE id=$1`, id, status); err != nil {
|
||||
return from, true, false, false, err
|
||||
}
|
||||
var assets []int64
|
||||
_ = json.Unmarshal(assetIDs, &assets)
|
||||
notified = RecordNotificationEventTx(ctx, tx, notify.EventFindingStatusChanged, id, notify.Snapshot{
|
||||
Kind: notify.EventFindingStatusChanged,
|
||||
FindingID: id,
|
||||
TaskID: taskID.Int64,
|
||||
VulnClass: vulnclass,
|
||||
Name: name,
|
||||
Severity: severity,
|
||||
Summary: summary,
|
||||
AssetIDs: assets,
|
||||
FromStatus: from,
|
||||
ToStatus: status,
|
||||
})
|
||||
return from, true, true, notified, nil
|
||||
}
|
||||
|
||||
// NotificationStats 是通知页顶部的概览计数。
|
||||
type NotificationStats struct {
|
||||
Channels int `json:"channels"`
|
||||
ChannelsOn int `json:"channels_on"`
|
||||
Pending int `json:"pending"`
|
||||
Failed int `json:"failed"`
|
||||
SentToday int `json:"sent_today"`
|
||||
BacklogAgeMS int64 `json:"backlog_age_ms"` // 最老的待发投递距今毫秒数
|
||||
}
|
||||
|
||||
// NotificationStatsSnapshot 汇总通知系统的健康度。
|
||||
// BacklogAgeMS 是「推送是不是卡住了」最直接的指标——比 pending 计数有用得多,
|
||||
// 因为积压 3 条和积压 3 条的差别可以是从 3 秒到 3 小时。
|
||||
func (d *DB) NotificationStatsSnapshot(ctx context.Context) (*NotificationStats, error) {
|
||||
var s NotificationStats
|
||||
if err := d.QueryRowContext(ctx, `SELECT
|
||||
(SELECT count(*) FROM notification_channels),
|
||||
(SELECT count(*) FROM notification_channels WHERE enabled),
|
||||
(SELECT count(*) FROM notification_deliveries WHERE state IN ($1,$2)),
|
||||
(SELECT count(*) FROM notification_deliveries WHERE state=$3),
|
||||
(SELECT count(*) FROM notification_deliveries WHERE state=$4 AND sent_at >= date_trunc('day', now())),
|
||||
COALESCE((SELECT EXTRACT(EPOCH FROM (now() - min(created_at))) * 1000 FROM notification_deliveries WHERE state=$1), 0)::bigint`,
|
||||
NotifyStatePending, NotifyStateSending, NotifyStateFailed, NotifyStateSent).
|
||||
Scan(&s.Channels, &s.ChannelsOn, &s.Pending, &s.Failed, &s.SentToday, &s.BacklogAgeMS); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件是投递任务的领取与状态流转。
|
||||
//
|
||||
// 领取用「租约」而非长事务:把行置为 sending 并把 next_attempt_at 推到未来作为
|
||||
// 租约到期时间,提交事务后再去做网络投递。这样投递期间不持有数据库锁——
|
||||
// 网络请求可能耗时数秒(客户端超时 15 秒),占着行锁不放会拖垮同库的其它写操作。
|
||||
//
|
||||
// 代价是进程若在投递途中崩溃,行会停在 sending。这是**可自愈**的:租约到期后
|
||||
// next_attempt_at 落入过去,下一轮领取会把同一行重新捞起来(见领取条件里的
|
||||
// state IN ('pending','sending'))。重试计数在领取时就已 +1,所以崩溃不会造成
|
||||
// 无限重试——MaxNotifyAttempts 次机会用完后落入 failed 等人工处理。
|
||||
|
||||
// MaxNotifyAttempts 是一条投递的最大尝试次数(含首次)。
|
||||
// 定义在这里而非投递引擎里:它是状态机自身的策略,引擎只是执行者。
|
||||
const MaxNotifyAttempts = 3
|
||||
|
||||
// MaxDigestBatchSize 是单个汇总批次一次最多合并多少条投递。
|
||||
//
|
||||
// 存在的理由是资源:一个汇总周期内如果扫出几万个漏洞(完全可能——一次全量扫描
|
||||
// 就能做到),不设上界的话领取会把全部行读进内存、渲染成一条超长消息,
|
||||
// 然后被渠道的长度上限截掉大半——既浪费内存,又**静默丢失**被截掉的那些漏洞。
|
||||
// 设上界后,超出的部分留在库里成为下一个批次,下个周期自然发出去,不会丢。
|
||||
//
|
||||
// 取 500 的依据:它是渲染成消息后在企微 4096 字节上限内还"有内容可读"的量级;
|
||||
// 再大也只是让截断发生在更靠后的位置而已。
|
||||
const MaxDigestBatchSize = 500
|
||||
|
||||
// NotificationDelivery 是一条投递任务,含渲染所需的渠道配置与事件快照。
|
||||
type NotificationDelivery struct {
|
||||
ID int64 `json:"id"`
|
||||
EventID int64 `json:"event_id"`
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
State string `json:"state"`
|
||||
Attempts int `json:"attempts"`
|
||||
NextAttemptAt time.Time `json:"next_attempt_at"`
|
||||
LastError string `json:"last_error"`
|
||||
BatchID *int64 `json:"batch_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
SentAt *time.Time `json:"sent_at,omitempty"`
|
||||
Snapshot json.RawMessage `json:"snapshot,omitempty"`
|
||||
// 联合加载的渲染上下文,不进 JSON(由 server 层组装 DTO)。
|
||||
Channel *NotificationChannel `json:"-"`
|
||||
// FindingID/EventKind 从事件带出,供历史列表直接跳转漏洞详情。
|
||||
FindingID int64 `json:"finding_id,string"`
|
||||
EventKind string `json:"event_kind"`
|
||||
// ChannelName/ChannelKind 是列表展示用的冗余字段,省掉前端二次查询。
|
||||
ChannelName string `json:"channel_name"`
|
||||
ChannelKind string `json:"channel_kind"`
|
||||
}
|
||||
|
||||
const notificationDeliveryCols = `d.id, d.event_id, d.channel_id, d.state, d.attempts, d.next_attempt_at,
|
||||
d.last_error, d.batch_id, d.created_at, d.sent_at`
|
||||
|
||||
// joinedDeliveryQuery 是投递行的统一读取形状:投递 + 事件快照 + 渠道配置。
|
||||
// 渲染一条消息三者缺一不可,分开查会写出三次往返。
|
||||
const joinedDeliveryQuery = `SELECT ` + notificationDeliveryCols + `,
|
||||
e.snapshot, e.kind, e.finding_id,
|
||||
c.id, c.name, c.kind, c.enabled, c.config, c.mode, c.filter, c.rate_per_min
|
||||
FROM notification_deliveries d
|
||||
JOIN notification_events e ON e.id = d.event_id
|
||||
JOIN notification_channels c ON c.id = d.channel_id`
|
||||
|
||||
func scanNotificationDelivery(sc interface{ Scan(...any) error }) (*NotificationDelivery, error) {
|
||||
var (
|
||||
dl NotificationDelivery
|
||||
lastErr sql.NullString
|
||||
batchID sql.NullInt64
|
||||
sentAt sql.NullTime
|
||||
snapshot []byte
|
||||
eventKind string
|
||||
channel NotificationChannel
|
||||
chEnabled bool
|
||||
)
|
||||
if err := sc.Scan(&dl.ID, &dl.EventID, &dl.ChannelID, &dl.State, &dl.Attempts, &dl.NextAttemptAt,
|
||||
&lastErr, &batchID, &dl.CreatedAt, &sentAt,
|
||||
&snapshot, &eventKind, &dl.FindingID,
|
||||
&channel.ID, &channel.Name, &channel.Kind, &chEnabled, &channel.Config, &channel.Mode, &channel.Filter, &channel.RatePerMin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dl.LastError = lastErr.String
|
||||
if batchID.Valid {
|
||||
dl.BatchID = &batchID.Int64
|
||||
}
|
||||
if sentAt.Valid {
|
||||
dl.SentAt = &sentAt.Time
|
||||
}
|
||||
dl.Snapshot = json.RawMessage(snapshot)
|
||||
dl.EventKind = eventKind
|
||||
dl.ChannelName = channel.Name
|
||||
dl.ChannelKind = channel.Kind
|
||||
channel.Enabled = &chEnabled
|
||||
dl.Channel = &channel
|
||||
return &dl, nil
|
||||
}
|
||||
|
||||
// claimQuery 描述一次领取:先按 sel 选出候选并加锁,再把它们置为 sending 并
|
||||
// 延长租约。sel 里的 lease 位置由调用方用 $n 占位并自行传参。
|
||||
type claimQuery struct {
|
||||
sql string
|
||||
args []any
|
||||
}
|
||||
|
||||
// ClaimRealtimeDeliveries 领取某渠道一批到期的实时投递,最多 limit 条。
|
||||
//
|
||||
// 刻意按**单个渠道**领取而不是「全局领一批再挑着发」:限流闸在投递引擎里按渠道
|
||||
// 维护,只有先知道这个渠道这一轮还能发几条、再去领同样多的行,限流才不会消耗
|
||||
// 重试次数。若反过来先领后弃,被限流挡下的行已经被计过一次 attempts,
|
||||
// 3 次预算会被纯粹的等待耗光,最后落进 failed。
|
||||
//
|
||||
// 条件含「租约已过期的 sending」——那是崩溃自愈的落点。lease 必须显著大于单次
|
||||
// 投递的最坏耗时(渠道 HTTP 客户端超时 15 秒),否则同一行会被两个 dispatcher
|
||||
// 同时投递。同时挡掉已停用渠道:停用操作已把存量投递标记为 skipped,
|
||||
// 这里再拦一道,避免停用与领取并发时的漏网。
|
||||
func (d *DB) ClaimRealtimeDeliveries(ctx context.Context, channelID int64, limit int, lease time.Duration) ([]*NotificationDelivery, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return d.claimDeliveries(ctx, lease, claimQuery{
|
||||
sql: `SELECT dd.id FROM notification_deliveries dd
|
||||
JOIN notification_channels c ON c.id = dd.channel_id
|
||||
WHERE dd.channel_id = $1 AND dd.state IN ($2,$3) AND dd.next_attempt_at <= now()
|
||||
AND c.enabled AND c.mode = $4
|
||||
ORDER BY dd.next_attempt_at, dd.id
|
||||
FOR UPDATE OF dd SKIP LOCKED
|
||||
LIMIT $5`,
|
||||
args: []any{channelID, NotifyStatePending, NotifyStateSending, NotifyModeRealtime, limit},
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// DigestBatchDue 报告该渠道是否已攒够一个到期批次:存在待发投递,且**最老的那条**
|
||||
// 年龄已达到汇总周期。
|
||||
//
|
||||
// 判定依据是最老投递的年龄而非墙上时钟:这样刚建好的渠道不会因为对齐到整点而
|
||||
// 立刻吐出一条只有一条的「汇总」,积压很久的批次也不会再白等一轮。
|
||||
//
|
||||
// 与 ClaimDigestBatch 分开是因为语义不同:本函数只回答「该不该发」,
|
||||
// 而领取要拿走该渠道**全部**待发行(包括尚未满年龄的那些)——否则一个周期
|
||||
// 会被拆成多条消息,汇总就失去意义了。
|
||||
func (d *DB) DigestBatchDue(ctx context.Context, channelID int64, minAge time.Duration) (bool, error) {
|
||||
var due bool
|
||||
err := d.QueryRowContext(ctx, `SELECT EXISTS (
|
||||
SELECT 1 FROM notification_deliveries d
|
||||
JOIN notification_channels c ON c.id = d.channel_id
|
||||
WHERE d.channel_id = $1 AND d.state IN ($2,$3) AND c.enabled
|
||||
GROUP BY d.channel_id
|
||||
HAVING min(d.created_at) <= now() - make_interval(secs => $4)
|
||||
)`, channelID, NotifyStatePending, NotifyStateSending, int64(minAge.Seconds())).Scan(&due)
|
||||
return due, err
|
||||
}
|
||||
|
||||
// ClaimDigestBatch 领取某渠道当前到期的待发投递,作为一个汇总批次,
|
||||
// 单批最多 MaxDigestBatchSize 条。
|
||||
//
|
||||
// 同批次的所有投递共享 batch_id,用集合里的最小 id 作批次号(稳定、可读、
|
||||
// 无需额外序列)。重试时用 COALESCE 保留原批次号,使「这批 N 条是一起发的」
|
||||
// 在多次重试后依然成立。
|
||||
//
|
||||
// 按 id 升序取前 N 条而非随机取:最早产生的投递最先发出去,积压时不会出现
|
||||
// 「新漏洞先发、老漏洞永远排在后面」的饥饿。
|
||||
func (d *DB) ClaimDigestBatch(ctx context.Context, channelID int64, limit int, lease time.Duration) ([]*NotificationDelivery, error) {
|
||||
if limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// 单批同时受两个上界约束:调用方的限流额度(本轮还能发几条)与内存上界
|
||||
// MaxDigestBatchSize。此前只用了后者,于是 digest 渠道的 rate_per_min
|
||||
// 完全不生效——令牌被 takeTokens 扣掉却没人用,等于白扣。
|
||||
if limit > MaxDigestBatchSize {
|
||||
limit = MaxDigestBatchSize
|
||||
}
|
||||
out, err := d.claimDeliveries(ctx, lease, claimQuery{
|
||||
sql: `SELECT dd.id FROM notification_deliveries dd
|
||||
JOIN notification_channels c ON c.id = dd.channel_id
|
||||
WHERE dd.channel_id = $1 AND dd.state IN ($2,$3) AND dd.next_attempt_at <= now() AND c.enabled
|
||||
ORDER BY dd.id
|
||||
FOR UPDATE OF dd SKIP LOCKED
|
||||
LIMIT $4`,
|
||||
args: []any{channelID, NotifyStatePending, NotifyStateSending, limit},
|
||||
}, func(tx *sql.Tx, ids []int64) error {
|
||||
batchID := ids[0]
|
||||
for _, id := range ids {
|
||||
if id < batchID {
|
||||
batchID = id
|
||||
}
|
||||
}
|
||||
ph, idArgs := placeholders(2, ids)
|
||||
_, err := tx.ExecContext(ctx, `UPDATE notification_deliveries SET batch_id = COALESCE(batch_id, $1)
|
||||
WHERE id IN (`+ph+`)`, append([]any{batchID}, idArgs...)...)
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// claimDeliveries 执行「选取 + 置 sending 延长租约 + 读取完整行」,全在一个事务里。
|
||||
// postClaim 是可选的附加步骤(汇总批次用它写入 batch_id)。
|
||||
func (d *DB) claimDeliveries(ctx context.Context, lease time.Duration, cq claimQuery, postClaim func(*sql.Tx, []int64) error) ([]*NotificationDelivery, error) {
|
||||
tx, err := d.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // 提交成功后是 no-op
|
||||
|
||||
ids, err := selectForClaim(ctx, tx, cq.sql, cq.args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, tx.Commit()
|
||||
}
|
||||
// 置 sending 并把 next_attempt_at 推到未来:这个未来时刻即租约到期时间,
|
||||
// 「租约未到期」与「未到重试时间」因此共用同一个条件表达,不需要新增列。
|
||||
ph, idArgs := placeholders(3, ids)
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE notification_deliveries
|
||||
SET state=$1, attempts=attempts+1, next_attempt_at=now()+make_interval(secs => $2)
|
||||
WHERE id IN (`+ph+`)`,
|
||||
append([]any{NotifyStateSending, lease.Seconds()}, idArgs...)...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if postClaim != nil {
|
||||
if err := postClaim(tx, ids); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
out, err := loadDeliveriesTx(ctx, tx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, tx.Commit()
|
||||
}
|
||||
|
||||
func selectForClaim(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]int64, error) {
|
||||
rows, err := tx.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
func loadDeliveriesTx(ctx context.Context, tx *sql.Tx, ids []int64) ([]*NotificationDelivery, error) {
|
||||
ph, args := placeholders(1, ids)
|
||||
rows, err := tx.QueryContext(ctx, joinedDeliveryQuery+` WHERE d.id IN (`+ph+`) ORDER BY d.id`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []*NotificationDelivery{}
|
||||
for rows.Next() {
|
||||
dl, err := scanNotificationDelivery(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, dl)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MarkDeliveriesSent 把一批投递标记为已送达。
|
||||
func (d *DB) MarkDeliveriesSent(ctx context.Context, ids []int64) error {
|
||||
ph, args := placeholders(2, ids)
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := d.ExecContext(ctx, `UPDATE notification_deliveries
|
||||
SET state=$1, sent_at=now(), last_error='' WHERE id IN (`+ph+`)`, append([]any{NotifyStateSent}, args...)...)
|
||||
return err
|
||||
}
|
||||
|
||||
// RescheduleDeliveries 把一批投递退回 pending 并推后重试时间。
|
||||
//
|
||||
// 退回 pending 而不是引入新的中间状态,是为了让「还剩几次机会」只由一个地方
|
||||
// 表达(MaxNotifyAttempts),避免状态机的分支随重试策略膨胀。
|
||||
func (d *DB) RescheduleDeliveries(ctx context.Context, ids []int64, delay time.Duration, errMsg string) error {
|
||||
ph, args := placeholders(4, ids)
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := d.ExecContext(ctx, `UPDATE notification_deliveries
|
||||
SET state=$1, next_attempt_at=now()+make_interval(secs => $2), last_error=$3
|
||||
WHERE id IN (`+ph+`)`,
|
||||
append([]any{NotifyStatePending, delay.Seconds(), truncateNotifyError(errMsg)}, args...)...)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeferDeliveries 把一批投递退回 pending、立即可再领,并**撤销领取时计的那一次尝试**。
|
||||
//
|
||||
// 用途只有一个:汇总消息按渠道长度上限分段发送时,没装进本条的条目要留到下一批。
|
||||
// 那不是失败,所以不该消耗重试预算——领取时 attempts 已经乐观地 +1 了,
|
||||
// 这里必须减回去。否则一个 500 条的积压会按每段 20 条切成 25 段,
|
||||
// 尾部条目在第 3 段就被 MaxNotifyAttempts 判成 failed,而它们从未出过任何错。
|
||||
//
|
||||
// GREATEST(...,0) 兜住「有人手工重发把 attempts 清零后又走到这里」的情况,
|
||||
// 不让计数变成负数。
|
||||
func (d *DB) DeferDeliveries(ctx context.Context, ids []int64, reason string) error {
|
||||
ph, args := placeholders(3, ids)
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := d.ExecContext(ctx, `UPDATE notification_deliveries
|
||||
SET state=$1, attempts=GREATEST(attempts-1, 0), next_attempt_at=now(), last_error=$2
|
||||
WHERE id IN (`+ph+`)`,
|
||||
append([]any{NotifyStatePending, truncateNotifyError(reason)}, args...)...)
|
||||
return err
|
||||
}
|
||||
|
||||
// FailDeliveries 把一批投递标记为最终失败,等待人工在投递历史里重发。
|
||||
func (d *DB) FailDeliveries(ctx context.Context, ids []int64, errMsg string) error {
|
||||
// 占位符从 $3 开始:$1 是 state、$2 是 last_error。
|
||||
ph, args := placeholders(3, ids)
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := d.ExecContext(ctx, `UPDATE notification_deliveries SET state=$1, last_error=$2 WHERE id IN (`+ph+`)`,
|
||||
append([]any{NotifyStateFailed, truncateNotifyError(errMsg)}, args...)...)
|
||||
return err
|
||||
}
|
||||
|
||||
// RetryNotificationDelivery 手动重发一条投递:重置为 pending、清零重试计数、
|
||||
// 立即到期。清计数是刻意的——人工点「重发」意味着前几次失败的原因已被处理,
|
||||
// 再拿旧计数限制它没有道理。
|
||||
func (d *DB) RetryNotificationDelivery(ctx context.Context, id int64) error {
|
||||
res, err := d.ExecContext(ctx, `UPDATE notification_deliveries
|
||||
SET state=$2, attempts=0, next_attempt_at=now(), last_error=''
|
||||
WHERE id=$1 AND state IN ($3,$4)`, id, NotifyStatePending, NotifyStateFailed, NotifyStateSkipped)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return fmt.Errorf("投递 %d 不存在或当前状态不允许重发", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotificationDeliveryFilter 是投递历史的查询条件。
|
||||
type NotificationDeliveryFilter struct {
|
||||
ChannelID int64
|
||||
State string
|
||||
EventKind string
|
||||
}
|
||||
|
||||
func (f NotificationDeliveryFilter) where() (string, []any) {
|
||||
var conds []string
|
||||
var args []any
|
||||
if f.ChannelID > 0 {
|
||||
args = append(args, f.ChannelID)
|
||||
conds = append(conds, fmt.Sprintf("d.channel_id=$%d", len(args)))
|
||||
}
|
||||
if f.State != "" {
|
||||
args = append(args, f.State)
|
||||
conds = append(conds, fmt.Sprintf("d.state=$%d", len(args)))
|
||||
}
|
||||
if f.EventKind != "" {
|
||||
args = append(args, f.EventKind)
|
||||
conds = append(conds, fmt.Sprintf("e.kind=$%d", len(args)))
|
||||
}
|
||||
if len(conds) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return " WHERE " + strings.Join(conds, " AND "), args
|
||||
}
|
||||
|
||||
// ListNotificationDeliveries 分页返回投递历史,新的在前。
|
||||
func (d *DB) ListNotificationDeliveries(ctx context.Context, f NotificationDeliveryFilter, page, pageSize int) ([]*NotificationDelivery, int, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 || pageSize > 200 {
|
||||
pageSize = 50
|
||||
}
|
||||
where, args := f.where()
|
||||
|
||||
var total int
|
||||
if err := d.QueryRowContext(ctx, `SELECT count(*) FROM notification_deliveries d
|
||||
JOIN notification_events e ON e.id = d.event_id`+where, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
q := fmt.Sprintf("%s%s ORDER BY d.id DESC LIMIT $%d OFFSET $%d",
|
||||
joinedDeliveryQuery, where, len(args)+1, len(args)+2)
|
||||
rows, err := d.QueryContext(ctx, q, append(args, pageSize, (page-1)*pageSize)...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []*NotificationDelivery{}
|
||||
for rows.Next() {
|
||||
dl, err := scanNotificationDelivery(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, dl)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// truncateNotifyError 把错误信息截到列可接受的长度。渠道返回的响应体可能很长
|
||||
// (通用 Webhook 打到自建服务时尤甚),不截断会让历史列表的载荷膨胀。
|
||||
func truncateNotifyError(msg string) string {
|
||||
const max = 500
|
||||
if len(msg) <= max {
|
||||
return msg
|
||||
}
|
||||
// 按字符边界回退,避免留下半个 UTF-8 字符让前端显示成乱码。
|
||||
cut := max
|
||||
for cut > 0 && !isUTF8Start(msg[cut]) {
|
||||
cut--
|
||||
}
|
||||
return msg[:cut] + "…"
|
||||
}
|
||||
|
||||
func isUTF8Start(b byte) bool { return b&0xC0 != 0x80 }
|
||||
|
||||
// placeholders 生成从 start 开始的 $n 占位串及对应参数,供 IN (...) 使用。
|
||||
// 例如 start=3, ids=[7,8] → "$3,$4", [7,8]。
|
||||
func placeholders(start int, ids []int64) (string, []any) {
|
||||
ph := make([]string, 0, len(ids))
|
||||
args := make([]any, 0, len(ids))
|
||||
for i, id := range ids {
|
||||
ph = append(ph, fmt.Sprintf("$%d", start+i))
|
||||
args = append(args, id)
|
||||
}
|
||||
return strings.Join(ph, ","), args
|
||||
}
|
||||
@@ -0,0 +1,874 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/notify"
|
||||
)
|
||||
|
||||
// 本文件的用例都会真连 PostgreSQL(无库时跳过)。这些 SQL 用到了
|
||||
// FOR UPDATE SKIP LOCKED、make_interval、JSONB、多行 IN(...) 占位符拼接,
|
||||
// 都是「编译通过但可能运行时报错」的写法,必须实跑才算验证过。
|
||||
|
||||
func notifyTestDB(t *testing.T) *DB {
|
||||
t.Helper()
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
t.Cleanup(func() { d.Close() })
|
||||
return d
|
||||
}
|
||||
|
||||
// newTestChannel 建一个渠道,测试结束自动删除。
|
||||
func newTestChannel(t *testing.T, d *DB, kind, mode string, filter string) *NotificationChannel {
|
||||
t.Helper()
|
||||
if filter == "" {
|
||||
filter = `{}`
|
||||
}
|
||||
ch := &NotificationChannel{
|
||||
Name: "测试渠道-" + t.Name(),
|
||||
Kind: kind,
|
||||
Mode: mode,
|
||||
Config: json.RawMessage(`{"webhook":"https://example.com/hook"}`),
|
||||
Filter: json.RawMessage(filter),
|
||||
RatePerMin: 100,
|
||||
}
|
||||
id, err := d.SaveNotificationChannel(context.Background(), ch)
|
||||
if err != nil {
|
||||
t.Fatalf("建渠道失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { d.Exec(`DELETE FROM notification_channels WHERE id=$1`, id) })
|
||||
ch.ID = id
|
||||
return ch
|
||||
}
|
||||
|
||||
// addTestEvent 直接写一条事件(不经 finding),用于测试分派与投递。
|
||||
func addTestEvent(t *testing.T, d *DB, kind string, findingID int64, snap notify.Snapshot) int64 {
|
||||
t.Helper()
|
||||
snap.Kind = kind
|
||||
snap.FindingID = findingID
|
||||
id, err := d.AddNotificationEvent(context.Background(), kind, findingID, snap)
|
||||
if err != nil {
|
||||
t.Fatalf("写事件失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { d.Exec(`DELETE FROM notification_events WHERE id=$1`, id) })
|
||||
return id
|
||||
}
|
||||
|
||||
func TestNotificationAssetNamesResolvesAndPreservesOrder(t *testing.T) {
|
||||
d := notifyTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 三类资产各有各的展示口径:域名、IP、URL。
|
||||
insertAsset := func(query, value string) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
if err := d.QueryRow(query, value).Scan(&id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
domID := insertAsset(`INSERT INTO assets(type, domain) VALUES('subdomain',$1) RETURNING id`, "a.example.com")
|
||||
ipID := insertAsset(`INSERT INTO assets(type, ip) VALUES('ip',$1) RETURNING id`, "10.1.2.3")
|
||||
svcID := insertAsset(`INSERT INTO assets(type, url) VALUES('service',$1) RETURNING id`, "https://a.example.com/admin")
|
||||
t.Cleanup(func() {
|
||||
d.Exec(`DELETE FROM assets WHERE id IN ($1,$2,$3)`, domID, ipID, svcID)
|
||||
})
|
||||
|
||||
// 传入顺序刻意乱序,且含一个不存在的 id。
|
||||
got, err := d.NotificationAssetNames(ctx, []int64{svcID, 999999999, domID, ipID, svcID})
|
||||
if err != nil {
|
||||
t.Fatalf("解析资产名失败: %v", err)
|
||||
}
|
||||
want := []string{"https://a.example.com/admin", "a.example.com", "10.1.2.3"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("资产名数量不符,期望 %v 得到 %v", want, got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("顺序/取值不符,期望 %v 得到 %v", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordNotificationEventTxUnwindsOnFailure 是保存点机制的核心用例:
|
||||
// 在事务里先让 notification_events 的写入必然失败(临时加一个恒 false 的约束),
|
||||
// 断言 ① 该函数报 false ② 事务没有进入 aborted 状态,后续语句仍能执行。
|
||||
//
|
||||
// 没有保存点的话,PostgreSQL 会让整个事务作废,后续任何语句都以
|
||||
// "current transaction is aborted" 失败——那正是「一个通知表的问题导致
|
||||
// 漏洞存不进库」的故障路径。
|
||||
//
|
||||
// 这里刻意用 **ROLLBACK 收尾而不是 COMMIT**:ALTER TABLE 在 PG 里是事务性的,
|
||||
// 一旦提交,那个临时约束就会永久留在 schema 里,把后续所有用例一起打挂。
|
||||
// 回滚能自动撤销 DDL,无需手工清理。断言只需要「事务还活着」,
|
||||
// 不需要真的提交。
|
||||
func TestRecordNotificationEventTxUnwindsOnFailure(t *testing.T) {
|
||||
d := notifyTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 防御性清理:若历史运行留下过这个约束,先摘掉。
|
||||
if _, err := d.Exec(`ALTER TABLE notification_events DROP CONSTRAINT IF EXISTS notify_test_never`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tx, err := d.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // 撤销临时约束,见函数注释
|
||||
|
||||
// NOT VALID:只约束此后写入的行,不去校验库里已有的历史事件
|
||||
// (否则存量行违规会导致约束加不上)。
|
||||
if _, err := tx.ExecContext(ctx, `ALTER TABLE notification_events ADD CONSTRAINT notify_test_never CHECK (false) NOT VALID`); err != nil {
|
||||
t.Fatalf("加临时约束失败: %v", err)
|
||||
}
|
||||
if RecordNotificationEventTx(ctx, tx, notify.EventFindingCreated, 1, notify.Snapshot{Severity: "high"}) {
|
||||
t.Fatal("在必然失败的约束下仍报告写入成功")
|
||||
}
|
||||
// 关键断言:事务还能用。
|
||||
var one int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT 1`).Scan(&one); err != nil {
|
||||
t.Fatalf("事务已被污染(保存点未生效): %v", err)
|
||||
}
|
||||
if err := tx.Rollback(); err != nil {
|
||||
t.Fatalf("回滚失败: %v", err)
|
||||
}
|
||||
// 确认 DDL 已随回滚撤销,不给后续用例留雷。
|
||||
var exists bool
|
||||
if err := d.QueryRow(`SELECT EXISTS(SELECT 1 FROM pg_constraint WHERE conname='notify_test_never')`).Scan(&exists); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exists {
|
||||
t.Fatal("临时约束未被回滚撤销,会污染后续用例")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanOutRoutesEventsByFilter(t *testing.T) {
|
||||
d := notifyTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
all := newTestChannel(t, d, notify.KindDingTalk, NotifyModeRealtime, `{}`)
|
||||
onlyCritical := newTestChannel(t, d, notify.KindDingTalk, NotifyModeRealtime, `{"min_severity":"critical"}`)
|
||||
sqlOnly := newTestChannel(t, d, notify.KindDingTalk, NotifyModeRealtime, `{"vulnclass_include":["SQL"]}`)
|
||||
|
||||
highSQL := addTestEvent(t, d, notify.EventFindingCreated, 1001, notify.Snapshot{Severity: "high", VulnClass: "SQL注入"})
|
||||
lowXSS := addTestEvent(t, d, notify.EventFindingCreated, 1002, notify.Snapshot{Severity: "low", VulnClass: "XSS"})
|
||||
criticalXSS := addTestEvent(t, d, notify.EventFindingCreated, 1003, notify.Snapshot{Severity: "critical", VulnClass: "XSS"})
|
||||
|
||||
if _, _, err := d.FanOutPendingEvents(ctx, 100); err != nil {
|
||||
t.Fatalf("分派失败: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
eventID int64
|
||||
channel int64
|
||||
want bool
|
||||
}{
|
||||
{"全收渠道收到 high", highSQL, all.ID, true},
|
||||
{"全收渠道收到 low", lowXSS, all.ID, true},
|
||||
{"仅严重渠道跳过 high", highSQL, onlyCritical.ID, false},
|
||||
{"仅严重渠道收到 critical", criticalXSS, onlyCritical.ID, true},
|
||||
{"仅SQL渠道收到 SQL", highSQL, sqlOnly.ID, true},
|
||||
{"仅SQL渠道跳过 XSS", lowXSS, sqlOnly.ID, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var exists bool
|
||||
if err := d.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM notification_deliveries WHERE event_id=$1 AND channel_id=$2)`,
|
||||
tc.eventID, tc.channel).Scan(&exists); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exists != tc.want {
|
||||
t.Fatalf("投递是否存在: 期望 %v 得到 %v", tc.want, exists)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 再分派一次不应产生重复投递(fanned_out 幂等)。
|
||||
events, deliveries, err := d.FanOutPendingEvents(ctx, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if events != 0 || deliveries != 0 {
|
||||
t.Fatalf("已分派的事件不应被再次处理,得到 events=%d deliveries=%d", events, deliveries)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFanOutMarksEventsWithNoMatchingChannel 覆盖「事件没命中任何渠道」的情况。
|
||||
// 这类事件必须照样被标记为已分派,否则它会永远留在待分派集合里、每个 tick 重扫。
|
||||
func TestFanOutMarksEventsWithNoMatchingChannel(t *testing.T) {
|
||||
d := notifyTestDB(t)
|
||||
ctx := context.Background()
|
||||
pick := newTestChannel(t, d, notify.KindDingTalk, NotifyModeRealtime, `{"vulnclass_include":["绝不匹配的类型"]}`)
|
||||
_ = pick
|
||||
|
||||
ev := addTestEvent(t, d, notify.EventFindingCreated, 2001, notify.Snapshot{Severity: "high", VulnClass: "XSS"})
|
||||
_, deliveries, err := d.FanOutPendingEvents(ctx, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deliveries != 0 {
|
||||
t.Fatalf("不该产生投递,得到 %d", deliveries)
|
||||
}
|
||||
var fanned bool
|
||||
if err := d.QueryRowContext(ctx, `SELECT fanned_out FROM notification_events WHERE id=$1`, ev).Scan(&fanned); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !fanned {
|
||||
t.Fatal("未命中渠道的事件也必须标记为已分派,否则会被无限重扫")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimRealtimeDeliveriesHonorsLeaseAndMode(t *testing.T) {
|
||||
d := notifyTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
realtime := newTestChannel(t, d, notify.KindDingTalk, NotifyModeRealtime, `{}`)
|
||||
digest := newTestChannel(t, d, notify.KindDingTalk, NotifyModeDigest, `{}`)
|
||||
|
||||
addTestEvent(t, d, notify.EventFindingCreated, 3001, notify.Snapshot{Severity: "high", VulnClass: "XSS"})
|
||||
if _, _, err := d.FanOutPendingEvents(ctx, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 实时领取只应拿到 realtime 渠道的那条,不该动 digest 渠道的。
|
||||
got, err := d.ClaimRealtimeDeliveries(ctx, realtime.ID, 10, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("领取失败: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("应领到 1 条,得到 %d", len(got))
|
||||
}
|
||||
if got[0].State != NotifyStateSending || got[0].Attempts != 1 {
|
||||
t.Fatalf("领取后应为 sending 且 attempts=1,得到 state=%s attempts=%d", got[0].State, got[0].Attempts)
|
||||
}
|
||||
// 关联加载的渲染上下文必须齐全(渠道配置 + 事件快照 + finding id)。
|
||||
if got[0].Channel == nil || len(got[0].Channel.Config) == 0 {
|
||||
t.Fatal("领取结果缺少渠道配置,渲染会失败")
|
||||
}
|
||||
if got[0].FindingID != 3001 {
|
||||
t.Fatalf("finding id 未从事件带出,得到 %d", got[0].FindingID)
|
||||
}
|
||||
|
||||
// 租约未到期,第二次领取应为空——这是「同一行不会被两个 dispatcher 同时投递」
|
||||
// 的保证。
|
||||
again, err := d.ClaimRealtimeDeliveries(ctx, realtime.ID, 10, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(again) != 0 {
|
||||
t.Fatalf("租约期内不应重复领取,得到 %d 条", len(again))
|
||||
}
|
||||
|
||||
// digest 渠道的投递不应被实时领取碰到。
|
||||
left, err := d.ClaimRealtimeDeliveries(ctx, digest.ID, 10, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(left) != 0 {
|
||||
t.Fatalf("实时领取不应拿到 digest 渠道的投递,得到 %d 条", len(left))
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaimExpiredLeaseRecovers 覆盖崩溃自愈:进程在投递途中挂掉会留下 sending
|
||||
// 行,租约到期后必须能被重新领起来,否则这条投递永远卡住。
|
||||
func TestClaimExpiredLeaseRecovers(t *testing.T) {
|
||||
d := notifyTestDB(t)
|
||||
ctx := context.Background()
|
||||
ch := newTestChannel(t, d, notify.KindDingTalk, NotifyModeRealtime, `{}`)
|
||||
addTestEvent(t, d, notify.EventFindingCreated, 4001, notify.Snapshot{Severity: "high"})
|
||||
if _, _, err := d.FanOutPendingEvents(ctx, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := d.ClaimRealtimeDeliveries(ctx, ch.ID, 10, time.Minute)
|
||||
if err != nil || len(first) != 1 {
|
||||
t.Fatalf("首次领取失败: %v (%d 条)", err, len(first))
|
||||
}
|
||||
// 把租约手动推到过去,模拟「租约已过期」。
|
||||
if _, err := d.Exec(`UPDATE notification_deliveries SET next_attempt_at = now() - interval '1 minute' WHERE id=$1`, first[0].ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := d.ClaimRealtimeDeliveries(ctx, ch.ID, 10, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(second) != 1 {
|
||||
t.Fatalf("租约过期的 sending 行应可被重新领取,得到 %d 条", len(second))
|
||||
}
|
||||
if second[0].Attempts != 2 {
|
||||
t.Fatalf("重新领取应累加尝试次数,得到 %d", second[0].Attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimSkipsDisabledChannel(t *testing.T) {
|
||||
d := notifyTestDB(t)
|
||||
ctx := context.Background()
|
||||
ch := newTestChannel(t, d, notify.KindDingTalk, NotifyModeRealtime, `{}`)
|
||||
addTestEvent(t, d, notify.EventFindingCreated, 5001, notify.Snapshot{Severity: "high"})
|
||||
if _, _, err := d.FanOutPendingEvents(ctx, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 停用会把存量待发投递一起标记为 skipped。
|
||||
if err := d.SetNotificationChannelEnabled(ctx, ch.ID, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var state string
|
||||
if err := d.QueryRow(`SELECT state FROM notification_deliveries WHERE channel_id=$1`, ch.ID).Scan(&state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state != NotifyStateSkipped {
|
||||
t.Fatalf("停用渠道的存量待发投递应被标记为 skipped,得到 %s", state)
|
||||
}
|
||||
got, err := d.ClaimRealtimeDeliveries(ctx, ch.ID, 10, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("已停用渠道不应能被领取,得到 %d 条", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDigestBatchDueAndStableBatchID(t *testing.T) {
|
||||
d := notifyTestDB(t)
|
||||
ctx := context.Background()
|
||||
ch := newTestChannel(t, d, notify.KindDingTalk, NotifyModeDigest, `{}`)
|
||||
for i := 0; i < 3; i++ {
|
||||
addTestEvent(t, d, notify.EventFindingCreated, int64(6000+i), notify.Snapshot{Severity: "high"})
|
||||
}
|
||||
if _, _, err := d.FanOutPendingEvents(ctx, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 批次刚建、年龄为 0,30 分钟的周期下不该到期。
|
||||
due, err := d.DigestBatchDue(ctx, ch.ID, 30*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("判断批次到期失败: %v", err)
|
||||
}
|
||||
if due {
|
||||
t.Fatal("刚建立的批次不应立即到期")
|
||||
}
|
||||
|
||||
// 把三条投递的创建时间一起推老,模拟一个攒够周期的批次。
|
||||
if _, err := d.Exec(`UPDATE notification_deliveries SET created_at = now() - interval '40 minutes' WHERE channel_id=$1`, ch.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
due, err = d.DigestBatchDue(ctx, ch.ID, 30*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !due {
|
||||
t.Fatal("超过周期的批次应判定为到期")
|
||||
}
|
||||
|
||||
batch, err := d.ClaimDigestBatch(ctx, ch.ID, MaxDigestBatchSize, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("领取汇总批次失败: %v", err)
|
||||
}
|
||||
if len(batch) != 3 {
|
||||
t.Fatalf("汇总应一次领走全部 3 条,得到 %d 条", len(batch))
|
||||
}
|
||||
if batch[0].BatchID == nil {
|
||||
t.Fatal("汇总批次必须写 batch_id,否则历史里看不出这几条是一起发的")
|
||||
}
|
||||
firstBatchID := *batch[0].BatchID
|
||||
for _, dl := range batch {
|
||||
if dl.BatchID == nil || *dl.BatchID != firstBatchID {
|
||||
t.Fatalf("同一批次应共享 batch_id,得到 %v vs %d", dl.BatchID, firstBatchID)
|
||||
}
|
||||
}
|
||||
|
||||
// 让这批**整体**失败重排后再领,batch_id 必须保持原值(COALESCE 的作用):
|
||||
// 否则一次重试就把「这批是一起发的」这个事实抹掉了。
|
||||
//
|
||||
// 必须整批重排而不是只重排一条——投递引擎发汇总消息时就是这样处理的
|
||||
// (一条消息代表整批,成败与共)。只重排一条的话,其余仍在租约期内,
|
||||
// 重领自然只拿到那一条。
|
||||
allIDs := make([]int64, 0, len(batch))
|
||||
for _, dl := range batch {
|
||||
allIDs = append(allIDs, dl.ID)
|
||||
}
|
||||
if err := d.RescheduleDeliveries(ctx, allIDs, time.Second, "模拟失败"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 把租约推到过去,模拟退避时间已到。
|
||||
if _, err := d.Exec(`UPDATE notification_deliveries SET next_attempt_at = now() - interval '1 minute' WHERE channel_id=$1`, ch.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reclaimed, err := d.ClaimDigestBatch(ctx, ch.ID, MaxDigestBatchSize, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(reclaimed) != 3 {
|
||||
t.Fatalf("重领应拿到全部 3 条,得到 %d", len(reclaimed))
|
||||
}
|
||||
if reclaimed[0].BatchID == nil || *reclaimed[0].BatchID != firstBatchID {
|
||||
t.Fatalf("重试后 batch_id 应保持原值 %d,得到 %v", firstBatchID, reclaimed[0].BatchID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryStateTransitions(t *testing.T) {
|
||||
d := notifyTestDB(t)
|
||||
ctx := context.Background()
|
||||
ch := newTestChannel(t, d, notify.KindDingTalk, NotifyModeRealtime, `{}`)
|
||||
addTestEvent(t, d, notify.EventFindingCreated, 7001, notify.Snapshot{Severity: "high"})
|
||||
if _, _, err := d.FanOutPendingEvents(ctx, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := d.ClaimRealtimeDeliveries(ctx, ch.ID, 10, time.Minute)
|
||||
if err != nil || len(got) != 1 {
|
||||
t.Fatalf("领取失败: %v (%d)", err, len(got))
|
||||
}
|
||||
id := got[0].ID
|
||||
|
||||
if err := d.RescheduleDeliveries(ctx, []int64{id}, time.Second, "网络抖动"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var state, lastErr string
|
||||
if err := d.QueryRow(`SELECT state, last_error FROM notification_deliveries WHERE id=$1`, id).Scan(&state, &lastErr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state != NotifyStatePending || lastErr != "网络抖动" {
|
||||
t.Fatalf("重排后应为 pending 并记录原因,得到 state=%s err=%q", state, lastErr)
|
||||
}
|
||||
|
||||
if err := d.FailDeliveries(ctx, []int64{id}, "重试耗尽"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.QueryRow(`SELECT state FROM notification_deliveries WHERE id=$1`, id).Scan(&state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state != NotifyStateFailed {
|
||||
t.Fatalf("应为 failed,得到 %s", state)
|
||||
}
|
||||
|
||||
// 手动重发要清零重试计数并立即到期,否则会继承旧的失败预算。
|
||||
if err := d.RetryNotificationDelivery(ctx, id); err != nil {
|
||||
t.Fatalf("重发失败: %v", err)
|
||||
}
|
||||
var attempts int
|
||||
var next time.Time
|
||||
if err := d.QueryRow(`SELECT state, attempts, next_attempt_at FROM notification_deliveries WHERE id=$1`, id).Scan(&state, &attempts, &next); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state != NotifyStatePending || attempts != 0 {
|
||||
t.Fatalf("重发后应为 pending 且 attempts=0,得到 state=%s attempts=%d", state, attempts)
|
||||
}
|
||||
if next.After(time.Now().Add(time.Second)) {
|
||||
t.Fatal("重发应立即可领")
|
||||
}
|
||||
|
||||
// 已送达的投递不应能被重发。
|
||||
if err := d.MarkDeliveriesSent(ctx, []int64{id}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.RetryNotificationDelivery(ctx, id); err == nil {
|
||||
t.Fatal("已送达的投递不该允许重发")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListNotificationDeliveriesPagingAndFilter(t *testing.T) {
|
||||
d := notifyTestDB(t)
|
||||
ctx := context.Background()
|
||||
ch := newTestChannel(t, d, notify.KindDingTalk, NotifyModeRealtime, `{}`)
|
||||
for i := 0; i < 5; i++ {
|
||||
addTestEvent(t, d, notify.EventFindingCreated, int64(8000+i), notify.Snapshot{Severity: "high", Name: "分页测试"})
|
||||
}
|
||||
if _, _, err := d.FanOutPendingEvents(ctx, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := d.ClaimRealtimeDeliveries(ctx, ch.ID, 10, time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
page1, total, err := d.ListNotificationDeliveries(ctx, NotificationDeliveryFilter{ChannelID: ch.ID, State: NotifyStateSending}, 1, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
if total != 5 {
|
||||
t.Fatalf("总数应为 5,得到 %d", total)
|
||||
}
|
||||
if len(page1) != 2 {
|
||||
t.Fatalf("每页 2 条,得到 %d", len(page1))
|
||||
}
|
||||
// 新的在前:第一页首条 id 应大于第二页首条。
|
||||
page2, _, err := d.ListNotificationDeliveries(ctx, NotificationDeliveryFilter{ChannelID: ch.ID, State: NotifyStateSending}, 2, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page2) != 2 || page2[0].ID >= page1[0].ID {
|
||||
t.Fatalf("分页顺序应为新的在前,得到 page1[0]=%d page2[0]=%d", page1[0].ID, page2[0].ID)
|
||||
}
|
||||
// 渲染上下文必须随历史一起返回,否则列表无法展示「推的是什么」。
|
||||
if page1[0].ChannelName == "" || page1[0].FindingID == 0 {
|
||||
t.Fatalf("历史项缺少展示字段: %+v", page1[0])
|
||||
}
|
||||
|
||||
// 按状态过滤:没有 pending 的。
|
||||
pending, totalPending, err := d.ListNotificationDeliveries(ctx, NotificationDeliveryFilter{ChannelID: ch.ID, State: NotifyStatePending}, 1, 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if totalPending != 0 || len(pending) != 0 {
|
||||
t.Fatalf("不该有 pending 投递,得到 %d 条 (total=%d)", len(pending), totalPending)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetFindingStatusWithNotifyOnlyEmitsOnRealChange(t *testing.T) {
|
||||
d := notifyTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tk, err := d.CreateTask("通知状态变更测试", "目标", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.DeleteTask(tk.ID)
|
||||
es := d.Exploration(tk.ExplorationID)
|
||||
f, err := es.RecordFinding(ctx, RecordFindingInput{
|
||||
TaskID: tk.ID, Worker: "test", VulnClass: "SQL注入", Name: "状态变更用例",
|
||||
Severity: "high", Summary: "摘要",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { d.Exec(`DELETE FROM notification_events WHERE finding_id=$1`, f.FindingID) })
|
||||
|
||||
// 落库时已登记一条 finding_created 事件,先把它数出来作为基线。
|
||||
var base int
|
||||
if err := d.QueryRow(`SELECT count(*) FROM notification_events WHERE finding_id=$1`, f.FindingID).Scan(&base); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if base < 1 {
|
||||
t.Fatal("漏洞落库应在同一事务里登记一条推送事件")
|
||||
}
|
||||
|
||||
// 改成同一个状态:不该产生事件(避免重复提交刷出推送噪音)。
|
||||
from, found, notified, err := d.SetFindingStatusWithNotify(ctx, f.FindingID, "pending")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("状态设置失败: found=%v err=%v", found, err)
|
||||
}
|
||||
if notified {
|
||||
t.Fatal("状态未变化时不应登记推送事件")
|
||||
}
|
||||
if from != "pending" {
|
||||
t.Fatalf("应返回变更前状态 pending,得到 %q", from)
|
||||
}
|
||||
|
||||
// 真正变更:应登记事件并记录 from/to。
|
||||
from, found, notified, err = d.SetFindingStatusWithNotify(ctx, f.FindingID, "fixed")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("状态设置失败: found=%v err=%v", found, err)
|
||||
}
|
||||
if !notified {
|
||||
t.Fatal("状态实际变更时应登记推送事件")
|
||||
}
|
||||
if from != "pending" {
|
||||
t.Fatalf("from 应为 pending,得到 %q", from)
|
||||
}
|
||||
var snapshot []byte
|
||||
if err := d.QueryRow(`SELECT snapshot FROM notification_events WHERE finding_id=$1 AND kind=$2`,
|
||||
f.FindingID, notify.EventFindingStatusChanged).Scan(&snapshot); err != nil {
|
||||
t.Fatalf("未找到状态变更事件: %v", err)
|
||||
}
|
||||
var snap notify.Snapshot
|
||||
if err := json.Unmarshal(snapshot, &snap); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snap.FromStatus != "pending" || snap.ToStatus != "fixed" {
|
||||
t.Fatalf("快照里的状态流转不对: %s → %s", snap.FromStatus, snap.ToStatus)
|
||||
}
|
||||
// 快照要带上渲染所需字段,否则状态变更消息会是空壳。
|
||||
if snap.VulnClass != "SQL注入" || snap.Severity != "high" || snap.Name != "状态变更用例" {
|
||||
t.Fatalf("快照缺少渲染字段: %+v", snap)
|
||||
}
|
||||
var status string
|
||||
if err := d.QueryRow(`SELECT status FROM findings WHERE id=$1`, f.FindingID).Scan(&status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "fixed" {
|
||||
t.Fatalf("状态应已更新为 fixed,得到 %s", status)
|
||||
}
|
||||
|
||||
// 不存在的漏洞:found=false,不报错。
|
||||
if _, found, _, err := d.SetFindingStatusWithNotify(ctx, 999999999, "fixed"); err != nil || found {
|
||||
t.Fatalf("不存在的漏洞应返回 found=false 且无错,得到 found=%v err=%v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationStatsSnapshot(t *testing.T) {
|
||||
d := notifyTestDB(t)
|
||||
ctx := context.Background()
|
||||
ch := newTestChannel(t, d, notify.KindDingTalk, NotifyModeRealtime, `{}`)
|
||||
addTestEvent(t, d, notify.EventFindingCreated, 9001, notify.Snapshot{Severity: "high"})
|
||||
if _, _, err := d.FanOutPendingEvents(ctx, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stats, err := d.NotificationStatsSnapshot(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("统计失败: %v", err)
|
||||
}
|
||||
if stats.Channels < 1 || stats.ChannelsOn < 1 {
|
||||
t.Fatalf("渠道计数不对: %+v", stats)
|
||||
}
|
||||
if stats.Pending < 1 {
|
||||
t.Fatalf("应统计到待发投递: %+v", stats)
|
||||
}
|
||||
// 刚建的投递积压年龄应接近 0,而不是负数或巨大值。
|
||||
if stats.BacklogAgeMS < 0 || stats.BacklogAgeMS > int64(time.Hour/time.Millisecond) {
|
||||
t.Fatalf("积压年龄不合法: %d ms", stats.BacklogAgeMS)
|
||||
}
|
||||
_ = ch
|
||||
}
|
||||
|
||||
func TestNotificationChannelCRUDRoundTrip(t *testing.T) {
|
||||
d := notifyTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
ch := &NotificationChannel{
|
||||
Name: "CRUD 往返",
|
||||
Kind: notify.KindEmail,
|
||||
Mode: NotifyModeDigest,
|
||||
Config: json.RawMessage(`{"host":"smtp.example.com","port":587,"from":"[email protected]","to":["[email protected]"]}`),
|
||||
Filter: json.RawMessage(`{"min_severity":"medium","on_status_change":true}`),
|
||||
RatePerMin: 42,
|
||||
}
|
||||
id, err := d.SaveNotificationChannel(ctx, ch)
|
||||
if err != nil {
|
||||
t.Fatalf("新建失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { d.Exec(`DELETE FROM notification_channels WHERE id=$1`, id) })
|
||||
|
||||
got, err := d.NotificationChannelByID(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("读取失败: %v", err)
|
||||
}
|
||||
if got.Mode != NotifyModeDigest || got.RatePerMin != 42 || got.Name != "CRUD 往返" {
|
||||
t.Fatalf("往返字段不一致: %+v", got)
|
||||
}
|
||||
if !got.IsEnabled() {
|
||||
t.Fatal("默认应为启用")
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal(got.Config, &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg["host"] != "smtp.example.com" {
|
||||
t.Fatalf("配置未正确落库: %v", cfg)
|
||||
}
|
||||
var filter notify.Filter
|
||||
if err := json.Unmarshal(got.Filter, &filter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filter.MinSeverity != "medium" || !filter.OnStatusChange {
|
||||
t.Fatalf("过滤条件未正确落库: %+v", filter)
|
||||
}
|
||||
|
||||
// 更新后再读。
|
||||
got.Name = "改名了"
|
||||
off := false
|
||||
got.Enabled = &off
|
||||
if _, err := d.SaveNotificationChannel(ctx, got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := d.NotificationChannelByID(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.Name != "改名了" || after.IsEnabled() {
|
||||
t.Fatalf("更新未生效: %+v", after)
|
||||
}
|
||||
|
||||
// 删除后应报「不存在」而不是静默成功。
|
||||
if err := d.DeleteNotificationChannel(ctx, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := d.NotificationChannelByID(ctx, id); err != ErrNotificationChannelNotFound {
|
||||
t.Fatalf("期望 ErrNotificationChannelNotFound,得到 %v", err)
|
||||
}
|
||||
if err := d.DeleteNotificationChannel(ctx, id); err != ErrNotificationChannelNotFound {
|
||||
t.Fatalf("重复删除应报不存在,得到 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveNotificationChannelKeepsExplicitZeroRate 锁住一个曾经写错的地方:
|
||||
// **0 是合法配置,含义是「不限流」,不能被 db 层当成「未指定」覆盖成默认值**。
|
||||
//
|
||||
// 历史 bug:SaveNotificationChannel 里写了 `if RatePerMin <= 0 { 取默认值 }`,
|
||||
// 于是文档、UI 提示、takeTokens 都按「0=不限流」解释,唯独写库这一层悄悄改成
|
||||
// 20(钉钉/企微/Telegram)或 100(飞书)——操作者以为放开了限流、实际被卡着,
|
||||
// 而且没有任何提示。「未指定」与「显式 0」的区别只有请求体能表达,
|
||||
// 所以默认值在 server 层填(见 notifyCreateChannel),db 层只管存。
|
||||
func TestSaveNotificationChannelKeepsExplicitZeroRate(t *testing.T) {
|
||||
d := notifyTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 显式 0(不限流):必须原样存下来。
|
||||
unlimited := &NotificationChannel{
|
||||
Name: "不限流", Kind: notify.KindDingTalk, RatePerMin: 0,
|
||||
Config: json.RawMessage(`{"webhook":"https://example.com/h"}`),
|
||||
}
|
||||
id, err := d.SaveNotificationChannel(ctx, unlimited)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { d.Exec(`DELETE FROM notification_channels WHERE id=$1`, id) })
|
||||
got, err := d.NotificationChannelByID(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.RatePerMin != 0 {
|
||||
t.Fatalf("显式 0 表示不限流,必须原样保存,得到 %d", got.RatePerMin)
|
||||
}
|
||||
if got.Mode != NotifyModeRealtime {
|
||||
t.Fatalf("默认模式应为 realtime,得到 %s", got.Mode)
|
||||
}
|
||||
|
||||
// 负值是非法输入,应被拒绝而不是悄悄改成别的值。
|
||||
bad := &NotificationChannel{
|
||||
Name: "负限流", Kind: notify.KindDingTalk, RatePerMin: -1,
|
||||
Config: json.RawMessage(`{"webhook":"https://example.com/h"}`),
|
||||
}
|
||||
if _, err := d.SaveNotificationChannel(ctx, bad); err == nil {
|
||||
t.Fatal("负限流应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteChannelCascadesDeliveries 锁住外键行为:渠道删除后其投递历史一并消失
|
||||
// (配置都没了,历史无从解读),但事件本身要留下——它可能还被别的渠道引用。
|
||||
func TestDeleteChannelCascadesDeliveries(t *testing.T) {
|
||||
d := notifyTestDB(t)
|
||||
ctx := context.Background()
|
||||
ch := newTestChannel(t, d, notify.KindDingTalk, NotifyModeRealtime, `{}`)
|
||||
ev := addTestEvent(t, d, notify.EventFindingCreated, 9101, notify.Snapshot{Severity: "high"})
|
||||
if _, _, err := d.FanOutPendingEvents(ctx, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var before int
|
||||
if err := d.QueryRow(`SELECT count(*) FROM notification_deliveries WHERE channel_id=$1`, ch.ID).Scan(&before); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before == 0 {
|
||||
t.Fatal("前置条件不成立:未产生投递")
|
||||
}
|
||||
if err := d.DeleteNotificationChannel(ctx, ch.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var after int
|
||||
if err := d.QueryRow(`SELECT count(*) FROM notification_deliveries WHERE channel_id=$1`, ch.ID).Scan(&after); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after != 0 {
|
||||
t.Fatalf("渠道删除后其投递应级联删除,仍有 %d 条", after)
|
||||
}
|
||||
var evExists bool
|
||||
if err := d.QueryRow(`SELECT EXISTS(SELECT 1 FROM notification_events WHERE id=$1)`, ev).Scan(&evExists); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !evExists {
|
||||
t.Fatal("删渠道不应连带删除事件本身")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaimDigestBatchHonorsCallerLimit 覆盖审计指出的一处口子:
|
||||
// 汇总渠道此前完全绕过令牌桶——allow 被 takeTokens 扣掉却没人用,
|
||||
// rate_per_min 对 digest 模式毫无作用。现在 limit 也参与约束。
|
||||
func TestClaimDigestBatchHonorsCallerLimit(t *testing.T) {
|
||||
d := notifyTestDB(t)
|
||||
ctx := context.Background()
|
||||
ch := newTestChannel(t, d, notify.KindDingTalk, NotifyModeDigest, `{}`)
|
||||
for i := 0; i < 10; i++ {
|
||||
addTestEvent(t, d, notify.EventFindingCreated, int64(7000+i), notify.Snapshot{Severity: "high"})
|
||||
}
|
||||
if _, _, err := d.FanOutPendingEvents(ctx, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 取 limit=3:只能领到 3 条,其余留在库里。
|
||||
got, err := d.ClaimDigestBatch(ctx, ch.ID, 3, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("领取失败: %v", err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("应按调用方限流额度只领 3 条,得到 %d", len(got))
|
||||
}
|
||||
// limit=0 表示本轮额度用尽:一条都不该领,也不该报错。
|
||||
if got, err := d.ClaimDigestBatch(ctx, ch.ID, 0, time.Minute); err != nil || len(got) != 0 {
|
||||
t.Fatalf("额度为 0 时应领 0 条且不报错,得到 %d 条 err=%v", len(got), err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFinishFindingRetestEmitsStatusChange 覆盖审计指出的一处完整性缺口:
|
||||
// 复测结论为「已修复」时,状态确实变了,但那条 UPDATE 是直接写库的、
|
||||
// 绕过了带通知的版本——于是配了 on_status_change 的渠道对这种状态流转
|
||||
// 完全收不到推送,界面上状态悄悄变了,运维要打开平台才知道。
|
||||
//
|
||||
// 这条用例锁住「所有改状态的路径都要登记状态变更事件」。
|
||||
func TestFinishFindingRetestEmitsStatusChange(t *testing.T) {
|
||||
d := notifyTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
tk, err := d.CreateTask("复测推送测试", "目标", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.DeleteTask(tk.ID)
|
||||
es := d.Exploration(tk.ExplorationID)
|
||||
f, err := es.RecordFinding(ctx, RecordFindingInput{
|
||||
TaskID: tk.ID, Worker: "test", VulnClass: "SQL注入", Name: "复测目标",
|
||||
Severity: "high", Summary: "摘要",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { d.Exec(`DELETE FROM notification_events WHERE finding_id=$1`, f.FindingID) })
|
||||
|
||||
// 建一条复测记录并直接推到完成态。
|
||||
rt, _, _, err := d.CreateFindingRetest(ctx, f.FindingID, "复核")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rt.ConversationID == nil {
|
||||
t.Fatal("复测应关联一个会话")
|
||||
}
|
||||
// 复测必须先进入 running 才能落结论(与真实流程一致)。
|
||||
if ok, err := d.StartFindingRetest(ctx, rt.ID); err != nil || !ok {
|
||||
t.Fatalf("启动复测失败: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if err := d.RecordFindingRetestResult(ctx, *rt.ConversationID, "fixed", "已修复", "证据"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.FinishFindingRetest(rt.ID, "completed", ""); err != nil {
|
||||
t.Fatalf("结束复测失败: %v", err)
|
||||
}
|
||||
|
||||
var status string
|
||||
if err := d.QueryRow(`SELECT status FROM findings WHERE id=$1`, f.FindingID).Scan(&status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != FindingFixed {
|
||||
t.Fatalf("复测判已修复后状态应为 fixed,得到 %s", status)
|
||||
}
|
||||
|
||||
// 关键断言:必须有一条状态变更事件,且 from/to 正确。
|
||||
var snapshot []byte
|
||||
err = d.QueryRow(`SELECT snapshot FROM notification_events WHERE finding_id=$1 AND kind=$2 ORDER BY id DESC LIMIT 1`,
|
||||
f.FindingID, notify.EventFindingStatusChanged).Scan(&snapshot)
|
||||
if err != nil {
|
||||
t.Fatalf("复测判已修复应登记状态变更推送事件(否则配了 on_status_change 的渠道收不到): %v", err)
|
||||
}
|
||||
var snap notify.Snapshot
|
||||
if err := json.Unmarshal(snapshot, &snap); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snap.FromStatus != "pending" || snap.ToStatus != FindingFixed {
|
||||
t.Fatalf("快照的状态流转不对: %s → %s", snap.FromStatus, snap.ToStatus)
|
||||
}
|
||||
// 快照要带渲染所需字段,否则推送出来是空壳。
|
||||
if snap.Name != "复测目标" || snap.Severity != "high" {
|
||||
t.Fatalf("快照缺少渲染字段: %+v", snap)
|
||||
}
|
||||
}
|
||||
+100
@@ -1284,3 +1284,103 @@ ALTER TABLE task_intercept_rules ADD COLUMN IF NOT EXISTS action TEXT NOT NULL D
|
||||
DROP TRIGGER IF EXISTS trg_task_intercept_rules_upd ON task_intercept_rules;
|
||||
CREATE TRIGGER trg_task_intercept_rules_upd BEFORE UPDATE ON task_intercept_rules
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- =====================================================================
|
||||
-- M. 漏洞 IM 推送
|
||||
--
|
||||
-- 三张表刻意分开,核心是**爆炸半径**:写漏洞的那个事务(RecordFindingTx,
|
||||
-- 持任务行锁)只允许做一次盲 INSERT,不读渠道表、不跑用户的过滤规则。否则
|
||||
-- 一条配错的 webhook 过滤条件就能污染/中止事务,导致漏洞存不进去。
|
||||
--
|
||||
-- notification_channels 渠道实例配置(可变、含凭据、UI 管理)
|
||||
-- notification_events 事件事实(写漏洞事务内盲插,含渲染快照)
|
||||
-- notification_deliveries 投递任务(事务外 fan-out 产生,承载状态/重试/批次)
|
||||
-- =====================================================================
|
||||
|
||||
-- 渠道实例:同一 kind 可配任意多个(如「应急群」「日常群」各一个钉钉机器人)。
|
||||
-- kind 取值由 server 侧白名单校验,不加 CHECK:与 findings.status 同理,
|
||||
-- 后续加渠道不应要求改表结构。
|
||||
CREATE TABLE IF NOT EXISTS notification_channels (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
-- dingtalk 钉钉 / feishu 飞书 / wecom 企业微信 / webhook 通用 / telegram / email
|
||||
kind TEXT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
-- 凭据(明文存储,UI 掩码回显;见 server 侧 maskChannelSecrets)。六种渠道字段差异极大,
|
||||
-- 统一 JSONB + Go 侧按 kind 严格校验,避免为每渠道加一堆 NULL 列:
|
||||
-- dingtalk {webhook,secret}
|
||||
-- feishu {webhook,secret}
|
||||
-- wecom {webhook}
|
||||
-- webhook {url,method,content_type,headers{},body_template}
|
||||
-- telegram {bot_token,chat_id,base_url}
|
||||
-- email {host,port,username,password,from,to[],tls}
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
-- 推送时机:realtime 命中即推 / digest 进批次按全局周期汇总成一条。
|
||||
mode TEXT NOT NULL DEFAULT 'realtime',
|
||||
-- 过滤条件,字段全部可选(缺省=不过滤):
|
||||
-- min_severity ''|low|medium|high|critical
|
||||
-- task_ids/asset_ids 空数组=不限;非空则须交集非空
|
||||
-- vulnclass_include/exclude 关键词数组(大小写不敏感子串);include 空=全收
|
||||
-- on_status_change bool,仅 realtime 模式有意义
|
||||
filter JSONB NOT NULL DEFAULT '{}',
|
||||
-- 每分钟投递上限;0=不限流。默认 20 对齐钉钉/企微官方硬限。
|
||||
-- 超限不丢消息,只把投递推迟到下一个 tick。
|
||||
rate_per_min INTEGER NOT NULL DEFAULT 20,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
DROP TRIGGER IF EXISTS trg_notification_channels_upd ON notification_channels;
|
||||
CREATE TRIGGER trg_notification_channels_upd BEFORE UPDATE ON notification_channels
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- 事件事实。由 RecordFindingTx / 状态变更事务**同事务**写入,保证「漏洞落库」
|
||||
-- 与「推送任务存在」原子一致——不存在提交成功但没入队、消息永久丢失的窗口。
|
||||
-- snapshot 刻意冗余:漏洞事后会被改名/改级别/改状态,推送内容应反映「事发当时」,
|
||||
-- 且 fan-out 与渲染不必回查 findings/tasks/assets 多张表。
|
||||
-- finding 删除后事件不级联删除:与 findings 表「任务删除仍独立留存」的语义一致。
|
||||
CREATE TABLE IF NOT EXISTS notification_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
-- finding_created | finding_status_changed
|
||||
kind TEXT NOT NULL,
|
||||
finding_id BIGINT NOT NULL,
|
||||
snapshot JSONB NOT NULL,
|
||||
-- fan-out 幂等标记:dispatcher 按此列取待分派事件,处理完置 true。
|
||||
-- 用列而非删行,以便投递历史能回溯到事件。
|
||||
fanned_out BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_events_pending
|
||||
ON notification_events(id) WHERE NOT fanned_out;
|
||||
|
||||
-- 投递任务:一条事件 × 一个启用渠道 = 一行。fan-out 在事务外做,所以渠道
|
||||
-- 后开不会补历史(与 agent_triggers 的「迟开 trigger 不补历史」语义一致,
|
||||
-- 避免启用渠道时一次性刷屏历史积压)。
|
||||
-- channel_id 级联删除:渠道配置都没了,其投递历史无意义。
|
||||
CREATE TABLE IF NOT EXISTS notification_deliveries (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
event_id BIGINT NOT NULL REFERENCES notification_events(id) ON DELETE CASCADE,
|
||||
channel_id BIGINT NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE,
|
||||
-- pending 待发 / sent 已发 / failed 重试耗尽(可手动重发) / skipped 渠道停用或批次取消
|
||||
-- pending 待发 / sending 已被某 dispatcher 领取(租约未到期) / sent 已发 /
|
||||
-- failed 重试耗尽或永久失败(可手动重发) / skipped 渠道停用。取值不加 CHECK,
|
||||
-- 与 findings.status 同理,由 server 侧白名单校验。
|
||||
state TEXT NOT NULL DEFAULT 'pending',
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
-- 兼作「下次可领取时间」与「租约到期时间」:领取时把它推到未来即构成租约,
|
||||
-- 于是「租约未到期」与「未到重试时间」共用同一个条件表达,不需要额外的
|
||||
-- lease_until 列。进程崩溃留下的 sending 行会因租约到期被下一轮重新领取。
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
-- digest 模式同批次共享;realtime 恒为 NULL。整批渲染成一条消息后一起置 sent。
|
||||
batch_id BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
sent_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_due
|
||||
ON notification_deliveries(next_attempt_at) WHERE state='pending';
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_history
|
||||
ON notification_deliveries(id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_batch
|
||||
ON notification_deliveries(batch_id) WHERE batch_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_channel
|
||||
ON notification_deliveries(channel_id, id DESC);
|
||||
+1
-1
@@ -188,7 +188,7 @@ func (s *Store) Record(ctx context.Context, in db.RecordFindingInput, refs []db.
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err = db.RecordFindingTx(tx, in, prepared)
|
||||
out, err = db.RecordFindingTx(ctx, tx, in, prepared)
|
||||
return err
|
||||
})
|
||||
return
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Channel 是一个通知渠道的适配器。实现必须**无状态**:同一个实例会被多个渠道
|
||||
// 配置并发复用,凭据一律从 cfg 参数传入。
|
||||
type Channel interface {
|
||||
// Kind 返回渠道类型标识,须与注册表的键一致。
|
||||
Kind() string
|
||||
// Validate 在保存配置时调用,校验必填字段与格式。返回的错误会直接展示给
|
||||
// 配置者,所以文案要说明「缺哪个字段」而不是泛泛的「配置无效」。
|
||||
Validate(cfg map[string]any) error
|
||||
// Send 投递一次消息,返回**实际送达的条目数**与错误。
|
||||
//
|
||||
// 为什么要返回条数:各平台都有消息长度上限,汇总消息装不下整批时会被截断。
|
||||
// 若调用方无条件把整批标记为已送达,被截掉的那些条目就消失了——消息里看不到、
|
||||
// 投递历史里也显示成功,没有任何地方能发现漏洞从未发出。返回 kept 后,
|
||||
// 调用方只标记前 kept 条,其余留待下一批。
|
||||
//
|
||||
// 返回错误表示投递失败,其中 *PermanentError 表示不该重试。
|
||||
// 失败时 kept 无意义,调用方应忽略它。
|
||||
Send(ctx context.Context, cfg map[string]any, m Message) (int, error)
|
||||
// DefaultRatePerMin 返回该渠道官方建议的每分钟投递上限,作为新建渠道实例
|
||||
// 时的默认限流值。返回 0 表示无已知限制。
|
||||
DefaultRatePerMin() int
|
||||
// SecretKeys 返回该渠道配置里属于凭据的键名。API 回显时这些键的值会被掩码,
|
||||
// 更新时收到掩码值则保留库中的原值。只有实现自己清楚哪些字段算凭据
|
||||
// (企业微信的整个 Webhook 地址就是凭据,而钉钉的只是其中的 secret),
|
||||
// 所以这个知识必须由渠道提供,不能由上层猜测。
|
||||
SecretKeys() []string
|
||||
// DestinationKeys 返回该渠道配置里决定「消息发往哪里」的键名。
|
||||
//
|
||||
// 与 SecretKeys 一样是安全相关的东西:目标地址与凭据是两套独立字段,
|
||||
// 若允许「只改地址、凭据原样保留」,任何能改渠道配置的人都能把库里的真凭据
|
||||
// 发到自己控制的服务器,渠道配置的掩码就完全失去意义。
|
||||
// 详见 PrepareConfigUpdate。
|
||||
DestinationKeys() []string
|
||||
}
|
||||
|
||||
// registry 是渠道注册表。刻意用显式字面量而不是 init() 自注册:这样「有哪些渠道」
|
||||
// 在一个地方就能看全,且新增渠道会在编译期暴露遗漏,而不是靠运行时副作用。
|
||||
var registry = map[string]Channel{
|
||||
KindDingTalk: dingTalkChannel{},
|
||||
KindFeishu: feishuChannel{},
|
||||
KindWeCom: weComChannel{},
|
||||
KindWebhook: webhookChannel{},
|
||||
KindTelegram: telegramChannel{},
|
||||
KindEmail: emailChannel{},
|
||||
}
|
||||
|
||||
// Get 按类型取渠道实现。
|
||||
func Get(kind string) (Channel, bool) {
|
||||
c, ok := registry[kind]
|
||||
return c, ok
|
||||
}
|
||||
|
||||
// ValidKind 报告 kind 是否为受支持的渠道类型。
|
||||
func ValidKind(kind string) bool {
|
||||
_, ok := registry[kind]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Kinds 返回全部受支持的渠道类型,按字典序排列(供 UI 下拉稳定展示)。
|
||||
func Kinds() []string {
|
||||
out := make([]string, 0, len(registry))
|
||||
for k := range registry {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// PermanentError 标记一个不该重试的投递失败:凭据错误、目标拒绝、请求体非法等。
|
||||
// 重试只对瞬时故障(网络抖动、限流、对端 5xx)有意义;对永久失败反复退避重试
|
||||
// 既不会成功,又会把真正的错误刷没在重试日志里。
|
||||
type PermanentError struct{ Err error }
|
||||
|
||||
func (e *PermanentError) Error() string { return e.Err.Error() }
|
||||
func (e *PermanentError) Unwrap() error { return e.Err }
|
||||
|
||||
// Permanent 把 err 标记为永久失败。err 为 nil 时返回 nil,
|
||||
// 方便写成 `return Permanent(someCheck())`。
|
||||
func Permanent(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return &PermanentError{Err: err}
|
||||
}
|
||||
|
||||
// IsPermanent 报告 err 链上是否带有永久失败标记。
|
||||
func IsPermanent(err error) bool {
|
||||
var pe *PermanentError
|
||||
return errors.As(err, &pe)
|
||||
}
|
||||
|
||||
// ---- 配置读取helper ----
|
||||
//
|
||||
// 渠道配置来自数据库的 JSONB 列,经 encoding/json 反序列化后是 map[string]any,
|
||||
// 数值一律是 float64、数组是 []any。下面这些 helper 统一这层转换,并容忍用户
|
||||
// 在 UI 里留空导致的类型偏差(如把端口填成字符串)。
|
||||
|
||||
// cfgString 取字符串配置项,前后空白一律裁掉——从网页表单复制粘贴很容易带上。
|
||||
func cfgString(cfg map[string]any, key string) string {
|
||||
v, ok := cfg[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// cfgInt 取整数配置项,兼容 float64(JSON 默认)与字符串两种来源。
|
||||
func cfgInt(cfg map[string]any, key string) int {
|
||||
switch v := cfg[key].(type) {
|
||||
case float64:
|
||||
return int(v)
|
||||
case int:
|
||||
return v
|
||||
case string:
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// cfgBool 取布尔配置项,兼容字符串 "true"/"1"。
|
||||
func cfgBool(cfg map[string]any, key string) bool {
|
||||
switch v := cfg[key].(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
s := strings.ToLower(strings.TrimSpace(v))
|
||||
return s == "true" || s == "1" || s == "yes"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// cfgStrings 取字符串数组配置项,自动裁空白并丢弃空串。
|
||||
func cfgStrings(cfg map[string]any, key string) []string {
|
||||
raw, ok := cfg[key].([]any)
|
||||
if !ok {
|
||||
// 也接受单个字符串,方便只有一个值时的表单提交。
|
||||
if s := cfgString(cfg, key); s != "" {
|
||||
return []string{s}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, v := range raw {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cfgMap 取字符串映射配置项(如自定义 HTTP 头),键值都裁空白,丢弃空键。
|
||||
func cfgMap(cfg map[string]any, key string) map[string]string {
|
||||
raw, ok := cfg[key].(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(raw))
|
||||
for k, v := range raw {
|
||||
k = strings.TrimSpace(k)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[k] = s
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// singleMsg 构造一条带引号与换行的单发消息。刻意用含 `"` 与 `\n` 的标题/摘要:
|
||||
// 这正是模板插值最容易产出的非法 JSON 的输入。
|
||||
func singleMsg() Message {
|
||||
return Message{
|
||||
Items: []Item{{
|
||||
FindingID: 42,
|
||||
Name: `登录处 "SQL注入" 风险`,
|
||||
VulnClass: "SQL注入",
|
||||
Severity: "high",
|
||||
Summary: "参数 id\n未过滤 导致注入",
|
||||
Assets: []string{"a.example.com", "b.example.com"},
|
||||
DetailURL: "https://artex.local/function/findings/detail?id=42",
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// batchMsg 构造一批汇总消息。
|
||||
func batchMsg(n int) Message {
|
||||
m := Message{Batch: true, WindowMinutes: 30, HomeURL: "https://artex.local/function/findings"}
|
||||
for i := 0; i < n; i++ {
|
||||
m.Items = append(m.Items, Item{
|
||||
FindingID: int64(i + 1),
|
||||
Name: "漏洞" + itoa(i+1),
|
||||
VulnClass: "XSS",
|
||||
Severity: "medium",
|
||||
Summary: "反射型跨站脚本",
|
||||
Assets: []string{"target.example.com"},
|
||||
})
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// capturePost 起一个假接收端,把收到的请求体与头回传给断言函数。
|
||||
func capturePost(t *testing.T, respBody string, assert func(t *testing.T, body map[string]any, r *http.Request)) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
var body map[string]any
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Errorf("请求体不是合法 JSON: %v\n原文: %s", err, raw)
|
||||
}
|
||||
}
|
||||
if assert != nil {
|
||||
assert(t, body, r)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, respBody)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestDingTalkSendsActionCardWhenLinkPresent(t *testing.T) {
|
||||
srv := capturePost(t, `{"errcode":0,"errmsg":"ok"}`, func(t *testing.T, body map[string]any, _ *http.Request) {
|
||||
if body["msgtype"] != "actionCard" {
|
||||
t.Fatalf("有回链时应发 actionCard,得到 %v", body["msgtype"])
|
||||
}
|
||||
card, _ := body["actionCard"].(map[string]any)
|
||||
if card["singleURL"] != "https://artex.local/function/findings/detail?id=42" {
|
||||
t.Errorf("回链丢失: %v", card["singleURL"])
|
||||
}
|
||||
})
|
||||
if _, err := (dingTalkChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, singleMsg()); err != nil {
|
||||
t.Fatalf("投递失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDingTalkFallsBackToMarkdownForBatch(t *testing.T) {
|
||||
srv := capturePost(t, `{"errcode":0,"errmsg":"ok"}`, func(t *testing.T, body map[string]any, _ *http.Request) {
|
||||
if body["msgtype"] != "markdown" {
|
||||
t.Fatalf("汇总消息应发 markdown,得到 %v", body["msgtype"])
|
||||
}
|
||||
md, _ := body["markdown"].(map[string]any)
|
||||
if !strings.Contains(md["text"].(string), "近 30 分钟") {
|
||||
t.Errorf("汇总正文缺少时间窗: %v", md["text"])
|
||||
}
|
||||
})
|
||||
if _, err := (dingTalkChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, batchMsg(3)); err != nil {
|
||||
t.Fatalf("投递失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDingTalkBusinessErrorIsPermanent 锁住「HTTP 200 但 errcode 非 0」的判定。
|
||||
// 不检查 errcode 会把投递失败记成成功——这是各家国内 IM 平台共有的坑。
|
||||
func TestDingTalkBusinessErrorIsPermanent(t *testing.T) {
|
||||
srv := capturePost(t, `{"errcode":310000,"errmsg":"keywords not in content"}`, nil)
|
||||
_, err := (dingTalkChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, singleMsg())
|
||||
if err == nil {
|
||||
t.Fatal("errcode 非 0 应报错")
|
||||
}
|
||||
if !IsPermanent(err) {
|
||||
t.Fatalf("关键词不匹配属于配置错误,应标记为永久失败,得到 %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "310000") {
|
||||
t.Errorf("错误信息应带上平台错误码,得到 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeComTruncatesCJKWithinByteLimit(t *testing.T) {
|
||||
var contentLen int
|
||||
srv := capturePost(t, `{"errcode":0,"errmsg":"ok"}`, func(t *testing.T, body map[string]any, _ *http.Request) {
|
||||
md, _ := body["markdown"].(map[string]any)
|
||||
content, _ := md["content"].(string)
|
||||
contentLen = len(content)
|
||||
if !utf8.ValidString(content) {
|
||||
t.Fatal("截断后不是合法 UTF-8——企微会整条拒收")
|
||||
}
|
||||
})
|
||||
// 造一批足够长的中文汇总,必然超过 4096 字节。
|
||||
m := batchMsg(200)
|
||||
if _, err := (weComChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, m); err != nil {
|
||||
t.Fatalf("投递失败: %v", err)
|
||||
}
|
||||
if contentLen > weComMarkdownLimit {
|
||||
t.Fatalf("正文 %d 字节超出企微上限 %d", contentLen, weComMarkdownLimit)
|
||||
}
|
||||
if contentLen == 0 {
|
||||
t.Fatal("正文为空")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeComRateLimitIsRetryableButKeyErrorIsPermanent(t *testing.T) {
|
||||
limited := capturePost(t, `{"errcode":45009,"errmsg":"api freq out of limit"}`, nil)
|
||||
_, err := (weComChannel{}).Send(context.Background(), map[string]any{"webhook": limited.URL}, singleMsg())
|
||||
if err == nil || IsPermanent(err) {
|
||||
t.Fatalf("45009 是滚动窗口限流,应可重试,得到 %v", err)
|
||||
}
|
||||
|
||||
badKey := capturePost(t, `{"errcode":93000,"errmsg":"invalid webhook url"}`, nil)
|
||||
_, err = (weComChannel{}).Send(context.Background(), map[string]any{"webhook": badKey.URL}, singleMsg())
|
||||
if err == nil || !IsPermanent(err) {
|
||||
t.Fatalf("93000 是 key 无效,重试不会自愈,应为永久失败,得到 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeishuCardStructureAndSign(t *testing.T) {
|
||||
const secret = "SECtest123"
|
||||
srv := capturePost(t, `{"code":0,"msg":"success"}`, func(t *testing.T, body map[string]any, _ *http.Request) {
|
||||
if body["msg_type"] != "interactive" {
|
||||
t.Fatalf("应发交互式卡片,得到 %v", body["msg_type"])
|
||||
}
|
||||
card, _ := body["card"].(map[string]any)
|
||||
header, _ := card["header"].(map[string]any)
|
||||
if header["template"] != "orange" {
|
||||
t.Errorf("high 级别应为 orange 配色,得到 %v", header["template"])
|
||||
}
|
||||
// 配了 secret 就必须带加签参数,否则飞书会以 19021 拒收。
|
||||
if body["sign"] == nil || body["timestamp"] == nil {
|
||||
t.Fatalf("缺少加签参数: %v", body)
|
||||
}
|
||||
// 卡片元素里应包含一个按钮,其 url 指向漏洞详情。
|
||||
elements, _ := card["elements"].([]any)
|
||||
foundButton := false
|
||||
for _, e := range elements {
|
||||
em, _ := e.(map[string]any)
|
||||
if em["tag"] != "action" {
|
||||
continue
|
||||
}
|
||||
actions, _ := em["actions"].([]any)
|
||||
for _, a := range actions {
|
||||
am, _ := a.(map[string]any)
|
||||
if am["url"] == "https://artex.local/function/findings/detail?id=42" {
|
||||
foundButton = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundButton {
|
||||
t.Fatal("卡片里没有指向详情页的按钮")
|
||||
}
|
||||
})
|
||||
cfg := map[string]any{"webhook": srv.URL, "secret": secret}
|
||||
if _, err := (feishuChannel{}).Send(context.Background(), cfg, singleMsg()); err != nil {
|
||||
t.Fatalf("投递失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeishuWithoutSecretOmitsSign(t *testing.T) {
|
||||
srv := capturePost(t, `{"code":0,"msg":"success"}`, func(t *testing.T, body map[string]any, _ *http.Request) {
|
||||
if body["sign"] != nil || body["timestamp"] != nil {
|
||||
t.Fatalf("未配置 secret 时不应带加签参数: %v", body)
|
||||
}
|
||||
})
|
||||
if _, err := (feishuChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, singleMsg()); err != nil {
|
||||
t.Fatalf("投递失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramEscapesHTMLInUntrustedContent(t *testing.T) {
|
||||
var text string
|
||||
srv := capturePost(t, `{"ok":true}`, func(t *testing.T, body map[string]any, _ *http.Request) {
|
||||
text, _ = body["text"].(string)
|
||||
if body["parse_mode"] != "HTML" {
|
||||
t.Fatalf("应使用 HTML 解析模式,得到 %v", body["parse_mode"])
|
||||
}
|
||||
})
|
||||
m := Message{Items: []Item{{
|
||||
Severity: "high",
|
||||
// 标题与摘要来自被测目标/模型输出,是不可信内容。
|
||||
Name: `<script>alert(1)</script>`,
|
||||
Summary: "a & b < c",
|
||||
}}}
|
||||
if _, err := (telegramChannel{}).Send(context.Background(),
|
||||
map[string]any{"bot_token": "tok", "chat_id": "1", "base_url": srv.URL}, m); err != nil {
|
||||
t.Fatalf("投递失败: %v", err)
|
||||
}
|
||||
if strings.Contains(text, "<script>") {
|
||||
t.Fatalf("未转义 HTML,存在注入: %q", text)
|
||||
}
|
||||
if !strings.Contains(text, "<script>") {
|
||||
t.Fatalf("期望转义后的实体,得到 %q", text)
|
||||
}
|
||||
if !strings.Contains(text, "a & b") {
|
||||
t.Fatalf("& 未转义,得到 %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramErrorClassification(t *testing.T) {
|
||||
rateLimited := capturePost(t, `{"ok":false,"error_code":429,"description":"Too Many Requests"}`, nil)
|
||||
_, err := (telegramChannel{}).Send(context.Background(),
|
||||
map[string]any{"bot_token": "tok", "chat_id": "1", "base_url": rateLimited.URL}, singleMsg())
|
||||
if err == nil || IsPermanent(err) {
|
||||
t.Fatalf("429 应可重试,得到 %v", err)
|
||||
}
|
||||
|
||||
forbidden := capturePost(t, `{"ok":false,"error_code":403,"description":"bot was blocked by the user"}`, nil)
|
||||
_, err = (telegramChannel{}).Send(context.Background(),
|
||||
map[string]any{"bot_token": "tok", "chat_id": "1", "base_url": forbidden.URL}, singleMsg())
|
||||
if err == nil || !IsPermanent(err) {
|
||||
t.Fatalf("403 是配置问题,应为永久失败,得到 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookDefaultTemplateProducesValidJSON(t *testing.T) {
|
||||
// 这条是默认模板存在的意义:标题里带引号与换行时,任何朴素的
|
||||
// `"title": "{{.Title}}"` 写法都会产出非法 JSON。{{json .}} 才不会。
|
||||
srv := capturePost(t, `{"ok":true}`, func(t *testing.T, body map[string]any, _ *http.Request) {
|
||||
if body["title"] != `[🟠 高危] 登录处 "SQL注入" 风险` {
|
||||
t.Errorf("标题未正确还原: %v", body["title"])
|
||||
}
|
||||
items, _ := body["items"].([]any)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items 数量应为 1,得到 %d", len(items))
|
||||
}
|
||||
it, _ := items[0].(map[string]any)
|
||||
if it["summary"] != "参数 id\n未过滤 导致注入" {
|
||||
t.Errorf("摘要未正确还原: %v", it["summary"])
|
||||
}
|
||||
// 数值必须是 JSON 数字而不是字符串(json:"...,string" 之类的写法会踩这坑)。
|
||||
if _, ok := it["finding_id"].(float64); !ok {
|
||||
t.Errorf("finding_id 应为数字,得到 %T", it["finding_id"])
|
||||
}
|
||||
})
|
||||
if _, err := (webhookChannel{}).Send(context.Background(), map[string]any{"url": srv.URL}, singleMsg()); err != nil {
|
||||
t.Fatalf("投递失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookCustomTemplateAndHeaders(t *testing.T) {
|
||||
srv := capturePost(t, `{"ok":true}`, func(t *testing.T, body map[string]any, r *http.Request) {
|
||||
if r.Header.Get("X-Token") != "s3cret" {
|
||||
t.Errorf("自定义头丢失: %v", r.Header)
|
||||
}
|
||||
if body["msg"] != "3 条" {
|
||||
t.Errorf("自定义模板渲染有误: %v", body["msg"])
|
||||
}
|
||||
if body["first"] != "漏洞1" {
|
||||
t.Errorf("range 提取有误: %v", body["first"])
|
||||
}
|
||||
})
|
||||
cfg := map[string]any{
|
||||
"url": srv.URL,
|
||||
"headers": map[string]any{"X-Token": "s3cret"},
|
||||
"body_template": `{"msg": {{json (printf "%d 条" .Count)}}, "first": {{json (index .Items 0).Name}}}`,
|
||||
}
|
||||
if _, err := (webhookChannel{}).Send(context.Background(), cfg, batchMsg(3)); err != nil {
|
||||
t.Fatalf("投递失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookRejectsNonJSONRenderResult(t *testing.T) {
|
||||
cfg := map[string]any{"url": "https://example.com/hook", "body_template": `not json at all`}
|
||||
_, err := (webhookChannel{}).Send(context.Background(), cfg, singleMsg())
|
||||
if err == nil || !IsPermanent(err) {
|
||||
t.Fatalf("渲染结果非 JSON 应为永久失败(模板写错了,重试无用),得到 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookValidateCatchesBadConfigEarly(t *testing.T) {
|
||||
bad := []map[string]any{
|
||||
{},
|
||||
{"url": "file:///etc/passwd"},
|
||||
{"url": "https://example.com", "method": "DELETE"},
|
||||
{"url": "https://example.com", "body_template": `{{.Items.`},
|
||||
}
|
||||
for i, cfg := range bad {
|
||||
if err := (webhookChannel{}).Validate(cfg); err == nil {
|
||||
t.Errorf("第 %d 组配置应被拒绝: %v", i, cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailMessageIsWellFormed(t *testing.T) {
|
||||
msg, err := buildEmailMessage("[email protected]", []string{"[email protected]", "[email protected]"}, singleMsg())
|
||||
if err != nil {
|
||||
t.Fatalf("组装邮件失败: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(msg, "From: [email protected]\r\n") {
|
||||
t.Fatalf("From 头有误:\n%s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "To: [email protected], [email protected]\r\n") {
|
||||
t.Fatalf("To 头有误:\n%s", msg)
|
||||
}
|
||||
// 中文主题必须 RFC 2047 编码,否则客户端显示成乱码。
|
||||
if !strings.Contains(msg, "Subject: =?utf-8?") {
|
||||
t.Fatalf("主题未做 RFC 2047 编码:\n%s", msg)
|
||||
}
|
||||
if dec, err := new(mime.WordDecoder).DecodeHeader(mustExtractHeader(t, msg, "Subject")); err != nil {
|
||||
t.Fatalf("主题无法解码: %v", err)
|
||||
} else if !strings.Contains(dec, "SQL注入") {
|
||||
t.Fatalf("主题解码后内容有误: %q", dec)
|
||||
}
|
||||
|
||||
// 正文是 base64,解出来应是合法 HTML。
|
||||
parts := strings.SplitN(msg, "\r\n\r\n", 2)
|
||||
if len(parts) != 2 {
|
||||
t.Fatal("邮件缺少头/体分隔")
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(strings.TrimSpace(parts[1]), "\r\n", ""))
|
||||
if err != nil {
|
||||
t.Fatalf("正文 base64 解码失败: %v", err)
|
||||
}
|
||||
html := string(decoded)
|
||||
if !strings.HasPrefix(html, "<div") {
|
||||
t.Fatalf("正文不是 HTML: %.80s", html)
|
||||
}
|
||||
// 标题原样出现在文本位置:HTML 文本内容里的双引号是合法字符,无需转义。
|
||||
// 这里断言「原样保留」是为了防止将来有人误加一层引号转义,让中文引号
|
||||
// 显示成 "。
|
||||
if !strings.Contains(html, `"SQL注入"`) {
|
||||
t.Fatalf("标题中的引号在文本位置应原样保留: %.200s", html)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmailEscapesStructuralInjection 覆盖邮件正文真正需要防的注入:
|
||||
// 漏洞标题与摘要来自被测目标与模型输出,是不可信内容。文本位置必须转义
|
||||
// & < >(否则可以注入标签),属性位置还必须转义引号(否则可以闭合 href)。
|
||||
func TestEmailEscapesStructuralInjection(t *testing.T) {
|
||||
m := Message{
|
||||
Items: []Item{{
|
||||
Severity: "high",
|
||||
Name: `<script>alert(1)</script>`,
|
||||
Summary: "a & b > c",
|
||||
DetailURL: `https://artex.local/x?a="onmouseover=alert(1)`,
|
||||
}},
|
||||
}
|
||||
html := htmlBody(m, 0)
|
||||
if strings.Contains(html, "<script>") {
|
||||
t.Fatalf("标题未转义,可注入标签: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, "<script>") {
|
||||
t.Fatalf("期望转义后的实体: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, "a & b > c") {
|
||||
t.Fatalf("& 与 > 未转义: %s", html)
|
||||
}
|
||||
// 回链是管理员可配的 public_base_url,本身可信度较高,但属性位置仍必须
|
||||
// 转义引号——否则一个带引号的地址会闭合 href 并注入事件处理器。
|
||||
if strings.Contains(html, `onmouseover=alert(1)">`) {
|
||||
t.Fatalf("href 属性未正确转义: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, """) {
|
||||
t.Fatalf("属性位置的引号应被转义: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func mustExtractHeader(t *testing.T, msg, name string) string {
|
||||
t.Helper()
|
||||
for _, line := range strings.Split(msg, "\r\n") {
|
||||
if strings.HasPrefix(line, name+": ") {
|
||||
return strings.TrimPrefix(line, name+": ")
|
||||
}
|
||||
}
|
||||
t.Fatalf("未找到 %s 头", name)
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestChannelValidateReportsMissingFields(t *testing.T) {
|
||||
// 校验错误会直接展示给配置者,必须说清楚缺什么,而不是泛泛的「配置无效」。
|
||||
cases := []struct {
|
||||
kind string
|
||||
cfg map[string]any
|
||||
substr string
|
||||
}{
|
||||
{KindDingTalk, map[string]any{}, "Webhook"},
|
||||
{KindFeishu, map[string]any{}, "Webhook"},
|
||||
{KindWeCom, map[string]any{}, "Webhook"},
|
||||
{KindTelegram, map[string]any{}, "Bot Token"},
|
||||
{KindTelegram, map[string]any{"bot_token": "t"}, "Chat ID"},
|
||||
{KindEmail, map[string]any{}, "SMTP"},
|
||||
{KindEmail, map[string]any{"host": "h"}, "端口"},
|
||||
{KindEmail, map[string]any{"host": "h", "port": 587, "from": "f"}, "收件人"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
ch, ok := Get(tc.kind)
|
||||
if !ok {
|
||||
t.Fatalf("渠道 %s 未注册", tc.kind)
|
||||
}
|
||||
err := ch.Validate(tc.cfg)
|
||||
if err == nil {
|
||||
t.Errorf("%s 配置 %v 应校验失败", tc.kind, tc.cfg)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.substr) {
|
||||
t.Errorf("%s 的错误信息应提到 %q,得到 %q", tc.kind, tc.substr, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmailSMTPErrorClassification 锁住 SMTP 4xx/5xx 的语义区分。
|
||||
// 若把 4xx 也判成永久失败,一个启用灰名单的邮件服务器会让每条推送都在第一次
|
||||
// 尝试后落入 failed —— 而灰名单恰恰是自动重试最该发挥作用的场景。
|
||||
func TestEmailSMTPErrorClassification(t *testing.T) {
|
||||
cases := []struct {
|
||||
reply string
|
||||
permanent bool
|
||||
}{
|
||||
{"450 4.7.1 Greylisting in action, please come back later", false},
|
||||
{"451 4.3.0 Temporary system failure", false},
|
||||
{"452 4.2.2 Mailbox full", false},
|
||||
{"550 5.1.1 User unknown", true},
|
||||
{"553 5.1.3 Bad address syntax", true},
|
||||
{"554 5.7.1 Relay access denied", true},
|
||||
// 取不到应答码时按「可重试」处理:宁可多试一次,也不要把可能的瞬时
|
||||
// 故障判死。
|
||||
{"unexpected EOF", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
err := smtpStageError("收件人被拒", errors.New(tc.reply))
|
||||
if got := IsPermanent(err); got != tc.permanent {
|
||||
t.Errorf("应答 %q: 期望 permanent=%v 得到 %v", tc.reply, tc.permanent, got)
|
||||
}
|
||||
// 无论怎么分类,原文都要保留给使用者排查。
|
||||
if tc.reply != "" && !strings.Contains(err.Error(), tc.reply) {
|
||||
t.Errorf("应答 %q 的原文被丢弃: %v", tc.reply, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryCoversAllKinds(t *testing.T) {
|
||||
// 六个渠道缺一不可——少一个会在 UI 下拉里静默消失。
|
||||
want := []string{KindDingTalk, KindEmail, KindFeishu, KindTelegram, KindWebhook, KindWeCom}
|
||||
got := Kinds()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("渠道数量应为 %d,得到 %d: %v", len(want), len(got), got)
|
||||
}
|
||||
for _, k := range want {
|
||||
if !ValidKind(k) {
|
||||
t.Errorf("渠道 %s 未注册", k)
|
||||
}
|
||||
if ch, ok := Get(k); !ok || ch.Kind() != k {
|
||||
t.Errorf("渠道 %s 的 Kind() 与注册键不一致", k)
|
||||
}
|
||||
}
|
||||
if ValidKind("nope") {
|
||||
t.Error("未注册的类型不应通过校验")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermanentErrorUnwrap(t *testing.T) {
|
||||
base := &permanentSentinel{}
|
||||
err := Permanent(base)
|
||||
if !IsPermanent(err) {
|
||||
t.Fatal("应识别为永久失败")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "sentinel") {
|
||||
t.Fatalf("错误信息应透传底层: %v", err)
|
||||
}
|
||||
if Permanent(nil) != nil {
|
||||
t.Fatal("Permanent(nil) 必须返回 nil")
|
||||
}
|
||||
if IsPermanent(nil) {
|
||||
t.Fatal("nil 不是永久失败")
|
||||
}
|
||||
}
|
||||
|
||||
type permanentSentinel struct{}
|
||||
|
||||
func (*permanentSentinel) Error() string { return "sentinel" }
|
||||
@@ -0,0 +1,160 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// dingTalkChannel 实现钉钉自定义机器人。
|
||||
//
|
||||
// 平台特性(决定了这里的实现取舍):
|
||||
// - 单机器人限流 20 条/分钟,超发会被静默丢弃(HTTP 仍可能 200),
|
||||
// 所以限流必须在客户端做,见 DefaultRatePerMin。
|
||||
// - 安全设置三选一:加签 / 自定义关键词 / IP 白名单。加签是唯一不依赖
|
||||
// 消息内容的方案,所以只支持加签(也支持三者都不开的裸 webhook)。
|
||||
// - 成功/失败都返回 HTTP 200,靠 body 里的 errcode 区分——不检查 errcode
|
||||
// 会把投递失败记成成功。
|
||||
type dingTalkChannel struct{}
|
||||
|
||||
func (dingTalkChannel) Kind() string { return KindDingTalk }
|
||||
|
||||
func (dingTalkChannel) DefaultRatePerMin() int { return 20 }
|
||||
|
||||
// 钉钉的 Webhook 地址里带 access_token,本身就是凭据,因此整体掩码。
|
||||
func (dingTalkChannel) SecretKeys() []string { return []string{"webhook", "secret"} }
|
||||
|
||||
// 目标是钉钉的 Webhook 地址本身;改地址必须同时对新地址重新表态加签密钥。
|
||||
func (dingTalkChannel) DestinationKeys() []string { return []string{"webhook"} }
|
||||
|
||||
func (dingTalkChannel) 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
|
||||
}
|
||||
|
||||
// Send 投递一次消息。有回链且是单条时用 ActionCard(带按钮),否则用 markdown。
|
||||
func (c dingTalkChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) {
|
||||
hook := cfgString(cfg, "webhook")
|
||||
if err := c.Validate(cfg); err != nil {
|
||||
return 0, Permanent(err)
|
||||
}
|
||||
endpoint, err := dingTalkSignedURL(hook, cfgString(cfg, "secret"), time.Now())
|
||||
if err != nil {
|
||||
return 0, Permanent(err)
|
||||
}
|
||||
|
||||
title := markdownTitle(m)
|
||||
// 钉钉 markdown 正文无明确字节上限,但仍做上限保护,避免证据字段异常膨胀。
|
||||
text, kept := markdownBody(m, 20000)
|
||||
|
||||
var payload any
|
||||
if !m.Batch && len(m.Items) == 1 && m.Items[0].DetailURL != "" {
|
||||
payload = map[string]any{
|
||||
"msgtype": "actionCard",
|
||||
"actionCard": map[string]any{
|
||||
"title": title,
|
||||
"text": text,
|
||||
"btnOrientation": "0",
|
||||
"singleTitle": "查看详情",
|
||||
"singleURL": m.Items[0].DetailURL,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
payload = map[string]any{
|
||||
"msgtype": "markdown",
|
||||
"markdown": map[string]any{"title": title, "text": text},
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := doJSON(ctx, "POST", endpoint, nil, payload)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// 钉钉把业务错误藏在 200 响应里。
|
||||
var res struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
return 0, fmt.Errorf("解析钉钉响应失败: %w (%s)", err, snippet(raw))
|
||||
}
|
||||
if res.ErrCode != 0 {
|
||||
// 301000 是签名校验失败、310000 是关键词不匹配——都是配置错误,
|
||||
// 重试不会自愈。
|
||||
return 0, Permanent(fmt.Errorf("钉钉返回错误 %d: %s", res.ErrCode, res.ErrMsg))
|
||||
}
|
||||
return kept, nil
|
||||
}
|
||||
|
||||
// dingTalkSignedURL 按官方加签规则给 webhook 追加 timestamp 与 sign 参数。
|
||||
//
|
||||
// 规则:待签串 = timestamp + "\n" + secret,HMAC-SHA256 的**密钥也是 secret**,
|
||||
// 结果 base64 后 URL 编码。timestamp 是毫秒。secret 为空时原样返回,
|
||||
// 以支持未开启加签的机器人。
|
||||
func dingTalkSignedURL(hook, secret string, now time.Time) (string, error) {
|
||||
if secret == "" {
|
||||
return hook, nil
|
||||
}
|
||||
ts := strconv.FormatInt(now.UnixMilli(), 10)
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(ts + "\n" + secret))
|
||||
sign := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
|
||||
u, err := url.Parse(hook)
|
||||
if err != nil {
|
||||
// 不透传 err:url.Parse 的错误文本里带完整地址(含 access_token)。
|
||||
return "", fmt.Errorf("解析 Webhook 地址失败: %s", redactRequestTarget(hook))
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("timestamp", ts)
|
||||
q.Set("sign", sign)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// validateHTTPURL 校验地址可用、协议受支持,并对字面 IP 目标做内网判断。
|
||||
//
|
||||
// 两点讲究:
|
||||
//
|
||||
// 1. **错误信息必须脱敏**。url.Parse 自己返回的是 *url.Error,它的 Error() 带
|
||||
// **完整原始地址**,而本功能这几家的地址里就嵌着凭据(钉钉 access_token、
|
||||
// 企微 key、Telegram 的 bot token、飞书 hook id)。曾经这里直接 `return err`,
|
||||
// 于是「地址格式非法」这条错误就把凭据带了出去,流向测试接口的 400 响应、
|
||||
// 每次投递落库的 last_error、服务端日志与投递历史接口。
|
||||
//
|
||||
// 2. **字面 IP 直接判内网**,域名留给拨号阶段判(blockInternalDial 才是最终
|
||||
// 生效点,也能覆盖 DNS 重绑定)。这里做一次是为了让保存配置时就能得到提示,
|
||||
// 而不是等到第一次投递失败。
|
||||
//
|
||||
// 限制协议是防御性的:file:///gopher:// 之类会让 http.Client 产生意料之外的
|
||||
// 行为(虽已被 scheme 检查挡下,但没有理由放开这个面)。
|
||||
func validateHTTPURL(raw string) error {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("地址无法解析(%s)", redactRequestTarget(raw))
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("只支持 http/https,收到 %q", u.Scheme)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return errors.New("缺少主机名")
|
||||
}
|
||||
if ip := net.ParseIP(u.Hostname()); ip != nil && isBlockedDialIP(ip) && !allowLocalTargets() {
|
||||
return fmt.Errorf("拒绝投递到本机/链路本地地址 %s(如确需投递到本机服务,设置 %s=1)", ip, AllowLocalTargetsEnv)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// emailDialTimeout / emailSessionTimeout 分别约束建连与整段 SMTP 会话。
|
||||
// net/smtp 自身没有任何超时机制,不设这两道的话,一个卡住的对端会让
|
||||
// 投递 goroutine 永久挂在那里——而 dispatcher 是单 goroutine 串行处理的,
|
||||
// 等于整个通知系统停摆。
|
||||
const (
|
||||
emailDialTimeout = 10 * time.Second
|
||||
emailSessionTimeout = 45 * time.Second
|
||||
)
|
||||
|
||||
// emailChannel 实现 SMTP 邮件投递。
|
||||
type emailChannel struct{}
|
||||
|
||||
func (emailChannel) Kind() string { return KindEmail }
|
||||
|
||||
// 邮件没有平台限流,但不该用它刷屏;给一个宽松的默认值。
|
||||
func (emailChannel) DefaultRatePerMin() int { return 60 }
|
||||
|
||||
// 只掩码密码。SMTP 主机、账号、收件人都不算秘密,掩码它们只会让编辑变麻烦。
|
||||
func (emailChannel) SecretKeys() []string { return []string{"password"} }
|
||||
|
||||
// host/port 决定把密码交给哪台服务器;tls 决定是否加密传输。三者任一变化都
|
||||
// 要求重新表态密码——顺带让「关掉 TLS」这一步必须显式带上凭据,而不是顺手一改。
|
||||
func (emailChannel) DestinationKeys() []string { return []string{"host", "port", "tls"} }
|
||||
|
||||
func (emailChannel) Validate(cfg map[string]any) error {
|
||||
if cfgString(cfg, "host") == "" {
|
||||
return errors.New("缺少 SMTP 服务器地址")
|
||||
}
|
||||
port := cfgInt(cfg, "port")
|
||||
if port <= 0 || port > 65535 {
|
||||
return errors.New("SMTP 端口无效(应为 1-65535)")
|
||||
}
|
||||
if cfgString(cfg, "from") == "" {
|
||||
return errors.New("缺少发件人地址")
|
||||
}
|
||||
if len(cfgStrings(cfg, "to")) == 0 {
|
||||
return errors.New("至少需要一个收件人地址")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c emailChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) {
|
||||
if err := c.Validate(cfg); err != nil {
|
||||
return 0, Permanent(err)
|
||||
}
|
||||
host := cfgString(cfg, "host")
|
||||
port := cfgInt(cfg, "port")
|
||||
from := cfgString(cfg, "from")
|
||||
to := cfgStrings(cfg, "to")
|
||||
username := cfgString(cfg, "username")
|
||||
password := cfgString(cfg, "password")
|
||||
implicitTLS := cfgBool(cfg, "tls")
|
||||
|
||||
msg, err := buildEmailMessage(from, to, m)
|
||||
if err != nil {
|
||||
return 0, Permanent(err)
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
client, err := emailDial(ctx, addr, host, implicitTLS)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// STARTTLS:对端支持就升级。明文会话下不能发凭据(见下面的 auth 说明)。
|
||||
if !implicitTLS {
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err := client.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil {
|
||||
return 0, fmt.Errorf("STARTTLS 失败: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if username != "" {
|
||||
if err := client.Auth(smtp.PlainAuth("", username, password, host)); err != nil {
|
||||
// smtp.PlainAuth 会拒绝在未加密连接上发送凭据(除非目标是 localhost)。
|
||||
// 这是**正确**的安全行为,不能绕过,但需要把原因翻译清楚——
|
||||
// 否则使用者只会看到「unencrypted connection」而不知道该怎么办。
|
||||
if strings.Contains(err.Error(), "unencrypted connection") {
|
||||
return 0, Permanent(fmt.Errorf("拒发凭据:连接未加密。请启用 TLS,或改用 465 端口(隐式 TLS),或把「启用 TLS」勾上 (%w)", err))
|
||||
}
|
||||
return 0, Permanent(fmt.Errorf("SMTP 认证失败: %w", err))
|
||||
}
|
||||
}
|
||||
if err := client.Mail(from); err != nil {
|
||||
return 0, smtpStageError(fmt.Sprintf("发件人 %s 被拒", from), err)
|
||||
}
|
||||
for _, rcpt := range to {
|
||||
if err := client.Rcpt(rcpt); err != nil {
|
||||
return 0, smtpStageError(fmt.Sprintf("收件人 %s 被拒", rcpt), err)
|
||||
}
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("SMTP DATA 失败: %w", err)
|
||||
}
|
||||
if _, err := w.Write([]byte(msg)); err != nil {
|
||||
return 0, fmt.Errorf("写入邮件正文失败: %w", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return 0, fmt.Errorf("提交邮件失败: %w", err)
|
||||
}
|
||||
// Quit 失败不影响「邮件已被服务器接收」这个事实,因此忽略其错误。
|
||||
_ = client.Quit()
|
||||
// 邮件没有长度截断(HTML 正文全部发送),整批都算送达。
|
||||
return len(m.Items), nil
|
||||
}
|
||||
|
||||
// emailDial 建立 SMTP 连接。
|
||||
//
|
||||
// implicitTLS=true 走 465 这类「连上即 TLS」的方式;false 走 25/587 明文建连后再
|
||||
// STARTTLS。两者不能混:对 465 端口发明文 greeting 会被直接断开。
|
||||
//
|
||||
// 会话期限在**建连处**就设好(而非事后补设),因为 net/smtp 的 Client 把底层
|
||||
// 连接藏在未导出字段里,外部拿不到它;连接一旦交出去就只能靠预先设置的 deadline
|
||||
// 兜底。这也顺带覆盖了握手阶段的阻塞。
|
||||
func emailDial(ctx context.Context, addr, host string, implicitTLS bool) (*smtp.Client, error) {
|
||||
d := &net.Dialer{Timeout: emailDialTimeout}
|
||||
var conn net.Conn
|
||||
var err error
|
||||
if implicitTLS {
|
||||
conn, err = tls.DialWithDialer(d, "tcp", addr, &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12})
|
||||
} else {
|
||||
conn, err = d.DialContext(ctx, "tcp", addr)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("连接 SMTP 服务器失败: %w", err)
|
||||
}
|
||||
_ = conn.SetDeadline(time.Now().Add(emailSessionTimeout))
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("SMTP 握手失败: %w", err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// smtpStageError 按 SMTP 应答码把某个阶段的失败分成「可重试」与「永久失败」。
|
||||
//
|
||||
// 为什么必须区分:SMTP 的 4xx 与 5xx 语义完全不同——
|
||||
// - 4xx(450 灰名单、451 本地错误、452 存储不足)是**临时**拒绝,
|
||||
// 正规做法是稍后重试;尤其是灰名单,几乎每次首次投递都会遇到。
|
||||
// - 5xx(550 用户不存在、553 地址非法)是永久拒绝,重试没有意义。
|
||||
//
|
||||
// 若一律判永久失败,一个启用灰名单的邮件服务器会让**每一条**推送都在第一次
|
||||
// 尝试后落入 failed——而这类失败恰恰是自动重试最该发挥作用的场景。
|
||||
// 应答码取错误文本的前三位数字;取不到码时按可重试处理(宁可多试一次,
|
||||
// 也不要因为解析不出就把可能的瞬时故障判死)。
|
||||
func smtpStageError(what string, err error) error {
|
||||
code := smtpReplyCode(err.Error())
|
||||
if code >= 500 && code < 600 {
|
||||
return Permanent(fmt.Errorf("%s: %w", what, err))
|
||||
}
|
||||
return fmt.Errorf("%s: %w", what, err)
|
||||
}
|
||||
|
||||
// smtpReplyCode 从 SMTP 错误文本里取前导的三位应答码,取不到返回 0。
|
||||
// net/smtp 不导出错误码字段,只能从文本里取;格式为「450 4.7.1 ...」。
|
||||
func smtpReplyCode(text string) int {
|
||||
if len(text) < 3 {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.Atoi(text[:3])
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// buildEmailMessage 组装完整的 RFC 5322 邮件。
|
||||
//
|
||||
// 正文用 base64 编码有两个原因:一是 SMTP 规定单行不超过 1000 字节,而 HTML
|
||||
// 正文(尤其汇总邮件)很容易出现超长行;二是 base64 天然不会出现以 "." 开头
|
||||
// 的行,省去 SMTP 点号转义的麻烦。
|
||||
func buildEmailMessage(from string, to []string, m Message) (string, error) {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "From: %s\r\n", from)
|
||||
fmt.Fprintf(&b, "To: %s\r\n", strings.Join(to, ", "))
|
||||
// 中文主题必须做 RFC 2047 编码,否则会被客户端显示成乱码。
|
||||
fmt.Fprintf(&b, "Subject: %s\r\n", mime.QEncoding.Encode("utf-8", htmlTitle(m)))
|
||||
b.WriteString("MIME-Version: 1.0\r\n")
|
||||
b.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n")
|
||||
b.WriteString("Content-Transfer-Encoding: base64\r\n")
|
||||
// 邮件没有长度硬上限,因此不截断正文。
|
||||
b.WriteString("\r\n")
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(htmlBody(m, 0)))
|
||||
// base64 按 76 字符折行,符合 RFC 2045。
|
||||
for len(encoded) > 76 {
|
||||
b.WriteString(encoded[:76] + "\r\n")
|
||||
encoded = encoded[76:]
|
||||
}
|
||||
b.WriteString(encoded + "\r\n")
|
||||
return b.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件补齐邮件渠道的协议级测试。在此之前 email.Send 的覆盖率是 0——
|
||||
// 整条 SMTP 路径没有任何用例跑过,而它恰恰是六个渠道里协议面最大、
|
||||
// 最容易出错的一个(握手、认证、信封、DATA 阶段各有各的失败语义)。
|
||||
//
|
||||
// 这里用自建的最小 SMTP 服务器驱动,而不是 mock 掉 net/smtp:
|
||||
// 邮件渠道的绝大部分风险就在「与真实 SMTP 服务器对话」这一步,
|
||||
// 把这一步 mock 掉等于不测。
|
||||
|
||||
// fakeSMTP 是一个刚好够用的 SMTP 服务器:能完成 greet/EHLO/AUTH/MAIL/RCPT/DATA/QUIT,
|
||||
// 并按用例要求对特定阶段返回指定应答码。
|
||||
type fakeSMTP struct {
|
||||
ln net.Listener
|
||||
|
||||
// rcptReply 是 RCPT TO 的应答;默认 250。
|
||||
rcptReply string
|
||||
// mailReply 是 MAIL FROM 的应答;默认 250。
|
||||
mailReply string
|
||||
// advertiseAuth 为 true 时在 EHLO 里声明支持 AUTH PLAIN。
|
||||
advertiseAuth bool
|
||||
|
||||
mu sync.Mutex
|
||||
data string
|
||||
commands []string
|
||||
}
|
||||
|
||||
func newFakeSMTP(t *testing.T) *fakeSMTP {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeSMTP{ln: ln, rcptReply: "250 OK", mailReply: "250 OK"}
|
||||
go f.serve()
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeSMTP) hostPort(t *testing.T) (string, int) {
|
||||
t.Helper()
|
||||
addr, ok := f.ln.Addr().(*net.TCPAddr)
|
||||
if !ok {
|
||||
t.Fatal("非 TCP 监听地址")
|
||||
}
|
||||
return "127.0.0.1", addr.Port
|
||||
}
|
||||
|
||||
func (f *fakeSMTP) record(cmd string) {
|
||||
f.mu.Lock()
|
||||
f.commands = append(f.commands, cmd)
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
func (f *fakeSMTP) body() string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.data
|
||||
}
|
||||
|
||||
func (f *fakeSMTP) sawCommand(prefix string) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, c := range f.commands {
|
||||
if strings.HasPrefix(c, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (f *fakeSMTP) serve() {
|
||||
conn, err := f.ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
br := bufio.NewReader(conn)
|
||||
w := func(s string) { _, _ = conn.Write([]byte(s + "\r\n")) }
|
||||
w("220 fake.local ESMTP ready")
|
||||
for {
|
||||
line, err := br.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
f.record(line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "EHLO"), strings.HasPrefix(line, "HELO"):
|
||||
// 不声明 STARTTLS:让代码走明文分支(测试目标是信封逻辑,不是 TLS)。
|
||||
w("250-fake.local")
|
||||
if f.advertiseAuth {
|
||||
w("250-AUTH PLAIN")
|
||||
}
|
||||
w("250 8BITMIME")
|
||||
case strings.HasPrefix(line, "AUTH"):
|
||||
// 简化处理:PLAIN 的初始应答可能跨多行,直接接受。
|
||||
w("235 2.7.0 Authentication successful")
|
||||
case strings.HasPrefix(line, "MAIL FROM"):
|
||||
w(f.mailReply)
|
||||
case strings.HasPrefix(line, "RCPT TO"):
|
||||
w(f.rcptReply)
|
||||
case strings.HasPrefix(line, "DATA"):
|
||||
w("354 End data with <CR><LF>.<CR><LF>")
|
||||
var sb strings.Builder
|
||||
for {
|
||||
dl, err := br.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimRight(dl, "\r\n") == "." {
|
||||
break
|
||||
}
|
||||
sb.WriteString(dl)
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.data = sb.String()
|
||||
f.mu.Unlock()
|
||||
w("250 2.0.0 Ok: queued as FAKE1")
|
||||
case strings.HasPrefix(line, "QUIT"):
|
||||
w("221 2.0.0 Bye")
|
||||
return
|
||||
default:
|
||||
w("250 OK")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func emailCfg(t *testing.T, f *fakeSMTP, extra map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
host, port := f.hostPort(t)
|
||||
cfg := map[string]any{
|
||||
"host": host,
|
||||
"port": float64(port),
|
||||
"from": "[email protected]",
|
||||
"to": []any{"[email protected]", "[email protected]"},
|
||||
}
|
||||
for k, v := range extra {
|
||||
cfg[k] = v
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestEmailSendDeliversFullMessage(t *testing.T) {
|
||||
f := newFakeSMTP(t)
|
||||
f.advertiseAuth = true
|
||||
cfg := emailCfg(t, f, map[string]any{"username": "artex", "password": "pw"})
|
||||
|
||||
if _, err := (emailChannel{}).Send(context.Background(), cfg, singleMsg()); err != nil {
|
||||
t.Fatalf("投递失败: %v", err)
|
||||
}
|
||||
// 信封阶段必须走到:发件人、两个收件人、DATA。
|
||||
for _, want := range []string{"MAIL FROM:<[email protected]>", "RCPT TO:<[email protected]>", "RCPT TO:<[email protected]>", "DATA", "AUTH", "QUIT"} {
|
||||
if !f.sawCommand(want) {
|
||||
t.Errorf("SMTP 会话里缺少 %q,实际命令:%v", want, f.commands)
|
||||
}
|
||||
}
|
||||
// 正文是 base64 的 HTML,且要带上真实的漏洞内容(编码后仍可辨认)。
|
||||
body := f.body()
|
||||
if body == "" {
|
||||
t.Fatal("DATA 阶段没有收到正文")
|
||||
}
|
||||
if !strings.Contains(body, "Content-Type: text/html") {
|
||||
t.Errorf("缺少 Content-Type 头:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "base64") {
|
||||
t.Errorf("正文未按 base64 编码(长 HTML 行会破坏 SMTP 的 1000 字节行长限制):\n%s", body)
|
||||
}
|
||||
// 多个收件人都要出现在 To 头里。
|
||||
if !strings.Contains(body, "[email protected], [email protected]") {
|
||||
t.Errorf("To 头未包含全部收件人:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailSendWithoutAuth(t *testing.T) {
|
||||
// 未配账号时不应发 AUTH —— 有些中继会因此拒收。
|
||||
f := newFakeSMTP(t)
|
||||
cfg := emailCfg(t, f, nil)
|
||||
if _, err := (emailChannel{}).Send(context.Background(), cfg, singleMsg()); err != nil {
|
||||
t.Fatalf("投递失败: %v", err)
|
||||
}
|
||||
if f.sawCommand("AUTH") {
|
||||
t.Errorf("未配账号却发了 AUTH: %v", f.commands)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmailSendClassifiesSMTPReplies 是本次审计修复的直接验证:
|
||||
// 5xx 判永久失败、4xx(灰名单)判可重试。
|
||||
func TestEmailSendClassifiesSMTPReplies(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rcptReply string
|
||||
mailReply string
|
||||
permanent bool
|
||||
}{
|
||||
{"收件人被 550 永久拒绝", "550 5.1.1 User unknown", "250 OK", true},
|
||||
{"收件人遇 450 灰名单", "450 4.7.1 Greylisting in action", "250 OK", false},
|
||||
{"收件人遇 452 邮箱满", "452 4.2.2 Mailbox full", "250 OK", false},
|
||||
{"发件人被 553 永久拒绝", "250 OK", "553 5.1.3 Bad address", true},
|
||||
{"发件人遇 451 临时错误", "250 OK", "451 4.3.0 Temporary failure", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := newFakeSMTP(t)
|
||||
f.rcptReply = tc.rcptReply
|
||||
f.mailReply = tc.mailReply
|
||||
_, err := (emailChannel{}).Send(context.Background(), emailCfg(t, f, nil), singleMsg())
|
||||
if err == nil {
|
||||
t.Fatal("应报错")
|
||||
}
|
||||
if got := IsPermanent(err); got != tc.permanent {
|
||||
t.Fatalf("permanent 判定错误:期望 %v 得到 %v (%v)", tc.permanent, got, err)
|
||||
}
|
||||
// 服务器原文要保留,否则用户不知道该找服务器管理员还是改地址。
|
||||
if !strings.Contains(err.Error(), strings.Fields(tc.rcptReply)[0]) && !strings.Contains(err.Error(), strings.Fields(tc.mailReply)[0]) {
|
||||
t.Errorf("错误里应保留服务器的应答码: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailSendRefusesPlaintextCredentials(t *testing.T) {
|
||||
// net/smtp 的 PlainAuth 拒绝在未加密连接上发凭据(除非目标是 localhost)。
|
||||
// 这是**正确**的安全行为,不能被绕过;但要给出能指导用户修复的错误。
|
||||
// 这里用一个非 localhost 的主机名触发它。
|
||||
f := newFakeSMTP(t)
|
||||
f.advertiseAuth = true
|
||||
_, port := f.hostPort(t)
|
||||
cfg := map[string]any{
|
||||
"host": "smtp.example.com", // 非 localhost
|
||||
"port": float64(port),
|
||||
"from": "[email protected]",
|
||||
"to": []any{"[email protected]"},
|
||||
"username": "artex",
|
||||
"password": "pw",
|
||||
}
|
||||
_, err := (emailChannel{}).Send(context.Background(), cfg, singleMsg())
|
||||
if err == nil {
|
||||
t.Skip("本机 DNS 解析到了本地服务器,跳过(不影响其它用例)")
|
||||
}
|
||||
// 连不上 或 被拒发凭据都算通过这条断言;关键是**不能**静默把密码发出去。
|
||||
if !IsPermanent(err) && !strings.Contains(err.Error(), "连接") {
|
||||
t.Logf("错误:%v(非 localhost 下未能连上属预期)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailValidateReportsMissingFields(t *testing.T) {
|
||||
// 邮件渠道的配置字段最多,遗漏任一个都会在投递时才暴露;这里逐个确认
|
||||
// 校验能提前拦下。断言检查的是「错误信息提到了缺什么」。
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]any
|
||||
}{
|
||||
{"缺 host", map[string]any{"port": float64(25), "from": "[email protected]", "to": []any{"[email protected]"}}},
|
||||
{"缺 port", map[string]any{"host": "smtp.example.com"}},
|
||||
{"port 越界", map[string]any{"host": "h", "port": float64(70000), "from": "[email protected]", "to": []any{"[email protected]"}}},
|
||||
{"缺 from", map[string]any{"host": "h", "port": float64(25), "to": []any{"[email protected]"}}},
|
||||
{"缺 to", map[string]any{"host": "h", "port": float64(25), "from": "[email protected]"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := (emailChannel{}).Validate(tc.cfg); err == nil {
|
||||
t.Fatalf("应校验失败: %v", tc.cfg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmailConfigTolerance 覆盖配置读取的容错:JSONB 里数值是 float64,
|
||||
// 但用户在 UI 里可能把端口填成字符串;数组也可能是单个字符串。
|
||||
func TestEmailConfigTolerance(t *testing.T) {
|
||||
cfg := map[string]any{
|
||||
"host": "smtp.example.com",
|
||||
"port": "587", // 字符串形式的端口
|
||||
"from": "[email protected]",
|
||||
"to": "[email protected]", // 单个字符串而非数组
|
||||
"tls": "true", // 字符串形式的布尔
|
||||
}
|
||||
if err := (emailChannel{}).Validate(cfg); err != nil {
|
||||
t.Fatalf("应容忍字符串形式的数值: %v", err)
|
||||
}
|
||||
if got := cfgInt(cfg, "port"); got != 587 {
|
||||
t.Errorf("cfgInt 未解析字符串端口,得到 %d", got)
|
||||
}
|
||||
if !cfgBool(cfg, "tls") {
|
||||
t.Error("cfgBool 未解析字符串 \"true\"")
|
||||
}
|
||||
if to := cfgStrings(cfg, "to"); len(to) != 1 || to[0] != "[email protected]" {
|
||||
t.Errorf("cfgStrings 未兼容单字符串,得到 %v", to)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilterValidateRejectsTypo 是审计修复的直接验证:
|
||||
// 门槛打错字必须在写入时被拦,否则过滤器会静默失效变成全推。
|
||||
func TestFilterValidateRejectsTypo(t *testing.T) {
|
||||
good := []string{"", "low", "medium", "high", "critical"}
|
||||
for _, s := range good {
|
||||
if err := (Filter{MinSeverity: s}).Validate(); err != nil {
|
||||
t.Errorf("合法门槛 %q 被拒: %v", s, err)
|
||||
}
|
||||
}
|
||||
// 这些是真实会发生的笔误——全部必须被拒。
|
||||
for _, s := range []string{"hgih", "HIGH", "严重", "high ", "crit"} {
|
||||
err := (Filter{MinSeverity: s}).Validate()
|
||||
if err == nil {
|
||||
t.Errorf("非法门槛 %q 应被拒绝(否则过滤器静默失效、变成全推)", s)
|
||||
continue
|
||||
}
|
||||
// 错误信息要能指导用户改对。
|
||||
if !strings.Contains(err.Error(), "low") || !strings.Contains(err.Error(), "critical") {
|
||||
t.Errorf("错误信息应列出可选值,得到 %q", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilterValidateIsWriteTimeOnly 锁住「写入严、读取宽」的分工:
|
||||
// 库里已有的坏值不能让渠道整个读不出来(那会让历史渠道突然全部停止推送)。
|
||||
func TestFilterValidateIsWriteTimeOnly(t *testing.T) {
|
||||
raw := []byte(`{"min_severity":"hgih"}`)
|
||||
f := ParseFilter(raw) // 不报错
|
||||
if f.MinSeverity != "hgih" {
|
||||
t.Fatalf("读取路径应原样保留,得到 %q", f.MinSeverity)
|
||||
}
|
||||
// 且该渠道仍能对事件做出判定(不 panic、不阻塞)。
|
||||
_ = Match(f, Snapshot{Kind: EventFindingCreated, Severity: "critical"})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package notify
|
||||
|
||||
// Snapshot 是 notification_events.snapshot 这一 JSONB 列的契约。写方是 db 层的
|
||||
// 漏洞落库事务,读方是 server 层的投递引擎与过滤匹配。定义放在本包是因为它是
|
||||
// 「通知领域」的载荷:db 只负责序列化,不理解字段含义。
|
||||
//
|
||||
// 为什么冗余存漏洞字段而不在渲染时回查:漏洞事后会被改名、改级别、改状态,
|
||||
// 而推送内容应当反映**事发当时**的结论——回查会得到「事后被改成 low」的
|
||||
// 危险误导。另外 fan-out 与渲染因此不必 JOIN findings/tasks/assets 三张表。
|
||||
type Snapshot struct {
|
||||
// 事件类型:finding_created / finding_status_changed
|
||||
Kind string `json:"kind"`
|
||||
FindingID int64 `json:"finding_id"`
|
||||
TaskID int64 `json:"task_id"`
|
||||
VulnClass string `json:"vulnclass"`
|
||||
Name string `json:"name"`
|
||||
Severity string `json:"severity"`
|
||||
Summary string `json:"summary"`
|
||||
AssetIDs []int64 `json:"asset_ids"`
|
||||
// 仅 kind=finding_status_changed 时非空。
|
||||
FromStatus string `json:"from_status,omitempty"`
|
||||
ToStatus string `json:"to_status,omitempty"`
|
||||
}
|
||||
|
||||
// Item 是一条待推送的漏洞,供渠道渲染。
|
||||
type Item struct {
|
||||
FindingID int64
|
||||
Name string
|
||||
VulnClass string
|
||||
Severity string
|
||||
Summary string
|
||||
// Assets 是解析后的资产展示名(如域名/IP)。由 server 层填充——
|
||||
// 本包不碰数据库,拿不到名字。
|
||||
Assets []string
|
||||
// DetailURL 是漏洞详情回链;为空表示未配 public_base_url,渲染时省略。
|
||||
DetailURL string
|
||||
// 状态变更事件专用;两项均非空时渲染成「待处理 → 已修复」。
|
||||
FromStatus string
|
||||
ToStatus string
|
||||
}
|
||||
|
||||
// IsStatusChange 报告该条目是否为状态变更事件。
|
||||
func (i Item) IsStatusChange() bool { return i.FromStatus != "" || i.ToStatus != "" }
|
||||
|
||||
// Title 返回条目的展示标题:优先人工命名的 name,回退漏洞类型 vulnclass,
|
||||
// 两者都空时用一个占位符——绝不输出空标题。
|
||||
func (i Item) Title() string {
|
||||
if i.Name != "" {
|
||||
return i.Name
|
||||
}
|
||||
if i.VulnClass != "" {
|
||||
return i.VulnClass
|
||||
}
|
||||
return "(未命名漏洞)"
|
||||
}
|
||||
|
||||
// Message 是一次渠道发送的完整内容。
|
||||
type Message struct {
|
||||
// 单条推送时长度为 1;汇总推送(digest)时为一整批。
|
||||
// 空切片是非法的,调用方须保证至少一条。
|
||||
Items []Item
|
||||
// Batch=true 时按汇总消息渲染(换标题、带上时间窗与条数)。
|
||||
Batch bool
|
||||
// WindowMinutes 是汇总周期(分钟),仅 Batch=true 时用于文案「近 N 分钟」。
|
||||
// 刻意由配置显式传入而不是渲染时算 time.Since:渲染保持确定性,才好测。
|
||||
WindowMinutes int
|
||||
// HomeURL 是平台面板地址(全局 public_base_url);空则不带面板入口。
|
||||
HomeURL string
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Filter 是 notification_channels.filter 这一 JSONB 列的契约:渠道实例的过滤条件。
|
||||
// 所有字段都可选,缺省即「不过滤」——这正是畸形配置的兜底语义,见 ParseFilter。
|
||||
type Filter struct {
|
||||
// MinSeverity 是最低级别门槛(low/medium/high/critical),空=不设门槛。
|
||||
MinSeverity string `json:"min_severity"`
|
||||
// TaskIDs / AssetIDs 为空数组表示不限;非空则要求事件与它有交集。
|
||||
TaskIDs []int64 `json:"task_ids"`
|
||||
AssetIDs []int64 `json:"asset_ids"`
|
||||
// VulnClassInclude 为空表示全收;非空则要求 vulnclass 命中其中任一关键词。
|
||||
// VulnClassExclude 命中任一关键词即排除(排除优先于包含)。
|
||||
// 匹配方式为大小写不敏感的子串——比正则安全:用户配错正则不会让渠道静默失效。
|
||||
VulnClassInclude []string `json:"vulnclass_include"`
|
||||
VulnClassExclude []string `json:"vulnclass_exclude"`
|
||||
// OnStatusChange 决定该渠道是否接收漏洞状态变更事件(仅 realtime 模式有意义)。
|
||||
OnStatusChange bool `json:"on_status_change"`
|
||||
}
|
||||
|
||||
// ParseFilter 解析渠道过滤配置。
|
||||
//
|
||||
// **永不返回 error。** 这是刻意的设计选择:过滤条件配置畸形时一律退化为零值
|
||||
// Filter(= 不过滤 = 全部命中),因为对一个漏洞通知系统来说,**多推一条远好过
|
||||
// 静默漏掉一条高危**。让解析失败变成「不推送」,等于给用户一个看起来配好了、
|
||||
// 实际什么都不推的渠道——这是最糟的失败模式。
|
||||
func ParseFilter(raw []byte) Filter {
|
||||
var f Filter
|
||||
if len(raw) == 0 {
|
||||
return f
|
||||
}
|
||||
// 解析失败时 f 保持零值,即不过滤。
|
||||
_ = json.Unmarshal(raw, &f)
|
||||
return f
|
||||
}
|
||||
|
||||
// ValidMinSeverity 报告 s 是否为合法的级别门槛(空串表示不设门槛)。
|
||||
func ValidMinSeverity(s string) bool {
|
||||
if s == "" {
|
||||
return true
|
||||
}
|
||||
_, ok := severityRank[s]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Validate 校验过滤配置里**取值受限**的字段,供保存渠道时调用。
|
||||
//
|
||||
// 为什么必须在写入时拦:Match 对未知门槛的判定是 `rank >= 0`,恒为真——
|
||||
// 也就是说 min_severity 打错一个字("hgih"),过滤器会**静默失效**变成
|
||||
// 「全推」。这与本包「宁可多推不可漏推」的取舍方向一致(不会漏),
|
||||
// 但后果是用户以为自己在做分级推送、实际把全部漏洞灌进群里,
|
||||
// 而且没有任何迹象提示他配错了。这类「静默降级」正应该在入口处拦掉。
|
||||
//
|
||||
// 注意 Validate 只用于**写入**路径。读取路径仍走 ParseFilter 的宽容语义,
|
||||
// 这样历史数据里已经存在的坏值不会让渠道整个读不出来。
|
||||
func (f Filter) Validate() error {
|
||||
if !ValidMinSeverity(f.MinSeverity) {
|
||||
return fmt.Errorf("最低级别 %q 无效,可选:low / medium / high / critical,或留空表示不限", f.MinSeverity)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Match 判定一个事件是否应投递到带有该过滤条件的渠道。
|
||||
//
|
||||
// **永不返回 error**,理由同 ParseFilter:任何内部异常都按「命中」处理。
|
||||
// 判定顺序:事件类型 → 级别门槛 → 任务/资产范围 → 漏洞类型关键词。
|
||||
func Match(f Filter, s Snapshot) bool {
|
||||
// 状态变更事件只有显式开启的渠道才接收。默认关,因为绝大多数使用者
|
||||
// 期望「推送」指的是「发现新漏洞」,而不是流水账式地跟进每个状态流转。
|
||||
if s.Kind == EventFindingStatusChanged && !f.OnStatusChange {
|
||||
return false
|
||||
}
|
||||
if !AtLeast(s.Severity, f.MinSeverity) {
|
||||
return false
|
||||
}
|
||||
if len(f.TaskIDs) > 0 && !slices.Contains(f.TaskIDs, s.TaskID) {
|
||||
return false
|
||||
}
|
||||
if len(f.AssetIDs) > 0 && !intersectsInt(f.AssetIDs, s.AssetIDs) {
|
||||
return false
|
||||
}
|
||||
// 排除优先:命中任一排除关键词即出局,即便同时命中了包含列表。
|
||||
if len(f.VulnClassExclude) > 0 && containsAnyFold(s.VulnClass, f.VulnClassExclude) {
|
||||
return false
|
||||
}
|
||||
if len(f.VulnClassInclude) > 0 && !containsAnyFold(s.VulnClass, f.VulnClassInclude) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func intersectsInt(a, b []int64) bool {
|
||||
// 小集合线性扫描即可;两边的量级都是「人手勾选的几十个」,
|
||||
// 建 map 的开销大于收益。
|
||||
for _, v := range b {
|
||||
if slices.Contains(a, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// containsAnyFold 报告 s 是否包含 keywords 中任一关键词(大小写不敏感)。
|
||||
func containsAnyFold(s string, keywords []string) bool {
|
||||
lower := strings.ToLower(s)
|
||||
for _, kw := range keywords {
|
||||
kw = strings.ToLower(strings.TrimSpace(kw))
|
||||
if kw != "" && strings.Contains(lower, kw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package notify
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseFilterMalformedFallsBackToMatchAll(t *testing.T) {
|
||||
// 畸形 JSON、空输入、类型不对的字段——全部必须退化为零值 Filter,
|
||||
// 即「不过滤」。这条不变量是「宁可多推不可漏推」的落点:
|
||||
// 一旦这里改成报错或半解析,用户配错一个字符就会静默丢掉所有高危通知。
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
}{
|
||||
{"空输入", ""},
|
||||
{"非法 JSON", `{not json`},
|
||||
{"截断的 JSON", `{"min_severity":`},
|
||||
{"类型不匹配", `{"min_severity": 123, "task_ids": "abc"}`},
|
||||
{"顶层是数组", `[1,2,3]`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := ParseFilter([]byte(tc.raw))
|
||||
if f.MinSeverity != "" || len(f.TaskIDs) != 0 || len(f.AssetIDs) != 0 {
|
||||
t.Fatalf("畸形配置应退化为零值 Filter,得到 %+v", f)
|
||||
}
|
||||
// 零值 Filter 必须命中任意事件。
|
||||
ev := Snapshot{Kind: EventFindingCreated, Severity: "low", VulnClass: "XSS"}
|
||||
if !Match(f, ev) {
|
||||
t.Fatal("零值 Filter 应命中所有事件")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchSeverityThreshold(t *testing.T) {
|
||||
ev := func(sev string) Snapshot {
|
||||
return Snapshot{Kind: EventFindingCreated, Severity: sev}
|
||||
}
|
||||
cases := []struct {
|
||||
min string
|
||||
sev string
|
||||
expect bool
|
||||
}{
|
||||
{"", "low", true},
|
||||
{"", "critical", true},
|
||||
{"high", "critical", true},
|
||||
{"high", "high", true},
|
||||
{"high", "medium", false},
|
||||
{"high", "low", false},
|
||||
{"critical", "high", false},
|
||||
{"critical", "critical", true},
|
||||
// 未知级别序数为 0,应被任何非空门槛挡住(存疑时不推)。
|
||||
{"low", "", false},
|
||||
{"low", "unknown", false},
|
||||
{"", "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := Match(Filter{MinSeverity: tc.min}, ev(tc.sev))
|
||||
if got != tc.expect {
|
||||
t.Errorf("min=%q sev=%q: 期望 %v 得到 %v", tc.min, tc.sev, tc.expect, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchScopeRestrictions(t *testing.T) {
|
||||
ev := Snapshot{
|
||||
Kind: EventFindingCreated,
|
||||
Severity: "high",
|
||||
TaskID: 7,
|
||||
AssetIDs: []int64{10, 20},
|
||||
VulnClass: "SQL注入",
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
filter Filter
|
||||
expect bool
|
||||
}{
|
||||
{"空范围=不限", Filter{}, true},
|
||||
{"任务命中", Filter{TaskIDs: []int64{7}}, true},
|
||||
{"任务未命中", Filter{TaskIDs: []int64{8}}, false},
|
||||
{"任务多选含命中", Filter{TaskIDs: []int64{8, 7}}, true},
|
||||
{"资产有交集", Filter{AssetIDs: []int64{20, 99}}, true},
|
||||
{"资产无交集", Filter{AssetIDs: []int64{99}}, false},
|
||||
{"任务与资产同时命中", Filter{TaskIDs: []int64{7}, AssetIDs: []int64{10}}, true},
|
||||
{"任务命中但资产未命中", Filter{TaskIDs: []int64{7}, AssetIDs: []int64{99}}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := Match(tc.filter, ev); got != tc.expect {
|
||||
t.Errorf("期望 %v 得到 %v", tc.expect, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchVulnClassKeywords(t *testing.T) {
|
||||
ev := func(class string) Snapshot {
|
||||
return Snapshot{Kind: EventFindingCreated, Severity: "high", VulnClass: class}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
filter Filter
|
||||
class string
|
||||
expect bool
|
||||
}{
|
||||
{"include 为空=全收", Filter{}, "任意类型", true},
|
||||
{"include 命中", Filter{VulnClassInclude: []string{"SQL"}}, "SQL注入", true},
|
||||
{"include 未命中", Filter{VulnClassInclude: []string{"命令执行"}}, "SQL注入", false},
|
||||
{"include 多词任一命中", Filter{VulnClassInclude: []string{"命令执行", "SQL"}}, "SQL注入", true},
|
||||
{"大小写不敏感", Filter{VulnClassInclude: []string{"sql"}}, "SQL注入", true},
|
||||
{"exclude 命中即排除", Filter{VulnClassExclude: []string{"信息泄露"}}, "信息泄露", false},
|
||||
{"exclude 未命中则放行", Filter{VulnClassExclude: []string{"信息泄露"}}, "SQL注入", true},
|
||||
// 排除优先于包含:同时命中时应当出局。
|
||||
{"排除优先于包含", Filter{
|
||||
VulnClassInclude: []string{"SQL"},
|
||||
VulnClassExclude: []string{"注入"},
|
||||
}, "SQL注入", false},
|
||||
// 纯空白关键词应被忽略,否则会退化成「匹配所有含空格的字符串」。
|
||||
{"空白关键词被忽略", Filter{VulnClassInclude: []string{"", " "}}, "SQL注入", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := Match(tc.filter, ev(tc.class)); got != tc.expect {
|
||||
t.Errorf("期望 %v 得到 %v", tc.expect, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchStatusChangeRequiresOptIn(t *testing.T) {
|
||||
ev := Snapshot{Kind: EventFindingStatusChanged, Severity: "critical", FromStatus: "pending", ToStatus: "fixed"}
|
||||
// 默认关:绝大多数人说的「推送漏洞」指发现新漏洞,不是状态流水账。
|
||||
if Match(Filter{MinSeverity: "low"}, ev) {
|
||||
t.Fatal("状态变更事件在未开启时应被跳过")
|
||||
}
|
||||
if !Match(Filter{OnStatusChange: true}, ev) {
|
||||
t.Fatal("开启 on_status_change 后状态变更事件应命中")
|
||||
}
|
||||
// 创建事件不受 on_status_change 影响。
|
||||
created := Snapshot{Kind: EventFindingCreated, Severity: "critical"}
|
||||
if !Match(Filter{MinSeverity: "low"}, created) {
|
||||
t.Fatal("创建事件不应依赖 on_status_change")
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件渲染邮件的 HTML 正文。刻意用内联样式 + 简单表格布局而不是现代 CSS:
|
||||
// 邮件客户端(尤其 Outlook 与国内企业邮箱)对 <style> 块和 flex/grid 的支持
|
||||
// 差异极大,内联样式是唯一在各家都能正确显示的写法。
|
||||
|
||||
// htmlSeverityColor 返回级别对应的强调色,用于左侧色条与标题。
|
||||
func htmlSeverityColor(severity string) string {
|
||||
switch severity {
|
||||
case "critical":
|
||||
return "#d32029"
|
||||
case "high":
|
||||
return "#e8830c"
|
||||
case "medium":
|
||||
return "#d4b106"
|
||||
case "low":
|
||||
return "#1677ff"
|
||||
default:
|
||||
return "#8c8c8c"
|
||||
}
|
||||
}
|
||||
|
||||
// htmlTitle 返回邮件主题。
|
||||
func htmlTitle(m Message) string {
|
||||
return markdownTitle(m)
|
||||
}
|
||||
|
||||
// htmlBody 渲染邮件正文 HTML。maxRunes<=0 表示不截断。
|
||||
func htmlBody(m Message, maxRunes int) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif;font-size:14px;color:#262626;line-height:1.6;">`)
|
||||
if m.Batch {
|
||||
b.WriteString(htmlBatchIntro(m))
|
||||
for _, it := range m.Items {
|
||||
b.WriteString(htmlItem(it, false))
|
||||
}
|
||||
} else if len(m.Items) > 0 {
|
||||
b.WriteString(htmlItem(m.Items[0], true))
|
||||
}
|
||||
if m.HomeURL != "" {
|
||||
fmt.Fprintf(&b, `<p style="margin:16px 0 0;"><a href="%s" style="color:#1677ff;">在平台中查看全部</a></p>`, htmlEscapeAttr(m.HomeURL))
|
||||
}
|
||||
b.WriteString(`</div>`)
|
||||
return TruncateHTML(b.String(), maxRunes)
|
||||
}
|
||||
|
||||
// htmlBatchIntro 渲染汇总邮件开头:条数与级别分布。
|
||||
func htmlBatchIntro(m Message) string {
|
||||
var b strings.Builder
|
||||
if m.WindowMinutes > 0 {
|
||||
fmt.Fprintf(&b, `<h2 style="font-size:16px;margin:0 0 4px;">近 %d 分钟新增 %d 个漏洞</h2>`, m.WindowMinutes, len(m.Items))
|
||||
} else {
|
||||
fmt.Fprintf(&b, `<h2 style="font-size:16px;margin:0 0 4px;">新增 %d 个漏洞</h2>`, len(m.Items))
|
||||
}
|
||||
counts := map[string]int{}
|
||||
for _, it := range m.Items {
|
||||
counts[it.Severity]++
|
||||
}
|
||||
var parts []string
|
||||
for _, sev := range []string{"critical", "high", "medium", "low"} {
|
||||
if n := counts[sev]; n > 0 {
|
||||
parts = append(parts, fmt.Sprintf(`<span style="color:%s;font-weight:600;">%s %d</span>`,
|
||||
htmlSeverityColor(sev), htmlEscape(SeverityLabel(sev)), n))
|
||||
}
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
fmt.Fprintf(&b, `<p style="margin:0 0 12px;">%s</p>`, strings.Join(parts, " · "))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// htmlItem 渲染单个漏洞。full=true 时含摘要与回链(单条推送),
|
||||
// false 时压缩成一行(汇总列表)。
|
||||
func htmlItem(it Item, full bool) string {
|
||||
color := htmlSeverityColor(it.Severity)
|
||||
var b strings.Builder
|
||||
if full {
|
||||
fmt.Fprintf(&b, `<div style="border-left:4px solid %s;padding:8px 0 8px 12px;margin-bottom:12px;">`, color)
|
||||
} else {
|
||||
fmt.Fprintf(&b, `<div style="border-left:3px solid %s;padding:4px 0 4px 10px;margin-bottom:8px;">`, color)
|
||||
}
|
||||
fmt.Fprintf(&b, `<div style="font-weight:600;">%s · %s</div>`,
|
||||
htmlEscape(SeverityLabel(it.Severity)), htmlEscape(it.Title()))
|
||||
|
||||
if !full {
|
||||
var extras []string
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
extras = append(extras, htmlEscape(a))
|
||||
}
|
||||
if it.Summary != "" {
|
||||
extras = append(extras, htmlEscape(OneLine(it.Summary, 60)))
|
||||
}
|
||||
if len(extras) > 0 {
|
||||
fmt.Fprintf(&b, `<div style="color:#595959;font-size:13px;">%s</div>`, strings.Join(extras, " · "))
|
||||
}
|
||||
b.WriteString(`</div>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
if it.IsStatusChange() {
|
||||
fmt.Fprintf(&b, `<div><b>状态变更</b>:%s → %s</div>`,
|
||||
htmlEscape(StatusLabel(it.FromStatus)), htmlEscape(StatusLabel(it.ToStatus)))
|
||||
}
|
||||
if it.VulnClass != "" && it.VulnClass != it.Title() {
|
||||
fmt.Fprintf(&b, `<div><b>类型</b>:%s</div>`, htmlEscape(it.VulnClass))
|
||||
}
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
fmt.Fprintf(&b, `<div><b>资产</b>:%s</div>`, htmlEscape(a))
|
||||
}
|
||||
if s := OneLine(it.Summary, maxSummaryRunes); s != "" {
|
||||
fmt.Fprintf(&b, `<div><b>摘要</b>:%s</div>`, htmlEscape(s))
|
||||
}
|
||||
if it.DetailURL != "" {
|
||||
fmt.Fprintf(&b, `<div style="margin-top:6px;"><a href="%s" style="color:#1677ff;">查看详情</a></div>`, htmlEscapeAttr(it.DetailURL))
|
||||
}
|
||||
b.WriteString(`</div>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// htmlEscape 转义 HTML 文本内容。漏洞标题与摘要来自被测目标与模型输出,
|
||||
// 是不可信内容——不转义就等于允许把任意 HTML(含外链图片)注入到邮件里。
|
||||
func htmlEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
return s
|
||||
}
|
||||
|
||||
// htmlEscapeAttr 转义 HTML 属性值(在文本转义之外额外处理引号,
|
||||
// 防止 URL 里的引号提前闭合 href 属性)。
|
||||
func htmlEscapeAttr(s string) string {
|
||||
s = htmlEscape(s)
|
||||
s = strings.ReplaceAll(s, "\"", """)
|
||||
return s
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// allowLocalTargets 决定是否允许把消息投递到环回 / 链路本地地址。
|
||||
//
|
||||
// 默认拒绝。这几段地址不是 IM 机器人或公网邮件服务器会出现的地方,而它们能
|
||||
// 打到的东西很敏感:同机另一个服务的管理端口、以及云环境的元数据端点
|
||||
// (169.254.169.254,可读出实例凭据)。投递地址是管理员配的,但一个被 XSS/CSRF
|
||||
// 借用的管理会话、或共用同一 JWT 的第二个人,都能靠改配置把响应内容读回来
|
||||
// ——doJSON 会把 4xx/5xx 的响应体前 200 字节写进 last_error,而投递历史接口
|
||||
// 会把它回显出来,这就是一条半盲读原语。
|
||||
//
|
||||
// 但「本机 SMTP 中继」(127.0.0.1:25 上的 postfix)是自建邮件的常见配置,
|
||||
// 一刀切会把人卡住。所以留一个显式逃生口而不是硬编码放行:
|
||||
// 设置 ARTEX_NOTIFY_ALLOW_LOCAL=1 即允许。
|
||||
//
|
||||
// 导出为 AllowLocalTargetsEnv 是为了让测试能明确地打开它——本包与 server 包的
|
||||
// 用例大量使用 127.0.0.1 上的 httptest 假接收端,不打开就全部被守卫拦下。
|
||||
const AllowLocalTargetsEnv = "ARTEX_NOTIFY_ALLOW_LOCAL"
|
||||
|
||||
func allowLocalTargets() bool {
|
||||
v := strings.TrimSpace(os.Getenv(AllowLocalTargetsEnv))
|
||||
return v == "1" || strings.EqualFold(v, "true")
|
||||
}
|
||||
|
||||
// isBlockedDialIP 报告目标 IP 是否属于「默认不允许投递」的地址段。
|
||||
//
|
||||
// 只拒绝环回、链路本地(含云元数据 169.254.169.254)、未指定与组播。
|
||||
// **不拒绝** RFC1918 私网:内网自建 Mattermost / SMTP 中继是很常见的合法用法,
|
||||
// 把它们一并挡掉会让功能在真实环境里直接不可用。这条取舍是刻意的——
|
||||
// 防护要挡住真正敏感的目标,同时不能把正常部署一起废掉。
|
||||
func isBlockedDialIP(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return true
|
||||
}
|
||||
// IPv4-mapped IPv6(::ffff:127.0.0.1)要还原成 IPv4 再判,否则绕过检查。
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
ip = v4
|
||||
}
|
||||
return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
|
||||
ip.IsInterfaceLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast()
|
||||
}
|
||||
|
||||
// blockInternalDial 是 http.Transport 拨号器的 Control 钩子,在**连接建立时**
|
||||
// 检查目标地址。
|
||||
//
|
||||
// 为什么设在拨号阶段而不是只在保存配置时校验:这里才是最终生效点。
|
||||
// 它同时覆盖两种绕过配置校验的情形——DNS 重绑定(校验时解析到公网 IP、
|
||||
// 真正连接时解析到内网)与重定向(虽然我们已拒绝跨主机跳转,但同主机跳转
|
||||
// 仍可能把路径指到别处)。
|
||||
func blockInternalDial(_, address string, _ syscall.RawConn) error {
|
||||
if allowLocalTargets() {
|
||||
return nil
|
||||
}
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return fmt.Errorf("无法解析目标地址 %q", host)
|
||||
}
|
||||
if isBlockedDialIP(ip) {
|
||||
return fmt.Errorf("拒绝投递到本机/链路本地地址 %s(如确需投递到本机服务,设置 %s=1)", ip, AllowLocalTargetsEnv)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// notifyTransport 在默认 Transport 的基础上只加一个拨号守卫。
|
||||
// 用 Clone 保留默认的全部调优(连接池、HTTP/2、超时、proxy 等),
|
||||
// 避免为了加一个检查而改动其它行为。
|
||||
var notifyTransport = func() *http.Transport {
|
||||
t, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return &http.Transport{}
|
||||
}
|
||||
clone := t.Clone()
|
||||
clone.DialContext = (&net.Dialer{Timeout: 10 * time.Second, Control: blockInternalDial}).DialContext
|
||||
return clone
|
||||
}()
|
||||
|
||||
// httpClient 是所有渠道投递共用的客户端。
|
||||
//
|
||||
// 刻意**不**复用项目的全局出口代理(server 侧的 GlobalProxy):那个代理是给渗透
|
||||
// 目标流量用的,常是不稳定的隧道,而通知的可用性不该被目标网络的抖动绑架。
|
||||
// IM 推送直连即可。超时设为 15 秒——比这更慢的对端实际上已经是故障状态。
|
||||
//
|
||||
// 拒绝跨主机重定向:本功能的投递地址都是「一个固定 endpoint」形态,正常不会
|
||||
// 重定向到别的主机;而这几家的凭据(钉钉的 access_token、企微的 key、Telegram 的
|
||||
// bot token)**就在 URL 里**,跟随跨主机跳转等于把凭据交给重定向目标。同主机
|
||||
// 的跳转(如末尾补斜杠)仍允许。
|
||||
var httpClient = &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
Transport: notifyTransport,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return errors.New("重定向次数过多")
|
||||
}
|
||||
if len(via) > 0 && req.URL.Host != via[0].URL.Host {
|
||||
return fmt.Errorf("拒绝跨主机重定向(%s → %s)", via[0].URL.Host, req.URL.Host)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// respBodyLimit 限制读取响应体的大小。对端异常时可能吐回超大内容,而我们只需要
|
||||
// 错误码和一小段错误描述用于展示在投递历史里。
|
||||
const respBodyLimit = 8 << 10
|
||||
|
||||
// doJSON 发送一次请求并返回响应体(已限长)。
|
||||
//
|
||||
// payload 为 nil 时发送空 body(用于 GET 或平台不要求 body 的场景)。
|
||||
// headers 里的键值原样附加,用于通用 Webhook 的自定义头。
|
||||
//
|
||||
// 错误分类是这个函数的核心职责:网络层失败与 5xx/408/429 归为「可重试」,
|
||||
// 其余 4xx 归为「永久失败」——重试一个 403 只是把同一个错误刷 3 遍日志。
|
||||
func doJSON(ctx context.Context, method, url string, headers map[string]string, payload any) ([]byte, error) {
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
// 序列化失败是本地 bug(配置字段类型不对),重试也不会变好。
|
||||
return nil, Permanent(fmt.Errorf("构造请求体失败: %w", err))
|
||||
}
|
||||
body = bytes.NewReader(raw)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
// URL 非法——多半是用户把地址填错了,属于永久失败。
|
||||
// 这里同样不能透传 err:url.Parse 的错误文本里含完整地址。
|
||||
return nil, Permanent(fmt.Errorf("请求地址非法: %s", redactRequestTarget(url)))
|
||||
}
|
||||
if payload != nil {
|
||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
// 连接被拒、DNS 失败、超时——多为瞬时故障,交给退避重试。
|
||||
//
|
||||
// 错误文本必须脱敏后再往外传。原因:http.Client.Do 返回的是 *url.Error,
|
||||
// 它的 Error() 是 `Op "完整URL": 底层错误`,而本功能这几家的凭据**就在 URL 里**
|
||||
// (钉钉 access_token、企微 key、飞书 hook id、Telegram /bot<token>/)。
|
||||
// 不脱敏的话,凭据会顺着这条错误串流到四个地方:notification_deliveries
|
||||
// 的 last_error(明文落库)、投递历史接口的响应(**绕过渠道配置的掩码**)、
|
||||
// 服务端日志、以及测试发送接口回给前端的 502 文本。
|
||||
return nil, fmt.Errorf("请求失败: %s", redactTransportError(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, readErr := io.ReadAll(io.LimitReader(resp.Body, respBodyLimit))
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("读取响应失败: %w", readErr)
|
||||
}
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return raw, nil
|
||||
}
|
||||
// 429(限流)与 408(超时)值得重试;其余 4xx 是配置或权限问题,重试无意义。
|
||||
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusRequestTimeout {
|
||||
return nil, fmt.Errorf("对方限流或超时 (HTTP %d): %s", resp.StatusCode, snippet(raw))
|
||||
}
|
||||
if resp.StatusCode >= 500 {
|
||||
return nil, fmt.Errorf("对方服务异常 (HTTP %d): %s", resp.StatusCode, snippet(raw))
|
||||
}
|
||||
return nil, Permanent(fmt.Errorf("对方拒绝请求 (HTTP %d): %s", resp.StatusCode, snippet(raw)))
|
||||
}
|
||||
|
||||
// snippet 把响应体压成一行短文本,用于错误信息。响应里可能带换行与大量空白,
|
||||
// 直接塞进 last_error 会让投递历史页面排版崩掉。
|
||||
func snippet(raw []byte) string {
|
||||
return OneLine(string(raw), 200)
|
||||
}
|
||||
|
||||
// redactRequestTarget 把投递地址压成「scheme://host/…」,用于错误信息。
|
||||
//
|
||||
// 这是本包唯一的地址脱敏口径,刻意做得**足够粗暴**:除了 scheme 与 host,
|
||||
// 其余一律丢弃。原因是没有一个「通用且安全」的方式判断 URL 的哪一段是凭据:
|
||||
//
|
||||
// 钉钉 凭据在 query /robot/send?access_token=xxx
|
||||
// 企微 凭据在 query /cgi-bin/webhook/send?key=xxx
|
||||
// 飞书 凭据在**路径末段** /open-apis/bot/v2/hook/<hook_id>
|
||||
// Telegram 凭据在**路径中段** /bot<token>/sendMessage
|
||||
//
|
||||
// 想「只保留有用部分」就得按渠道打补丁,而漏掉任何一家就是一次凭据泄露。
|
||||
// 保留 host 已经够用于排查(DNS 解析不了、连不上、证书不对都能定位),
|
||||
// 具体是哪个机器人由渠道配置里的掩码尾号提示去认。
|
||||
//
|
||||
// 解析失败时返回固定占位符——绝不把原始串回显出去。
|
||||
func redactRequestTarget(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return "(地址不可解析)"
|
||||
}
|
||||
return u.Scheme + "://" + u.Host + "/…"
|
||||
}
|
||||
|
||||
// redactTransportError 从传输层错误里剥掉地址,只保留底层原因。
|
||||
//
|
||||
// *url.Error 的结构是 {Op, URL, Err},Error() 会把 URL 一起打出来。
|
||||
// 这里显式取 Err 字段,绕开它的 Error() ——比事后做字符串替换更可靠,
|
||||
// 因为替换要正确应对 URL 编码/转义后的各种变体,容易漏。
|
||||
func redactTransportError(err error) string {
|
||||
var uerr *url.Error
|
||||
if errors.As(err, &uerr) {
|
||||
host := ""
|
||||
if u, parseErr := url.Parse(uerr.URL); parseErr == nil {
|
||||
host = u.Host
|
||||
}
|
||||
if uerr.Err != nil {
|
||||
return fmt.Sprintf("%s %s: %s", uerr.Op, host, uerr.Err)
|
||||
}
|
||||
return fmt.Sprintf("%s %s: 未知错误", uerr.Op, host)
|
||||
}
|
||||
// 非 *url.Error(如重定向策略返回的错误)也可能带地址,统一走脱敏。
|
||||
return redactURLsInText(err.Error())
|
||||
}
|
||||
|
||||
// redactURLsInText 把一段文本里出现的 http(s) 地址替换成脱敏形态。
|
||||
//
|
||||
// 用于兜底那些拿不到结构化字段的错误(重定向策略错误、第三方库的自定义错误)。
|
||||
// 只识别 http/https 前缀,按空白与引号切分——地址不会包含这两类字符。
|
||||
func redactURLsInText(s string) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); {
|
||||
rest := s[i:]
|
||||
if strings.HasPrefix(rest, "http://") || strings.HasPrefix(rest, "https://") {
|
||||
end := len(rest)
|
||||
if j := strings.IndexAny(rest, " \t\n\"'"); j >= 0 {
|
||||
end = j
|
||||
}
|
||||
b.WriteString(redactRequestTarget(rest[:end]))
|
||||
i += end
|
||||
continue
|
||||
}
|
||||
b.WriteByte(s[i])
|
||||
i++
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件覆盖 doJSON 的 HTTP 层错误分级。
|
||||
//
|
||||
// 为什么要单独测:各渠道适配器只管平台自己的业务错误码(钉钉 errcode、
|
||||
// 飞书 code、Telegram ok 字段),而**HTTP 层**的分级是 doJSON 统一做的,
|
||||
// 两者是两道独立的防线。少了这道,一个返回 503 的中转网关会被当成永久失败、
|
||||
// 直接放弃重试;而一个 403 会被当成可重试、白白退避三轮。
|
||||
|
||||
func replyServer(t *testing.T, status int, body string) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestDoJSONClassifiesHTTPStatus(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
permanent bool
|
||||
}{
|
||||
{"200 成功不算错误", 200, false},
|
||||
{"429 限流可重试", 429, false},
|
||||
{"408 请求超时可重试", 408, false},
|
||||
{"500 服务端错误可重试", 500, false},
|
||||
{"502 网关错误可重试", 502, false},
|
||||
{"503 服务不可用可重试", 503, false},
|
||||
{"400 参数错误永久失败", 400, true},
|
||||
{"401 鉴权失败永久失败", 401, true},
|
||||
{"403 禁止访问永久失败", 403, true},
|
||||
{"404 地址不存在永久失败", 404, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := replyServer(t, tc.status, `{"detail":"upstream says no"}`)
|
||||
_, err := doJSON(context.Background(), "GET", srv.URL, nil, nil)
|
||||
if tc.status < 300 {
|
||||
if err != nil {
|
||||
t.Fatalf("2xx 不应报错: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("非 2xx 应报错")
|
||||
}
|
||||
if got := IsPermanent(err); got != tc.permanent {
|
||||
t.Fatalf("HTTP %d 的 permanent 判定错误:期望 %v 得到 %v (%v)",
|
||||
tc.status, tc.permanent, got, err)
|
||||
}
|
||||
// 状态码必须出现在错误里,否则用户无从判断是自己配错了还是对端挂了。
|
||||
// 断言数字而不是 Go 的英文 StatusText:本包的文案是中文的
|
||||
// (与项目其它部分一致),数字才是语言无关、可稳定断言的部分。
|
||||
if !strings.Contains(err.Error(), strconv.Itoa(tc.status)) {
|
||||
t.Errorf("错误信息应带上 HTTP 状态码 %d,得到 %v", tc.status, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoJSONIncludesResponseSnippet 覆盖 snippet:对端返回的错误说明要带回来,
|
||||
// 否则用户只知道「失败了」,不知道对端为什么拒绝。
|
||||
func TestDoJSONIncludesResponseSnippet(t *testing.T) {
|
||||
srv := replyServer(t, 400, `{"error":"invalid webhook token"}`)
|
||||
_, err := doJSON(context.Background(), "GET", srv.URL, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("应报错")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid webhook token") {
|
||||
t.Errorf("错误信息应带回对端的说明,得到 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoJSONSnippetIsSingleLineAndBounded 约束 snippet 的形态:
|
||||
// 它对端响应会原样进 last_error 列与前端表格,多行/超长会破坏排版与载荷。
|
||||
func TestDoJSONSnippetIsSingleLineAndBounded(t *testing.T) {
|
||||
// 带换行、制表符与 5000 字符超长内容的响应。
|
||||
long := strings.Repeat("x", 5000)
|
||||
srv := replyServer(t, 500, "line1\nline2\r\n\tline3 "+long)
|
||||
_, err := doJSON(context.Background(), "GET", srv.URL, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("应报错")
|
||||
}
|
||||
msg := err.Error()
|
||||
if strings.ContainsAny(msg, "\r\n\t") {
|
||||
t.Errorf("错误信息应压成单行,得到 %q", msg)
|
||||
}
|
||||
// snippet 上限 200 字符 + 固定前缀,总量必须远小于原始响应。
|
||||
if len(msg) > 400 {
|
||||
t.Errorf("错误信息过长(%d 字节),应被 snippet 截断: %q", len(msg), msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoJSONRejectsOversizedResponse 确认读取有上限:对端异常返回超大内容时
|
||||
// 不能把整个响应读进内存(投递历史里每一条都会存一份 last_error)。
|
||||
func TestDoJSONRejectsOversizedResponse(t *testing.T) {
|
||||
huge := strings.Repeat("A", 1<<20) // 1 MiB
|
||||
srv := replyServer(t, 400, huge)
|
||||
_, err := doJSON(context.Background(), "GET", srv.URL, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("应报错")
|
||||
}
|
||||
if len(err.Error()) > 400 {
|
||||
t.Errorf("超大响应应被限长读取并截断,错误信息长度 %d", len(err.Error()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件是「Markdown 系」渠道(钉钉、企业微信)共用的消息渲染。
|
||||
// 飞书用卡片 JSON、Telegram 用 HTML、邮件用 HTML,各自在适配器里渲染。
|
||||
|
||||
// maxAssetsShown 是消息里最多列出几个资产。一个漏洞可能锚定几十个资产,
|
||||
// 全列会挤爆消息且没有信息价值——第 4 个之后的域名没人会在 IM 里看。
|
||||
const maxAssetsShown = 3
|
||||
|
||||
// maxSummaryRunes 是摘要被压缩到多少字符。IM 消息是「提示去看详情」,
|
||||
// 不是报告本体,完整内容在平台里。
|
||||
const maxSummaryRunes = 120
|
||||
|
||||
// markdownReservedBytes 预留给消息头部(汇总行 + 级别分布 + 可能的截断提示)
|
||||
// 与尾部(平台链接)。按整条打包时把这部分从预算里扣掉,保证头尾不会被截掉——
|
||||
// 头尾一旦被截,读者连「这是哪一批、还有多少条没显示」都看不出来。
|
||||
const markdownReservedBytes = 320
|
||||
|
||||
// markdownEscape 转义 markdown 元字符。
|
||||
//
|
||||
// 为什么必须做:漏洞标题、摘要、类型、资产展示名全都来自**不可信来源**——
|
||||
// 标题与摘要出自模型输出(模型读的是被测目标的响应),资产的 url 则是扫描
|
||||
// 得到的完整 URL(含目标可控的查询串)。不转义的话,一条标题为
|
||||
//
|
||||
// 登录口 SQL 注入\n[紧急:点此验证账号](http://attacker.tld)
|
||||
//
|
||||
// 的漏洞会在安全工程师的钉钉/飞书里渲染成**可点击的外链**;而
|
||||
// `` 会在渲染时被客户端拉取,等于通报了
|
||||
// 「这条漏洞已经被看过」并泄露阅读者 IP。就算是无恶意的内容,注入的粗体或
|
||||
// 引用块也能把下面的严重漏洞挤出折叠线。
|
||||
//
|
||||
// 转义集合覆盖标题/链接/强调/列表/引用/删除线这几类会改变结构或产生可点击
|
||||
// 元素的字符。`\` 必须最先处理,否则会把后面补上的反斜杠再次转义。
|
||||
func markdownEscape(s string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
`\`, `\\`,
|
||||
"`", "\\`",
|
||||
"*", `\*`,
|
||||
"_", `\_`,
|
||||
"[", `\[`,
|
||||
"]", `\]`,
|
||||
"(", `\(`,
|
||||
")", `\)`,
|
||||
"!", `\!`,
|
||||
"#", `\#`,
|
||||
">", `\>`,
|
||||
"|", `\|`,
|
||||
"~", `\~`,
|
||||
)
|
||||
return replacer.Replace(s)
|
||||
}
|
||||
|
||||
// markdownText 把不可信文本压成单行并转义,供 markdown 正文使用。
|
||||
// 单行化是转义之外的另一半:换行本身就能伪造出新的列表项或引用块,
|
||||
// 而转义字符挡不住它。
|
||||
func markdownText(s string, maxRunes int) string {
|
||||
return markdownEscape(OneLine(s, maxRunes))
|
||||
}
|
||||
|
||||
// markdownTitle 返回消息标题(IM 平台的标题栏/卡片标题),内容是**未转义的原文**。
|
||||
//
|
||||
// 这里刻意不做转义:这个标题被四种语境的渲染器共用——markdown 正文、Telegram 的
|
||||
// HTML、飞书卡片的 plain_text、以及通用 Webhook 的 JSON 与邮件主题。每个语境的
|
||||
// 转义规则都不同(markdown 转义塞进 HTML 会留下可见的反斜杠,塞进 JSON 会污染
|
||||
// 数据),所以转义必须由各自的输出端负责,见 writeItem / feishuItemLines /
|
||||
// telegramEscape。曾经在共享函数里加过 markdown 转义,结果 Telegram 消息里
|
||||
// 出现了 `\(1\)` 这种可见的反斜杠。
|
||||
func markdownTitle(m Message) string {
|
||||
if m.Batch {
|
||||
return fmt.Sprintf("漏洞汇总 · 共 %d 条", len(m.Items))
|
||||
}
|
||||
if len(m.Items) == 0 {
|
||||
return "漏洞通知"
|
||||
}
|
||||
it := m.Items[0]
|
||||
return fmt.Sprintf("[%s] %s", SeverityLabel(it.Severity), OneLine(it.Title(), 0))
|
||||
}
|
||||
|
||||
// markdownBody 渲染消息正文,返回正文与**实际写入的条目数**。
|
||||
//
|
||||
// 返回值 kept 是这次投递真正送达的条目数,调用方据此只把前 kept 条标记为
|
||||
// 已送达——被渠道长度上限挡在外面的条目必须留待下一批,而不是跟着一起被
|
||||
// 标记成功。这正是「静默丢失」的来源:消息被截断了,但投递记录显示全部送达,
|
||||
// 没有任何地方能看出后半截从未发出。
|
||||
//
|
||||
// maxBytes<=0 表示不限制。
|
||||
func markdownBody(m Message, maxBytes int) (string, int) {
|
||||
if !m.Batch {
|
||||
if len(m.Items) == 0 {
|
||||
return "", 0
|
||||
}
|
||||
var b strings.Builder
|
||||
writeItem(&b, m.Items[0], "", true)
|
||||
// 单条消息即使超长也照发(由最终截断兜底):一条漏洞的部分信息
|
||||
// 也好过一条都不发。
|
||||
return TruncateBytes(b.String(), maxBytes), 1
|
||||
}
|
||||
|
||||
footer := ""
|
||||
if m.HomeURL != "" {
|
||||
footer = fmt.Sprintf("\n[在平台中查看全部](%s)\n", m.HomeURL)
|
||||
}
|
||||
kept := packItemCount(m.Items, maxBytes, markdownReservedBytes, footer, byteSize, func(it Item, idx int) string {
|
||||
var b strings.Builder
|
||||
writeItem(&b, it, fmt.Sprintf("%d. ", idx+1), false)
|
||||
return b.String()
|
||||
})
|
||||
|
||||
items := m.Items[:kept]
|
||||
var b strings.Builder
|
||||
b.WriteString(markdownBatchIntro(m, items, len(m.Items)))
|
||||
for i, it := range items {
|
||||
writeItem(&b, it, fmt.Sprintf("%d. ", i+1), false)
|
||||
}
|
||||
b.WriteString(footer)
|
||||
return TruncateBytes(b.String(), maxBytes), kept
|
||||
}
|
||||
|
||||
// markdownBatchIntro 渲染汇总消息的开头:时间窗、条数与级别分布。
|
||||
// 有了这些,收到汇总的人不用点进平台就能判断这批需不需要立刻处理。
|
||||
//
|
||||
// items 是**实际装下**的条目,total 是本批应有的总数。两者不同时必须明说
|
||||
// 「还有多少条在下一条消息里」——否则读者会以为消息头写的那个数字就是全部,
|
||||
// 而后面那些从未发出的条目在界面上完全不存在。
|
||||
func markdownBatchIntro(m Message, items []Item, total int) string {
|
||||
var b strings.Builder
|
||||
if m.WindowMinutes > 0 {
|
||||
fmt.Fprintf(&b, "**近 %d 分钟新增 %d 个漏洞**", m.WindowMinutes, total)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "**新增 %d 个漏洞**", total)
|
||||
}
|
||||
if extra := total - len(items); extra > 0 {
|
||||
fmt.Fprintf(&b, "(本条显示前 %d 条,其余 %d 条将在下一条消息继续)", len(items), extra)
|
||||
}
|
||||
// 按级别给出分布,让读者一眼看到有没有严重项。只统计**本条实际包含**的
|
||||
// 条目,保证「严重 3」和下面能数出来的条目一致。
|
||||
counts := map[string]int{}
|
||||
for _, it := range items {
|
||||
counts[it.Severity]++
|
||||
}
|
||||
var parts []string
|
||||
for _, sev := range []string{"critical", "high", "medium", "low"} {
|
||||
if n := counts[sev]; n > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%s %d", SeverityLabel(sev), n))
|
||||
}
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
b.WriteString("\n" + strings.Join(parts, " · "))
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// writeItem 渲染单个漏洞条目。
|
||||
//
|
||||
// prefix 用于汇总列表的序号;single=true 时渲染完整版(含摘要与回链),
|
||||
// 汇总列表里只渲染一行摘要——否则 50 条汇总会变成一篇长文档。
|
||||
//
|
||||
// 所有来自外部的内容(标题/类型/资产/摘要)都过 markdownText:
|
||||
// 单行化 + 转义。回链是管理员配置的 public_base_url 拼出来的,不是不可信内容,
|
||||
// 且必须是可点的链接,所以原样输出。
|
||||
func writeItem(b *strings.Builder, it Item, prefix string, single bool) {
|
||||
line := fmt.Sprintf("%s**%s · %s**", prefix, SeverityLabel(it.Severity), markdownText(it.Title(), 0))
|
||||
if !single {
|
||||
// 汇总模式:单行呈现,资产与摘要压缩后跟在后面。
|
||||
var extras []string
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
extras = append(extras, markdownText(a, 0))
|
||||
}
|
||||
if it.Summary != "" {
|
||||
extras = append(extras, markdownText(it.Summary, 60))
|
||||
}
|
||||
if len(extras) > 0 {
|
||||
line += " — " + strings.Join(extras, " · ")
|
||||
}
|
||||
b.WriteString(line + "\n")
|
||||
return
|
||||
}
|
||||
b.WriteString(line + "\n")
|
||||
if it.IsStatusChange() {
|
||||
fmt.Fprintf(b, "**状态变更**:%s → %s\n",
|
||||
markdownText(StatusLabel(it.FromStatus), 0), markdownText(StatusLabel(it.ToStatus), 0))
|
||||
}
|
||||
if it.VulnClass != "" && it.VulnClass != it.Title() {
|
||||
fmt.Fprintf(b, "**类型**:%s\n", markdownText(it.VulnClass, 0))
|
||||
}
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
fmt.Fprintf(b, "**资产**:%s\n", markdownText(a, 0))
|
||||
}
|
||||
if it.Summary != "" {
|
||||
if s := markdownText(it.Summary, maxSummaryRunes); s != "" {
|
||||
fmt.Fprintf(b, "**摘要**:%s\n", s)
|
||||
}
|
||||
}
|
||||
if it.DetailURL != "" {
|
||||
fmt.Fprintf(b, "[查看详情](%s)\n", it.DetailURL)
|
||||
}
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaskedPrefix 是掩码值的标记前缀。API 回显凭据时用带此前缀的值替换真实内容,
|
||||
// 更新接口收到带此前缀的值即理解为「保持库中原值不变」。
|
||||
//
|
||||
// 用前缀而不是空串或某个固定常量,是为了能顺带带上一点可辨识信息
|
||||
// (见 MaskedValue),让用户区分得出「这是哪个机器人」而不必重新粘贴密钥。
|
||||
const MaskedPrefix = "__masked__"
|
||||
|
||||
// MaskedValue 生成一个掩码值:
|
||||
//
|
||||
// "__masked__" 原值太短,不给任何提示
|
||||
// "__masked__:…ab12cd" 带上原值末 6 位作为辨识提示
|
||||
//
|
||||
// 只暴露末 6 位是刻意选择的:Webhook 地址的辨识信息在末段(如企业微信的 key、
|
||||
// 飞书的机器人 id),而前缀部分各机器人相同、没有辨识价值。末 6 位不足以
|
||||
// 还原凭据,但足以让配置者认出「是我那个群」。
|
||||
func MaskedValue(secret string) string {
|
||||
if len(secret) <= 6 {
|
||||
return MaskedPrefix
|
||||
}
|
||||
return MaskedPrefix + ":…" + secret[len(secret)-6:]
|
||||
}
|
||||
|
||||
// IsMasked 报告某个值是否为掩码值(即接口回显后未被修改)。
|
||||
func IsMasked(v string) bool { return strings.HasPrefix(v, MaskedPrefix) }
|
||||
|
||||
// MaskConfig 返回配置的副本,把该渠道的凭据字段替换成掩码值。
|
||||
//
|
||||
// 未知渠道类型返回空 map 而不是原配置——宁可让 UI 显示「配置不可用」,
|
||||
// 也不要在渠道类型无法识别时把可能含凭据的原始内容整个吐回去。
|
||||
// 非凭据字段原样保留,UI 才能正常展示。
|
||||
func MaskConfig(kind string, cfg map[string]any) map[string]any {
|
||||
channel, ok := Get(kind)
|
||||
if !ok {
|
||||
return map[string]any{}
|
||||
}
|
||||
secrets := map[string]bool{}
|
||||
for _, k := range channel.SecretKeys() {
|
||||
secrets[k] = true
|
||||
}
|
||||
out := make(map[string]any, len(cfg))
|
||||
for k, v := range cfg {
|
||||
if !secrets[k] {
|
||||
out[k] = v
|
||||
continue
|
||||
}
|
||||
// headers 这类嵌套结构整体按一个凭据处理:逐个子键判断需要每个渠道
|
||||
// 再声明一套「哪些子键是凭据」的规则,复杂度远超收益。
|
||||
if s, ok := v.(string); ok {
|
||||
out[k] = MaskedValue(s)
|
||||
continue
|
||||
}
|
||||
out[k] = MaskedPrefix
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ErrDestinationChangedWithoutCredentials 表示「目标地址变了,但调用方没有对
|
||||
// 凭据字段表态」。返回它而不是默默放行或默默丢弃凭据,理由见 PrepareConfigUpdate。
|
||||
type ErrDestinationChangedWithoutCredentials struct {
|
||||
Changed []string // 发生变化的目的地键
|
||||
Missing []string // 未显式表态的凭据键
|
||||
}
|
||||
|
||||
func (e *ErrDestinationChangedWithoutCredentials) Error() string {
|
||||
return "目标地址(" + strings.Join(e.Changed, "、") + ")已变更,请同时重新填写凭据字段(" +
|
||||
strings.Join(e.Missing, "、") + "):填入新值,或显式留空表示不再需要凭据。" +
|
||||
"原凭据只对旧地址有效,继续沿用等于把它交给新地址。"
|
||||
}
|
||||
|
||||
// PrepareConfigUpdate 合并渠道配置,并处理「目标地址变更」这一安全敏感情况。
|
||||
//
|
||||
// 它替代裸的 MergeConfig 用在渠道更新路径上,解决的是这样一条实测可行的路径:
|
||||
// 目标地址(消息发往哪)与凭据(用什么身份发)是两套独立字段,而 MergeConfig
|
||||
// 对「未提及的键」一律保留库中原值。于是任何能 PATCH 渠道的人只要**只改地址、
|
||||
// 对凭据避而不谈**,就能让服务器把库里的真凭据发到自己控制的端点:
|
||||
//
|
||||
// webhook {config:{url:"https://attacker.tld"}} → 原始 Authorization 头随请求外发
|
||||
// telegram {config:{base_url:"https://attacker.tld"}} → /bot<真Token>/sendMessage
|
||||
// email {config:{host:"smtp.attacker.tld"}} → STARTTLS 后交出用户名与密码
|
||||
//
|
||||
// 这条路径完全静默、不依赖重定向(所以拒绝跨主机跳转挡不住它),
|
||||
// 而且直接击穿了本包掩码机制的目标——「凭据不回显给浏览器」。
|
||||
//
|
||||
// 规则:只要某个目的地键被改成新值,调用方就必须对**每一个**凭据键显式表态:
|
||||
// - 给出新值 → 用新值
|
||||
// - 显式传空串 → 该字段不再需要凭据(保留清空语义)
|
||||
// - 原样回传掩码值 / 干脆不提这个键 → 拒绝
|
||||
//
|
||||
// 第三种之所以也拒绝,是因为「掩码值」的含义正是「沿用旧凭据」,而旧凭据
|
||||
// 只对旧地址有效。这里刻意不做「自动丢弃凭据」——那对可选凭据字段
|
||||
// (webhook 的 headers、email 的 password)会静默变成「鉴权没了但接口返回 200」,
|
||||
// 比报错更难排查。宁可让操作者多填一次。
|
||||
func PrepareConfigUpdate(kind string, stored, incoming map[string]any) (map[string]any, error) {
|
||||
channel, ok := Get(kind)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("渠道类型 %q 未注册", kind)
|
||||
}
|
||||
secrets := channel.SecretKeys()
|
||||
destinations := channel.DestinationKeys()
|
||||
|
||||
// 非字符串的凭据值(如 webhook 的 headers 是个对象)里若嵌着掩码字面量,
|
||||
// 说明调用方把「保持原值」的哨兵塞进了结构体内部。MergeConfig 只认「字符串
|
||||
// 且带前缀」为掩码,这种形态会被当普通对象原样存下去——库里真的落下字面量
|
||||
// "__masked__",后续鉴权静默失效且没有任何报错。宁可拒掉。
|
||||
//
|
||||
// 这个检查必须放在**最前面**:地址没变时会走提前返回,放在后面就等于
|
||||
// 只覆盖了「改地址」这一条路径(第一版就是这么放错的,测试直接抓到了)。
|
||||
if err := rejectMaskedInContainers(incoming, secrets); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 找出真正被改掉的目的地键。掩码值等于「没改」。
|
||||
var changed []string
|
||||
for _, key := range destinations {
|
||||
raw, present := incoming[key]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
s, isStr := raw.(string)
|
||||
if isStr && IsMasked(s) {
|
||||
continue
|
||||
}
|
||||
if !sameConfigValue(raw, stored[key]) {
|
||||
changed = append(changed, key)
|
||||
}
|
||||
}
|
||||
if len(changed) == 0 {
|
||||
// 地址没变,走普通合并(掩码值保留原值、空串清空、其余覆盖)。
|
||||
return MergeConfig(stored, incoming), nil
|
||||
}
|
||||
|
||||
// 地址变了:要求对每个凭据键显式表态。
|
||||
var missing []string
|
||||
for _, key := range secrets {
|
||||
raw, present := incoming[key]
|
||||
if !present {
|
||||
missing = append(missing, key)
|
||||
continue
|
||||
}
|
||||
if s, isStr := raw.(string); isStr && IsMasked(s) {
|
||||
missing = append(missing, key)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return nil, &ErrDestinationChangedWithoutCredentials{Changed: changed, Missing: missing}
|
||||
}
|
||||
return MergeConfig(stored, incoming), nil
|
||||
}
|
||||
|
||||
// rejectMaskedInContainers 拒绝把掩码哨兵嵌在非字符串结构里提交。
|
||||
//
|
||||
// 掩码机制的前提是「整个值就是个字符串」。像 webhook 的 headers 这种对象字段,
|
||||
// 只能整体掩码(写成字符串 "__masked__")或整体提交;把哨兵塞进对象内部
|
||||
// 既表达不了「保持不变」,又会被当成真实值存进库。
|
||||
func rejectMaskedInContainers(incoming map[string]any, secretKeys []string) error {
|
||||
for _, key := range secretKeys {
|
||||
raw, present := incoming[key]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
if _, isStr := raw.(string); isStr {
|
||||
continue
|
||||
}
|
||||
encoded, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(string(encoded), MaskedPrefix) {
|
||||
return fmt.Errorf("字段 %s 的内容里含掩码标记 %q:该字段只能整体留空表示沿用、或整体提交新值,不能在结构体内部夹带掩码占位",
|
||||
key, MaskedPrefix)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sameConfigValue 比较两个配置值是否等价。用 JSON 序列化比较是为了顺带处理
|
||||
// 类型差异——前端提交的端口是 number,而库里读回来的是 float64,直接 == 会误判。
|
||||
func sameConfigValue(a, b any) bool {
|
||||
if a == nil && b == nil {
|
||||
return true
|
||||
}
|
||||
ra, errA := json.Marshal(a)
|
||||
rb, errB := json.Marshal(b)
|
||||
if errA != nil || errB != nil {
|
||||
return false
|
||||
}
|
||||
return string(ra) == string(rb)
|
||||
}
|
||||
|
||||
// MergeConfig 把 incoming 合并到 stored 之上,用于更新渠道配置。
|
||||
//
|
||||
// 规则:
|
||||
// - incoming 里值为掩码的键 → 保留 stored 的原值(用户没改这个字段)
|
||||
// - incoming 里值为空串的键 → 视为显式清空,删除该键
|
||||
// - 其余键 → 用 incoming 的值覆盖
|
||||
// - stored 里有而 incoming 里没有的键 → 保留(局部更新语义)
|
||||
//
|
||||
// 空串是否算「清空」需要明确:前端表单把未填的字段提交为空串,
|
||||
// 若把它当成有效值写入,会把「留空以保留原值」的字段真的清掉。
|
||||
// 这里选择显式清空,因为要清除一个设错的字段时,用户没有别的表达方式
|
||||
// (拖走字段可区分「未提供」与「提供空值」,但 UI 用不到这个区别)。
|
||||
func MergeConfig(stored, incoming map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(stored)+len(incoming))
|
||||
for k, v := range stored {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range incoming {
|
||||
if s, ok := v.(string); ok {
|
||||
if IsMasked(s) {
|
||||
continue // 掩码值 = 未修改,保留 stored
|
||||
}
|
||||
if strings.TrimSpace(s) == "" {
|
||||
delete(out, k)
|
||||
continue
|
||||
}
|
||||
out[k] = s
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMaskedValueHidesBodyButKeepsTailHint(t *testing.T) {
|
||||
const secret = "https://oapi.dingtalk.com/robot/send?access_token=abcdef123456"
|
||||
got := MaskedValue(secret)
|
||||
if strings.Contains(got, "abcdef123456") {
|
||||
t.Fatalf("掩码值泄露了完整凭据: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "oapi.dingtalk.com") {
|
||||
t.Fatalf("掩码值不应暴露地址主体: %q", got)
|
||||
}
|
||||
// 末 6 位要保留,用户才能认出是哪个机器人。
|
||||
if !strings.HasSuffix(got, "123456") {
|
||||
t.Fatalf("应保留末 6 位作为辨识提示: %q", got)
|
||||
}
|
||||
if !IsMasked(got) {
|
||||
t.Fatalf("掩码值必须能被 IsMasked 识别: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskedValueShortSecretGivesNoHint(t *testing.T) {
|
||||
// 短凭据如果也暴露末 6 位,等于把整个凭据暴露出去。
|
||||
for _, s := range []string{"abc", "abcdef", ""} {
|
||||
got := MaskedValue(s)
|
||||
if got != MaskedPrefix {
|
||||
t.Fatalf("长度 %d 的凭据不应给出尾部提示,得到 %q", len(s), got)
|
||||
}
|
||||
if s != "" && strings.Contains(got, s) {
|
||||
t.Fatalf("掩码值包含了原值: %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskConfigMasksOnlySecrets(t *testing.T) {
|
||||
cfg := map[string]any{
|
||||
"webhook": "https://example.com/hook?token=SECRETVALUE",
|
||||
"secret": "SECtest123456",
|
||||
"port": float64(587),
|
||||
"host": "smtp.example.com",
|
||||
}
|
||||
masked := MaskConfig(KindDingTalk, cfg)
|
||||
for _, k := range []string{"webhook", "secret"} {
|
||||
s, _ := masked[k].(string)
|
||||
if !IsMasked(s) {
|
||||
t.Errorf("%s 应被掩码,得到 %q", k, s)
|
||||
}
|
||||
}
|
||||
// 非凭据字段必须原样保留,否则 UI 无法展示。
|
||||
if masked["port"] != float64(587) {
|
||||
t.Errorf("非凭据字段 port 不应改动: %v", masked["port"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskConfigUnknownKindReturnsEmpty(t *testing.T) {
|
||||
// 渠道类型无法识别时,宁可让 UI 显示空配置,也不要把可能含凭据的原始内容吐回去。
|
||||
got := MaskConfig("nope", map[string]any{"webhook": "https://x/y?token=LEAK"})
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("未知渠道类型应返回空配置,得到 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskConfigDoesNotMutateInput(t *testing.T) {
|
||||
// 掩码是展示层行为,不能反过来把库里的真值改掉。
|
||||
cfg := map[string]any{"webhook": "https://example.com/hook", "secret": "SECtest123456"}
|
||||
_ = MaskConfig(KindDingTalk, cfg)
|
||||
if IsMasked(cfg["secret"].(string)) {
|
||||
t.Fatal("MaskConfig 修改了入参,会导致真实凭据被掩码值覆盖")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConfigKeepsStoredOnMaskedIncoming(t *testing.T) {
|
||||
stored := map[string]any{"webhook": "https://real/hook", "secret": "REALSECRET", "method": "POST"}
|
||||
// 用户只改了 method,浏览器提交的是掩码值+新 method。
|
||||
incoming := map[string]any{
|
||||
"webhook": MaskedValue("https://real/hook"),
|
||||
"secret": MaskedValue("REALSECRET"),
|
||||
"method": "PUT",
|
||||
}
|
||||
got := MergeConfig(stored, incoming)
|
||||
if got["webhook"] != "https://real/hook" || got["secret"] != "REALSECRET" {
|
||||
t.Fatalf("掩码字段应保留库中原值,得到 %v", got)
|
||||
}
|
||||
if got["method"] != "PUT" {
|
||||
t.Fatalf("被修改的字段应生效,得到 %v", got["method"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConfigEmptyStringClears(t *testing.T) {
|
||||
stored := map[string]any{"webhook": "https://real/hook", "secret": "REALSECRET"}
|
||||
got := MergeConfig(stored, map[string]any{"secret": ""})
|
||||
if _, ok := got["secret"]; ok {
|
||||
t.Fatalf("空串应清空该字段,得到 %v", got)
|
||||
}
|
||||
// 没提到的字段保留(局部更新语义)。
|
||||
if got["webhook"] != "https://real/hook" {
|
||||
t.Fatalf("未提及的字段应保留,得到 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConfigKeepsUnmentionedStoredKeys(t *testing.T) {
|
||||
stored := map[string]any{"host": "smtp.example.com", "port": float64(587), "password": "pw"}
|
||||
got := MergeConfig(stored, map[string]any{"port": float64(465)})
|
||||
if got["host"] != "smtp.example.com" || got["password"] != "pw" {
|
||||
t.Fatalf("未提及的字段应保留,得到 %v", got)
|
||||
}
|
||||
if got["port"] != float64(465) {
|
||||
t.Fatalf("已提及的字段应更新,得到 %v", got["port"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareConfigUpdateBlocksDestinationSwap 是本包最重要的一条安全不变量:
|
||||
// **改目标地址不能把旧凭据带过去**。
|
||||
//
|
||||
// 这些用例用的正是攻击形状的输入(只改地址、对凭据避而不谈),
|
||||
// 而不是「防御逻辑的正确输入」——只测后者的话,防御没生效也照样全绿。
|
||||
func TestPrepareConfigUpdateBlocksDestinationSwap(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
kind string
|
||||
stored map[string]any
|
||||
incoming map[string]any
|
||||
// wantMissing 是预期被点名的凭据键。
|
||||
wantMissing string
|
||||
}{
|
||||
{
|
||||
name: "通用 Webhook 改地址想沿用 Authorization 头",
|
||||
kind: KindWebhook,
|
||||
stored: map[string]any{
|
||||
"url": "https://legit.example.com/hook",
|
||||
"headers": map[string]any{"Authorization": "Bearer REAL-TOKEN"},
|
||||
},
|
||||
incoming: map[string]any{"url": "https://attacker.tld/c"},
|
||||
wantMissing: "headers",
|
||||
},
|
||||
{
|
||||
name: "Telegram 改 base_url 想把 Bot Token 发到自己的端点",
|
||||
kind: KindTelegram,
|
||||
stored: map[string]any{"bot_token": "123456:REAL", "chat_id": "1", "base_url": "https://api.telegram.org"},
|
||||
incoming: map[string]any{"base_url": "https://attacker.tld"},
|
||||
wantMissing: "bot_token",
|
||||
},
|
||||
{
|
||||
name: "邮件改 SMTP 主机想交出密码",
|
||||
kind: KindEmail,
|
||||
stored: map[string]any{"host": "smtp.corp.com", "port": 587, "password": "REALPW", "from": "[email protected]", "to": []any{"[email protected]"}},
|
||||
incoming: map[string]any{"host": "smtp.attacker.tld"},
|
||||
wantMissing: "password",
|
||||
},
|
||||
{
|
||||
name: "邮件关掉 TLS 也必须重新表态密码",
|
||||
kind: KindEmail,
|
||||
stored: map[string]any{"host": "smtp.corp.com", "port": 587, "tls": false, "password": "REALPW", "from": "[email protected]", "to": []any{"[email protected]"}},
|
||||
incoming: map[string]any{"tls": true},
|
||||
wantMissing: "password",
|
||||
},
|
||||
{
|
||||
// 掩码值 = 「沿用旧凭据」,在地址变更的语境下同样必须拒绝。
|
||||
name: "回传掩码凭据 + 新地址",
|
||||
kind: KindTelegram,
|
||||
stored: map[string]any{"bot_token": "123456:REAL", "chat_id": "1", "base_url": "https://api.telegram.org"},
|
||||
incoming: map[string]any{"base_url": "https://attacker.tld", "bot_token": MaskedValue("123456:REAL")},
|
||||
wantMissing: "bot_token",
|
||||
},
|
||||
{
|
||||
name: "钉钉改 Webhook 想沿用加签密钥",
|
||||
kind: KindDingTalk,
|
||||
stored: map[string]any{"webhook": "https://oapi.dingtalk.com/robot/send?access_token=OLD", "secret": "REALSEC"},
|
||||
incoming: map[string]any{"webhook": "https://attacker.tld/hook"},
|
||||
wantMissing: "secret",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
merged, err := PrepareConfigUpdate(tc.kind, tc.stored, tc.incoming)
|
||||
if err == nil {
|
||||
t.Fatalf("改地址却未重新表态凭据,应当被拒绝;得到配置 %v", merged)
|
||||
}
|
||||
var target *ErrDestinationChangedWithoutCredentials
|
||||
if !errors.As(err, &target) {
|
||||
t.Fatalf("应返回专门的错误类型以便接口给出可操作提示,得到 %T: %v", err, err)
|
||||
}
|
||||
found := false
|
||||
for _, m := range target.Missing {
|
||||
if m == tc.wantMissing {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("应点名缺失的凭据键 %q,得到 %v", tc.wantMissing, target.Missing)
|
||||
}
|
||||
// 错误信息要能指导操作者怎么修。
|
||||
if !strings.Contains(err.Error(), tc.wantMissing) {
|
||||
t.Errorf("错误信息应提到 %q: %v", tc.wantMissing, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareConfigUpdateAllowsLegitimateEdits 反向用例:正常的编辑不能被误拦,
|
||||
// 否则这个防护会因为「太烦」而被绕过或删掉。
|
||||
func TestPrepareConfigUpdateAllowsLegitimateEdits(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
kind string
|
||||
stored map[string]any
|
||||
incoming map[string]any
|
||||
}{
|
||||
{
|
||||
name: "只改名字(配置原样回传)",
|
||||
kind: KindWebhook,
|
||||
stored: map[string]any{"url": "https://legit.example.com/hook", "headers": map[string]any{"Authorization": "Bearer REAL"}},
|
||||
incoming: map[string]any{"url": MaskedValue("https://legit.example.com/hook")},
|
||||
},
|
||||
{
|
||||
name: "只改请求方法,地址与凭据都不动",
|
||||
kind: KindWebhook,
|
||||
stored: map[string]any{"url": "https://legit.example.com/hook", "method": "POST"},
|
||||
incoming: map[string]any{"method": "PUT"},
|
||||
},
|
||||
{
|
||||
name: "换地址并**同时**给新凭据",
|
||||
kind: KindWebhook,
|
||||
stored: map[string]any{"url": "https://old.example.com/hook", "headers": map[string]any{"Authorization": "Bearer OLD"}},
|
||||
incoming: map[string]any{"url": "https://new.example.com/hook", "headers": map[string]any{"Authorization": "Bearer NEW"}},
|
||||
},
|
||||
{
|
||||
name: "换地址并显式声明不再需要凭据",
|
||||
kind: KindWebhook,
|
||||
stored: map[string]any{"url": "https://old.example.com/hook", "headers": map[string]any{"Authorization": "Bearer OLD"}},
|
||||
incoming: map[string]any{"url": "https://new.example.com/hook", "headers": ""},
|
||||
},
|
||||
{
|
||||
name: "Telegram 改 chat_id(不是目的地)",
|
||||
kind: KindTelegram,
|
||||
stored: map[string]any{"bot_token": "t", "chat_id": "1", "base_url": "https://api.telegram.org"},
|
||||
incoming: map[string]any{"chat_id": "-100200"},
|
||||
},
|
||||
{
|
||||
name: "邮件改收件人(不是目的地)",
|
||||
kind: KindEmail,
|
||||
stored: map[string]any{"host": "smtp.corp.com", "port": 587, "password": "PW", "from": "[email protected]", "to": []any{"[email protected]"}},
|
||||
incoming: map[string]any{"to": []any{"[email protected]"}},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
merged, err := PrepareConfigUpdate(tc.kind, tc.stored, tc.incoming)
|
||||
if err != nil {
|
||||
t.Fatalf("合法编辑被误拦: %v", err)
|
||||
}
|
||||
if merged == nil {
|
||||
t.Fatal("应返回合并结果")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareConfigUpdatePortTypeTolerance 覆盖一个容易误判的细节:
|
||||
// 前端提交的端口是 JSON number(float64),库里读回来也是 float64,
|
||||
// 但两个值的类型可能不同(如 int vs float64)。用 == 比较会把「没改」判成「改了」,
|
||||
// 从而对只改了名字的用户弹出「请重新填写密码」——假警报会让人不再信任这个防护。
|
||||
func TestPrepareConfigUpdatePortTypeTolerance(t *testing.T) {
|
||||
stored := map[string]any{"host": "smtp.corp.com", "port": float64(587), "password": "PW"}
|
||||
// 同一个端口,以 int 形式提交。
|
||||
if _, err := PrepareConfigUpdate(KindEmail, stored, map[string]any{"port": 587}); err != nil {
|
||||
t.Fatalf("端口值相同(仅类型不同)不应被判为地址变更: %v", err)
|
||||
}
|
||||
// 真的换了端口则必须拦。
|
||||
if _, err := PrepareConfigUpdate(KindEmail, stored, map[string]any{"port": 25}); err == nil {
|
||||
t.Fatal("端口变更应被拦下")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDestinationKeysDeclaredForEveryKind(t *testing.T) {
|
||||
// 与 SecretKeys 同理:渠道若忘记声明目的地键,PrepareConfigUpdate 就保护不到它。
|
||||
for kind, ch := range registry {
|
||||
if len(ch.DestinationKeys()) == 0 {
|
||||
t.Errorf("渠道 %s 未声明目的地键,改地址带出凭据的防护对它无效", kind)
|
||||
}
|
||||
if len(ch.SecretKeys()) == 0 {
|
||||
t.Errorf("渠道 %s 未声明凭据键", kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretKeysDeclaredForEveryKind(t *testing.T) {
|
||||
// 编译器已经强制每个渠道实现 SecretKeys,这里再确认一遍「没有渠道在掩码上
|
||||
// 交白卷」——返回空切片的渠道意味着它的凭据会明文回显到浏览器。
|
||||
expect := map[string]bool{
|
||||
KindDingTalk: true, KindFeishu: true, KindWeCom: true,
|
||||
KindWebhook: true, KindTelegram: true, KindEmail: true,
|
||||
}
|
||||
for kind, ch := range registry {
|
||||
if !expect[kind] {
|
||||
t.Errorf("渠道 %s 未在测试中登记掩码预期", kind)
|
||||
continue
|
||||
}
|
||||
if len(ch.SecretKeys()) == 0 {
|
||||
t.Errorf("渠道 %s 未声明任何凭据字段,其配置会明文回显", kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareConfigUpdateRejectsMaskedInContainer 覆盖审计指出的一处口子:
|
||||
// 把掩码哨兵塞进**非字符串**结构(如 webhook.headers 是个对象)时,
|
||||
// MergeConfig 只认「字符串且带前缀」为掩码,于是字面量 "__masked__" 会被当成
|
||||
// 真实头值存进库——后续鉴权静默失效,且没有任何报错。
|
||||
func TestPrepareConfigUpdateRejectsMaskedInContainer(t *testing.T) {
|
||||
stored := map[string]any{
|
||||
"url": "https://legit.example.com/hook",
|
||||
"headers": map[string]any{"Authorization": "Bearer REAL"},
|
||||
}
|
||||
// 对象内部夹带掩码哨兵。
|
||||
incoming := map[string]any{
|
||||
"headers": map[string]any{"Authorization": MaskedPrefix},
|
||||
}
|
||||
if _, err := PrepareConfigUpdate(KindWebhook, stored, incoming); err == nil {
|
||||
t.Fatal("结构体内部夹带掩码哨兵应被拒绝(否则会把字面量存进库)")
|
||||
}
|
||||
// 整体提交对象(真实新值)照常接受。
|
||||
ok := map[string]any{"headers": map[string]any{"Authorization": "Bearer NEW"}}
|
||||
if _, err := PrepareConfigUpdate(KindWebhook, stored, ok); err != nil {
|
||||
t.Fatalf("正常提交新请求头不应被拦: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Package notify 实现漏洞发现的 IM / 邮件推送渠道适配层。
|
||||
//
|
||||
// 分层:本包是**叶子包**,只依赖标准库。它不认识数据库、不认识 server。渠道配置
|
||||
// 以 map[string]any 传入(对应 notification_channels.config 这一 JSONB 列),
|
||||
// 待推送内容以 Message 传入。这样拆开的好处是:签名计算、UTF-8 截断、过滤匹配这些
|
||||
// 真正容易出错的地方可以脱离 PostgreSQL 单测,宿主只需在 server 侧做编排。
|
||||
//
|
||||
// 并发约定:Channel 的实现必须**无状态**。同一个 Channel 实例会被多个渠道配置
|
||||
// (甚至同一渠道的多个机器人实例)并发复用,所有凭据一律从 cfg 参数传入,
|
||||
// 不允许把 webhook URL 之类的东西缓存进实现自身的字段。
|
||||
package notify
|
||||
|
||||
// 渠道类型标识。取值同时是 notification_channels.kind 的合法集合,由 server 侧
|
||||
// 白名单校验(与 findings.status 同理,不用 DB CHECK,方便后续加渠道)。
|
||||
const (
|
||||
KindDingTalk = "dingtalk" // 钉钉自定义机器人
|
||||
KindFeishu = "feishu" // 飞书(含 Lark)自定义机器人
|
||||
KindWeCom = "wecom" // 企业微信群机器人
|
||||
KindWebhook = "webhook" // 通用 Webhook:自定义方法/头/JSON 模板
|
||||
KindTelegram = "telegram" // Telegram Bot API
|
||||
KindEmail = "email" // SMTP 邮件
|
||||
)
|
||||
|
||||
// 事件类型,对应 notification_events.kind。
|
||||
const (
|
||||
EventFindingCreated = "finding_created"
|
||||
EventFindingStatusChanged = "finding_status_changed"
|
||||
)
|
||||
|
||||
// InitKind 是 config 里为空的 kind 的兜底值。
|
||||
const InitKind = KindDingTalk
|
||||
|
||||
// severityRank 把漏洞级别映射成可比较的序数。未知级别返回 0,因此任何
|
||||
// min_severity 设置都会把未知级别挡在外面——存疑时不推,避免误报刷屏。
|
||||
var severityRank = map[string]int{
|
||||
"low": 1,
|
||||
"medium": 2,
|
||||
"high": 3,
|
||||
"critical": 4,
|
||||
}
|
||||
|
||||
// SeverityRank 返回级别的序数;未知级别返回 0。
|
||||
func SeverityRank(severity string) int { return severityRank[severity] }
|
||||
|
||||
// SeverityLabel 返回带 emoji 的中文级别名,用于消息标题与卡片配色。
|
||||
// 未知级别原样回显,不臆造。
|
||||
func SeverityLabel(severity string) string {
|
||||
switch severity {
|
||||
case "critical":
|
||||
return "🔴 严重"
|
||||
case "high":
|
||||
return "🟠 高危"
|
||||
case "medium":
|
||||
return "🟡 中危"
|
||||
case "low":
|
||||
return "🔵 低危"
|
||||
default:
|
||||
return severity
|
||||
}
|
||||
}
|
||||
|
||||
// StatusLabel 把处置状态翻译成中文,用于状态变更消息。
|
||||
func StatusLabel(status string) string {
|
||||
switch status {
|
||||
case "pending":
|
||||
return "待处理"
|
||||
case "in_progress":
|
||||
return "处理中"
|
||||
case "confirmed":
|
||||
return "已确认"
|
||||
case "resolved":
|
||||
return "已处理"
|
||||
case "fixed":
|
||||
return "已修复"
|
||||
case "false_positive":
|
||||
return "误报"
|
||||
case "ignored":
|
||||
return "忽略"
|
||||
case "duplicate":
|
||||
return "重复"
|
||||
case "risk_accepted":
|
||||
return "风险接受"
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
// AtLeast 判断 severity 是否达到 min 门槛。min 为空表示不设门槛,一律通过。
|
||||
// 注意未知 severity 的序数为 0,会被任何非空 min 拒掉(见 severityRank 注释)。
|
||||
func AtLeast(severity, min string) bool {
|
||||
if min == "" {
|
||||
return true
|
||||
}
|
||||
return SeverityRank(severity) >= SeverityRank(min)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// 本文件覆盖「按整条打包」这个修复:汇总消息超出渠道长度上限时,必须**按整条**
|
||||
// 截断并把没装下的条目数如实报出来,让调用方只标记真正送达的那些。
|
||||
//
|
||||
// 之前的做法是渲染完整篇再截断、然后整批标记已送达:消息后半截凭空消失,
|
||||
// 而投递历史显示全部成功——漏洞就这么没了,且没有任何地方能发现。
|
||||
|
||||
func TestMarkdownBodyPacksWholeItemsWithinByteLimit(t *testing.T) {
|
||||
// 200 条中文汇总,必然远超企微 4096 字节。
|
||||
m := batchMsg(200)
|
||||
body, kept := markdownBody(m, weComMarkdownLimit)
|
||||
|
||||
if len(body) > weComMarkdownLimit {
|
||||
t.Fatalf("正文 %d 字节超上限 %d", len(body), weComMarkdownLimit)
|
||||
}
|
||||
if !utf8.ValidString(body) {
|
||||
t.Fatal("正文不是合法 UTF-8")
|
||||
}
|
||||
if kept <= 0 || kept >= len(m.Items) {
|
||||
t.Fatalf("应只装下一部分(0 < kept < %d),得到 %d", len(m.Items), kept)
|
||||
}
|
||||
// 头部必须如实说明本条只包含多少条、其余有多少条——否则读者会把头部
|
||||
// 那个数字当成全部。
|
||||
if !strings.Contains(body, "其余") || !strings.Contains(body, "下一条消息继续") {
|
||||
t.Fatalf("头部应说明还有多少条未包含在本条里:\n%s", body[:minInt(400, len(body))])
|
||||
}
|
||||
// 只应包含前 kept 条。
|
||||
for i := 0; i < kept; i++ {
|
||||
if !strings.Contains(body, "漏洞"+itoa(i+1)) {
|
||||
t.Fatalf("第 %d 条应在本条消息里:\n%s", i+1, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "漏洞"+itoa(kept+1)) {
|
||||
t.Fatalf("第 %d 条不该出现(它属于下一批)", kept+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownBodyKeepsEverythingWhenUnderLimit(t *testing.T) {
|
||||
m := batchMsg(3)
|
||||
body, kept := markdownBody(m, 0) // 0 = 不限制
|
||||
if kept != len(m.Items) {
|
||||
t.Fatalf("不限制长度时应全部保留,得到 kept=%d", kept)
|
||||
}
|
||||
if strings.Contains(body, "其余") {
|
||||
t.Fatalf("没有截断时不该出现截断提示:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownBodyAlwaysKeepsAtLeastOneItem(t *testing.T) {
|
||||
// 预算小到连一条都装不下时,仍要发出一条(由最终截断兜底)。
|
||||
// 否则一条超长漏洞会把整批永久卡在原地:每次领取都装不下、每次都不发。
|
||||
m := batchMsg(5)
|
||||
_, kept := markdownBody(m, 50)
|
||||
if kept != 1 {
|
||||
t.Fatalf("至少应保留 1 条,得到 %d", kept)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownBodySingleReturnsOne(t *testing.T) {
|
||||
_, kept := markdownBody(singleMsg(), 4096)
|
||||
if kept != 1 {
|
||||
t.Fatalf("单条消息应报送达 1 条,得到 %d", kept)
|
||||
}
|
||||
// 空消息没有可送达的条目。
|
||||
if _, k := markdownBody(Message{}, 4096); k != 0 {
|
||||
t.Fatalf("空消息应报 0 条,得到 %d", k)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramPackingUsesRuneBudget(t *testing.T) {
|
||||
m := batchMsg(200)
|
||||
text, kept := telegramHTML(m)
|
||||
// Telegram 按**字符数**限长;用字节口径会把中文消息压到三分之一。
|
||||
if n := utf8.RuneCountInString(text); n > telegramTextLimit {
|
||||
t.Fatalf("正文 %d 字符超上限 %d", n, telegramTextLimit)
|
||||
}
|
||||
if kept <= 0 || kept >= len(m.Items) {
|
||||
t.Fatalf("应只装下一部分,得到 %d", kept)
|
||||
}
|
||||
if !strings.Contains(text, "下一条继续") {
|
||||
t.Fatalf("应说明还有余量未包含:\n%.300s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeishuPackingReportsKept(t *testing.T) {
|
||||
m := batchMsg(2000)
|
||||
_, kept := feishuCard(m)
|
||||
if kept <= 0 || kept >= len(m.Items) {
|
||||
t.Fatalf("卡片应只装下一部分,得到 %d", kept)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookAndEmailReportAllItems(t *testing.T) {
|
||||
// 这两个渠道不截断正文,整批都算送达。
|
||||
m := batchMsg(7)
|
||||
if n := len(m.Items); n != 7 {
|
||||
t.Fatal("前置条件不成立")
|
||||
}
|
||||
// 通过渲染器的返回值间接确认:markdownBody(0) 不限制时全部保留。
|
||||
if _, k := markdownBody(m, 0); k != len(m.Items) {
|
||||
t.Fatalf("不限制长度时应用全部,得到 %d", k)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkdownEscapesUntrustedContent 是「不可信内容不得改变消息结构」的回归测试。
|
||||
// 标题与摘要来自模型输出(模型读的是被测目标响应),资产名来自被测目标的 URL。
|
||||
func TestMarkdownEscapesUntrustedContent(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
item Item
|
||||
must []string // 结果里必须出现(转义形态)
|
||||
wrong []string // 结果里不得出现(未转义形态)
|
||||
}{
|
||||
{
|
||||
name: "标题里的换行 + 外链",
|
||||
item: Item{
|
||||
Severity: "high",
|
||||
Name: "登录口 SQL 注入\n[紧急:点此验证账号](http://attacker.tld)",
|
||||
},
|
||||
// 换行必须被折叠(否则能伪造出新的列表项/引用块);
|
||||
// 方括号与圆括号必须被转义(否则是可点击的外链)。
|
||||
must: []string{`\[紧急:点此验证账号\]`, `\(http://attacker.tld\)`},
|
||||
wrong: []string{"\n[紧急", "\n\n[紧急"},
|
||||
},
|
||||
{
|
||||
name: "标题里的图片信标",
|
||||
item: Item{
|
||||
Severity: "high",
|
||||
Name: "漏洞 ",
|
||||
},
|
||||
must: []string{`\!`, `\(http://attacker.tld/beacon\)`},
|
||||
wrong: []string{" {
|
||||
m := Message{Items: []Item{tc.item}}
|
||||
// 单条模式的写Item 是三个 markdown 渠道共用的渲染路径。
|
||||
var b strings.Builder
|
||||
writeItem(&b, tc.item, "", true)
|
||||
got := b.String()
|
||||
for _, want := range tc.must {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("缺少转义形态 %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, bad := range tc.wrong {
|
||||
if strings.Contains(got, bad) {
|
||||
t.Errorf("出现了未转义形态 %q(可被用来注入结构或外链):\n%s", bad, got)
|
||||
}
|
||||
}
|
||||
_ = m
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkdownEscapeBackslashFirst 锁住转义顺序:反斜杠必须最先处理,
|
||||
// 否则会给后面补上的反斜杠再套一层,输出里出现双反斜杠。
|
||||
func TestMarkdownEscapeBackslashFirst(t *testing.T) {
|
||||
if got := markdownEscape(`a\b*c`); got != `a\\b\*c` {
|
||||
t.Fatalf("转义顺序有误,得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTelegramTitleHasNoMarkdownEscapes 锁住一个具体的回归:
|
||||
// markdown 转义不能泄漏到 Telegram 的 HTML 输出里(曾经在共享的标题函数里
|
||||
// 加过转义,结果 Telegram 消息里出现 `\(1\)` 这种可见反斜杠)。
|
||||
func TestTelegramTitleHasNoMarkdownEscapes(t *testing.T) {
|
||||
m := Message{Items: []Item{{Severity: "high", Name: "alert(1) *重点*"}}}
|
||||
text, _ := telegramHTML(m)
|
||||
if strings.Contains(text, `\(`) || strings.Contains(text, `\*`) {
|
||||
t.Fatalf("Telegram 正文里出现了 markdown 的反斜杠转义:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件是不变量测试:**任何**从渠道实现里冒出来的错误文本都不得携带凭据。
|
||||
//
|
||||
// 为什么单独拉一个文件:最初的渠道用例只覆盖了成功路径与平台业务错误,
|
||||
// 完全没看传输层失败。而恰恰是传输层错误(连接被拒/DNS 失败/超时)最危险——
|
||||
// http.Client.Do 返回的 *url.Error 会把**完整 URL** 打进错误文本,而本功能
|
||||
// 这几家的凭据就在 URL 里。凭据顺着这条串流到了四个出口:
|
||||
//
|
||||
// notification_deliveries.last_error → 明文落库
|
||||
// GET /api/notify/deliveries 响应 → 绕过渠道配置的掩码,回显给浏览器
|
||||
// 服务端日志 → 常被外发留存
|
||||
// 测试发送接口的 502 响应 → 直接弹在前端
|
||||
//
|
||||
// 所以这里不只测一个函数,而是逐个渠道真发一次必然失败的请求,断言错误文本
|
||||
// 里找不到那个凭据。
|
||||
|
||||
// credentialCases 覆盖所有「凭据在 URL 里」的渠道形态:
|
||||
// 钉钉/企微在 query,飞书在路径末段,Telegram 在路径中段。
|
||||
var credentialCases = []struct {
|
||||
name string
|
||||
ch Channel
|
||||
cfg map[string]any
|
||||
secret string
|
||||
}{
|
||||
{
|
||||
name: "钉钉 access_token 在 query",
|
||||
ch: dingTalkChannel{},
|
||||
cfg: map[string]any{"webhook": "http://127.0.0.1:1/robot/send?access_token=" + leakProbeToken},
|
||||
secret: leakProbeToken,
|
||||
},
|
||||
{
|
||||
name: "企业微信 key 在 query",
|
||||
ch: weComChannel{},
|
||||
cfg: map[string]any{"webhook": "http://127.0.0.1:1/cgi-bin/webhook/send?key=" + leakProbeToken},
|
||||
secret: leakProbeToken,
|
||||
},
|
||||
{
|
||||
name: "飞书 hook id 在路径末段",
|
||||
ch: feishuChannel{},
|
||||
cfg: map[string]any{"webhook": "http://127.0.0.1:1/open-apis/bot/v2/hook/" + leakProbeToken},
|
||||
secret: leakProbeToken,
|
||||
},
|
||||
{
|
||||
name: "Telegram bot token 在路径中段",
|
||||
ch: telegramChannel{},
|
||||
cfg: map[string]any{"bot_token": leakProbeToken, "chat_id": "1", "base_url": "http://127.0.0.1:1"},
|
||||
secret: leakProbeToken,
|
||||
},
|
||||
{
|
||||
name: "钉钉加签密钥",
|
||||
ch: dingTalkChannel{},
|
||||
cfg: map[string]any{"webhook": "http://127.0.0.1:1/robot/send", "secret": leakProbeToken},
|
||||
secret: leakProbeToken,
|
||||
},
|
||||
}
|
||||
|
||||
// leakProbeToken 是一个绝不可能是真实凭据的哨兵值,用于在错误文本里搜它。
|
||||
const leakProbeToken = "LEAKPROBE0123456789abcdef"
|
||||
|
||||
// TestChannelErrorsNeverLeakCredentials 是核心不变量。
|
||||
func TestChannelErrorsNeverLeakCredentials(t *testing.T) {
|
||||
for _, tc := range credentialCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// 必然失败的对端:127.0.0.1:1 无人监听,走的是连接被拒这条路径。
|
||||
_, err := tc.ch.Send(context.Background(), tc.cfg, Message{
|
||||
Items: []Item{{FindingID: 1, Severity: "high", Name: "泄露探针"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("对不可达地址应报错")
|
||||
}
|
||||
assertNoSecret(t, err.Error(), tc.secret)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelErrorsNeverLeakCredentialsInPermanentPath 覆盖永久失败分支:
|
||||
// URL 校验失败、平台业务错误等也会把错误文本外传,同样不能带凭据。
|
||||
func TestChannelErrorsNeverLeakCredentialsInPermanentPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ch Channel
|
||||
cfg map[string]any
|
||||
}{
|
||||
// 地址里带凭据但格式非法 → 触发 validateHTTPURL / url.Parse 分支。
|
||||
{"钉钉地址非法", dingTalkChannel{}, map[string]any{"webhook": "file:///" + leakProbeToken}},
|
||||
{"企微地址非法", weComChannel{}, map[string]any{"webhook": "gopher://" + leakProbeToken}},
|
||||
{"飞书地址非法", feishuChannel{}, map[string]any{"webhook": "ftp://" + leakProbeToken + "/hook"}},
|
||||
{"Telegram API 地址非法", telegramChannel{}, map[string]any{"bot_token": "tok", "chat_id": "1", "base_url": "file://" + leakProbeToken}},
|
||||
{"通用 Webhook 地址非法", webhookChannel{}, map[string]any{"url": "javascript:" + leakProbeToken}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := tc.ch.Send(context.Background(), tc.cfg, Message{Items: []Item{{Severity: "high"}}})
|
||||
if err == nil {
|
||||
t.Fatal("非法配置应报错")
|
||||
}
|
||||
assertNoSecret(t, err.Error(), leakProbeToken)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoSecret(t *testing.T, text, secret string) {
|
||||
t.Helper()
|
||||
if strings.Contains(text, secret) {
|
||||
t.Fatalf("错误文本泄露了凭据 %q:\n %s", secret, text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactRequestTargetKeepsOnlySchemeAndHost(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"https://oapi.dingtalk.com/robot/send?access_token=S1": "https://oapi.dingtalk.com/…",
|
||||
"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=S2": "https://qyapi.weixin.qq.com/…",
|
||||
"https://open.feishu.cn/open-apis/bot/v2/hook/S3": "https://open.feishu.cn/…",
|
||||
"https://api.telegram.org/botS4/sendMessage": "https://api.telegram.org/…",
|
||||
"http://10.0.0.5:8080/hook": "http://10.0.0.5:8080/…",
|
||||
}
|
||||
for in, want := range cases {
|
||||
got := redactRequestTarget(in)
|
||||
if got != want {
|
||||
t.Errorf("redactRequestTarget(%q) = %q,期望 %q", in, got, want)
|
||||
}
|
||||
// 脱敏结果本身不得再含有原地址的任何路径/查询片段。
|
||||
if parts := strings.SplitN(in, "://", 2); len(parts) == 2 {
|
||||
if hostAndRest := strings.SplitN(parts[1], "/", 2); len(hostAndRest) == 2 && hostAndRest[1] != "" {
|
||||
if strings.Contains(got, hostAndRest[1]) {
|
||||
t.Errorf("脱敏后仍含路径/查询片段 %q: %q", hostAndRest[1], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 不可解析的输入绝不回显原串。
|
||||
for _, bad := range []string{"", "://", "not a url", "http://"} {
|
||||
if got := redactRequestTarget(bad); strings.Contains(got, bad) && bad != "" {
|
||||
t.Errorf("不可解析输入 %q 被回显为 %q", bad, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactTransportErrorStripsURL 直接盯住 *url.Error 这个具体类型:
|
||||
// 它是 http.Client.Do 的返回类型,也是泄露的第一现场。
|
||||
func TestRedactTransportErrorStripsURL(t *testing.T) {
|
||||
inner := errors.New("dial tcp 127.0.0.1:1: connect: connection refused")
|
||||
uerr := &url.Error{
|
||||
Op: "Post",
|
||||
URL: "https://api.telegram.org/bot" + leakProbeToken + "/sendMessage",
|
||||
Err: inner,
|
||||
}
|
||||
got := redactTransportError(uerr)
|
||||
assertNoSecret(t, got, leakProbeToken)
|
||||
if !strings.Contains(got, "api.telegram.org") {
|
||||
t.Errorf("应保留 host 以便排查,得到 %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "connection refused") {
|
||||
t.Errorf("应保留底层原因以便排查,得到 %q", got)
|
||||
}
|
||||
// Op 也要保留(POST 还是 GET 对排查有意义)。
|
||||
if !strings.Contains(got, "Post") {
|
||||
t.Errorf("应保留操作名,得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactURLsInTextHandlesFallback 兜底路径:非 *url.Error 的自定义错误
|
||||
// (如重定向策略返回的错误)里的地址同样要被摘掉。
|
||||
func TestRedactURLsInTextHandlesFallback(t *testing.T) {
|
||||
in := fmt.Sprintf("拒绝跨主机重定向(a.example → http://b.example/bot%s/send)", leakProbeToken)
|
||||
got := redactURLsInText(in)
|
||||
assertNoSecret(t, got, leakProbeToken)
|
||||
if !strings.Contains(got, "http://b.example/…") {
|
||||
t.Errorf("应把地址替换为脱敏形态,得到 %q", got)
|
||||
}
|
||||
// 不含地址的文本原样保留。
|
||||
if plain := "dial tcp: connection refused"; redactURLsInText(plain) != plain {
|
||||
t.Error("不含地址的文本不应被改动")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossHostRedirectRefused 覆盖「凭据在 URL 里 + 跟随跨主机跳转 = 交出凭据」。
|
||||
// httptest 的两个服务监听在 127.0.0.1 的不同端口,端口不同即 Host 不同,
|
||||
// 正好构成跨主机跳转。
|
||||
func TestCrossHostRedirectRefused(t *testing.T) {
|
||||
var hit bool
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hit = true
|
||||
_, _ = io.WriteString(w, `{"errcode":0,"errmsg":"ok"}`)
|
||||
}))
|
||||
defer target.Close()
|
||||
|
||||
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, target.URL+"/robot/send?access_token="+leakProbeToken, http.StatusTemporaryRedirect)
|
||||
}))
|
||||
defer redirector.Close()
|
||||
|
||||
_, err := (dingTalkChannel{}).Send(context.Background(),
|
||||
map[string]any{"webhook": redirector.URL + "/robot/send?access_token=" + leakProbeToken},
|
||||
Message{Items: []Item{{Severity: "high"}}})
|
||||
if err == nil {
|
||||
t.Fatal("跨主机重定向应被拒绝")
|
||||
}
|
||||
if hit {
|
||||
t.Fatal("跳转目标被访问了——凭据已随重定向外泄")
|
||||
}
|
||||
assertNoSecret(t, err.Error(), leakProbeToken)
|
||||
}
|
||||
|
||||
// TestSameHostRedirectAllowed 反向用例:同主机跳转(如末尾补斜杠)必须仍然可用,
|
||||
// 否则会把正常工作流一起挡掉。
|
||||
func TestSameHostRedirectAllowed(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/robot/send" {
|
||||
// 同主机、同端口的跳转。
|
||||
http.Redirect(w, r, "/robot/send/", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"errcode":0,"errmsg":"ok"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := (dingTalkChannel{}).Send(context.Background(),
|
||||
map[string]any{"webhook": srv.URL + "/robot/send"},
|
||||
Message{Items: []Item{{Severity: "high"}}}); err != nil {
|
||||
t.Fatalf("同主机重定向不应被拒绝: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const ellipsis = "…"
|
||||
|
||||
// TruncateBytes 把 s 截断到不超过 max 字节,保证结果是合法 UTF-8 且不切断字符。
|
||||
//
|
||||
// 为什么必须按字符边界切:企微群机器人的 markdown 有 4096 **字节**硬上限(不是
|
||||
// 字符数),而中文一个字 3 字节。直接按字节切片会把一个汉字切成两半,产出非法
|
||||
// UTF-8——平台侧要么整条拒收,要么显示成乱码方块。这里的做法是先从预算位置
|
||||
// 往前回退到最近的 rune 起始字节(utf8.RuneStart 判定续字节 0b10xxxxxx)。
|
||||
//
|
||||
// max<=0 表示不限制。截断后追加省略号,除非 max 小到装不下省略号。
|
||||
func TruncateBytes(s string, max int) string {
|
||||
if max <= 0 || len(s) <= max {
|
||||
return s
|
||||
}
|
||||
budget := max - len(ellipsis)
|
||||
suffix := ellipsis
|
||||
if budget < 0 {
|
||||
// max 比省略号还短:放弃省略号,纯截断,避免结果反而超出 max。
|
||||
budget = max
|
||||
suffix = ""
|
||||
}
|
||||
cut := budget
|
||||
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||
cut--
|
||||
}
|
||||
return s[:cut] + suffix
|
||||
}
|
||||
|
||||
// OneLine 把多行文本压成单行:折叠所有空白,再按字符数截断。
|
||||
// 用于 IM 消息的标题行——摘要里常有换行,直接塞进表格/标题会撑坏排版。
|
||||
// max<=0 表示不限制长度。
|
||||
func OneLine(s string, max int) string {
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
return TruncateRunes(s, max)
|
||||
}
|
||||
|
||||
// TruncateRunes 把 s 截断到不超过 max 个字符(而非字节),超出时追加省略号。
|
||||
// max<=0 表示不限制。
|
||||
//
|
||||
// 与 TruncateBytes 的区别在于平台口径:企微按字节限长,Telegram 按字符数限长。
|
||||
// 用错口径不会报错,只会让消息被切得远比预期短(中文 1 字 = 3 字节,
|
||||
// 按字节切 4096 只剩约 1365 字),所以两个函数都必须保留、按渠道选用。
|
||||
func TruncateRunes(s string, max int) string {
|
||||
if max <= 0 {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= max {
|
||||
return s
|
||||
}
|
||||
if max <= 1 {
|
||||
return string(runes[:max])
|
||||
}
|
||||
return string(runes[:max-1]) + ellipsis
|
||||
}
|
||||
|
||||
// TruncateHTML 按字符数截断 HTML 片段,并保证不产生半截标签。
|
||||
//
|
||||
// 直接对 HTML 做字符截断会切出 `<a href="htt` 这种残缺标签,平台解析器要么
|
||||
// 报错拒收整条、要么把后续正文当成属性值吞掉。这里的做法是:先按字符截断,
|
||||
// 再检查尾部是否有未闭合的 `<`,有就退到它之前。
|
||||
//
|
||||
// 不做标签配平(补全 </b> 之类):Telegram 的 HTML 解析器会自动闭合未闭合标签,
|
||||
// 而自己实现配平要处理属性里的引号、注释、自闭合标签,复杂度与收益不成比例。
|
||||
func TruncateHTML(s string, max int) string {
|
||||
if max <= 0 || len([]rune(s)) <= max {
|
||||
return s
|
||||
}
|
||||
cut := TruncateRunes(s, max)
|
||||
// 尾部若是 `<` 开头的残片(最后出现 `<` 之后没有 `>`),退回 `<` 之前。
|
||||
if lt := strings.LastIndex(cut, "<"); lt >= 0 && !strings.Contains(cut[lt:], ">") {
|
||||
cut = cut[:lt]
|
||||
}
|
||||
// 尾部若是被切断的 HTML 实体(如 `&` 被切成 `&`),同样要退回去。
|
||||
// 实体残片在一个只认实体的解析器里可能让**整条消息**被拒收——一条超过
|
||||
// 长度上限的汇总消息本来就常见,不值得为此丢掉整条通知。
|
||||
if amp := strings.LastIndex(cut, "&"); amp >= 0 && !strings.Contains(cut[amp:], ";") {
|
||||
cut = cut[:amp]
|
||||
}
|
||||
return cut
|
||||
}
|
||||
|
||||
// packItemCount 计算在预算内能**完整**放下多少条,供汇总消息按整条打包。
|
||||
//
|
||||
// 为什么要按整条而不是渲染完整篇再截断:截断会让后半截条目凭空消失,
|
||||
// 而它们的投递记录仍会被标记为已送达——消息里看不出来、投递历史里也看不出来,
|
||||
// 漏洞就这么没了。按整条打包后,装不下的条目留在库里成为下一批,
|
||||
// 调用方拿到的 kept 就是本条消息真正送达的条数。
|
||||
//
|
||||
// 参数:maxSize<=0 表示不限制;reserve 是给消息头部/尾部预留的量;
|
||||
// size 负责计量(各平台口径不同:企微/钉钉按字节,Telegram 按字符数——
|
||||
// 用错口径不会报错,只会让中文消息被压到远小于上限);
|
||||
// render 把第 idx 条渲染成它的实际文本——长度因内容而异,不能靠估算。
|
||||
//
|
||||
// 至少返回 1(只要还有条目)。单条极端超长时也要发出这一条、由调用方的
|
||||
// 最终截断兜底,否则一条超长漏洞会把整批永久卡在原地。
|
||||
func packItemCount(items []Item, maxSize, reserve int, footer string, size func(string) int, render func(Item, int) string) int {
|
||||
if maxSize <= 0 {
|
||||
return len(items)
|
||||
}
|
||||
budget := maxSize - reserve - size(footer)
|
||||
if budget < 0 {
|
||||
budget = 0
|
||||
}
|
||||
used := 0
|
||||
for i, it := range items {
|
||||
used += size(render(it, i))
|
||||
if used > budget && i > 0 {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(items)
|
||||
}
|
||||
|
||||
// byteSize / runeSize 是 packItemCount 的两种计量口径,命名出来避免调用处
|
||||
// 出现裸的 func(s string) int 闭包,否则很难一眼看出用的是哪种口径。
|
||||
func byteSize(s string) int { return len(s) }
|
||||
func runeSize(s string) int { return utf8.RuneCountInString(s) }
|
||||
|
||||
// assetLine 把资产列表渲染成一行展示文本,超过 limit 个时省略其余并标注总数。
|
||||
// 一个漏洞可能锚定几十个资产,全列出来会挤爆消息。
|
||||
func assetLine(assets []string, limit int) string {
|
||||
if len(assets) == 0 {
|
||||
return ""
|
||||
}
|
||||
if limit <= 0 || len(assets) <= limit {
|
||||
return strings.Join(assets, "、")
|
||||
}
|
||||
return strings.Join(assets[:limit], "、") + " 等 " + itoa(len(assets)) + " 个"
|
||||
}
|
||||
|
||||
// itoa 是 strconv.Itoa 的短别名,仅用于拼接展示文本,避免到处 import strconv。
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestTruncateBytesKeepsValidUTF8(t *testing.T) {
|
||||
// 这是本包最要紧的一条不变量。企微按**字节**限长,中文 3 字节/字,
|
||||
// 任何按字节硬切的实现都会把汉字切成半个,产出非法 UTF-8 而被平台拒收。
|
||||
// 用长度互质的多种中英混排输入去撞每一个可能的切点。
|
||||
inputs := []string{
|
||||
"中文测试内容",
|
||||
"混合 mixed 内容 content",
|
||||
"a中b文c测d试e",
|
||||
"🔴🟠🟡🔵", // 4 字节 emoji,切错更明显
|
||||
strings.Repeat("漏洞", 100),
|
||||
}
|
||||
for _, in := range inputs {
|
||||
for max := 1; max <= len(in)+2; max++ {
|
||||
got := TruncateBytes(in, max)
|
||||
if !utf8.ValidString(got) {
|
||||
t.Fatalf("输入 %q max=%d: 产出非法 UTF-8 %q", in, max, got)
|
||||
}
|
||||
if len(got) > max {
|
||||
t.Fatalf("输入 %q max=%d: 结果 %d 字节超出上限", in, max, len(got))
|
||||
}
|
||||
// 未被截断时不得改动内容。
|
||||
if len(in) <= max && got != in {
|
||||
t.Fatalf("输入 %q max=%d: 未超限却改动了内容 -> %q", in, max, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateBytesZeroMeansUnlimited(t *testing.T) {
|
||||
long := strings.Repeat("x", 10000)
|
||||
if got := TruncateBytes(long, 0); got != long {
|
||||
t.Fatal("max=0 应表示不限制")
|
||||
}
|
||||
if got := TruncateBytes(long, -5); got != long {
|
||||
t.Fatal("max<0 应表示不限制")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateBytesEllipsisBudget(t *testing.T) {
|
||||
// max 小于省略号本身时,不能因为追加省略号而反过来超限。
|
||||
got := TruncateBytes("abcdefgh", 1)
|
||||
if len(got) > 1 {
|
||||
t.Fatalf("max=1 时结果 %q 长度 %d 超限", got, len(got))
|
||||
}
|
||||
// 正常情况应带省略号。
|
||||
if got := TruncateBytes("abcdefgh", 5); !strings.HasSuffix(got, ellipsis) {
|
||||
t.Fatalf("期望带省略号,得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateRunesCountsCharactersNotBytes(t *testing.T) {
|
||||
// 与 TruncateBytes 的口径差异必须保留:Telegram 按字符限长,
|
||||
// 用字节口径会把中文消息切到只剩三分之一。
|
||||
s := "一二三四五六七八九十"
|
||||
got := TruncateRunes(s, 5)
|
||||
if n := utf8.RuneCountInString(got); n != 5 {
|
||||
t.Fatalf("期望 5 个字符,得到 %d 个 (%q)", n, got)
|
||||
}
|
||||
// 同样的字符串按字节口径应明显更短。
|
||||
if utf8.RuneCountInString(TruncateBytes(s, 5)) >= 5 {
|
||||
t.Fatal("字节口径不应产出与字符口径相同的字符数")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneLineCollapsesWhitespace(t *testing.T) {
|
||||
got := OneLine("第一行\n\n第二行\t带制表 多空格", 0)
|
||||
if strings.ContainsAny(got, "\n\t") {
|
||||
t.Fatalf("应折叠所有空白,得到 %q", got)
|
||||
}
|
||||
if strings.Contains(got, " ") {
|
||||
t.Fatalf("不应保留连续空格,得到 %q", got)
|
||||
}
|
||||
// 截断后仍须可读且合法。
|
||||
got = OneLine("一二三四五六七八九十", 4)
|
||||
if n := utf8.RuneCountInString(got); n != 4 {
|
||||
t.Fatalf("期望 4 字符,得到 %d (%q)", n, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateHTMLNeverCutsTagInHalf(t *testing.T) {
|
||||
// 直接截断 HTML 会切出 `<a href="htt` 这种残片,平台会拒收整条消息。
|
||||
s := `<b>标题</b>正文正文正文<a href="https://example.com/very/long/path">查看详情</a>`
|
||||
for max := 1; max <= utf8.RuneCountInString(s)+2; max++ {
|
||||
got := TruncateHTML(s, max)
|
||||
if n := utf8.RuneCountInString(got); max > 0 && n > max {
|
||||
t.Fatalf("max=%d: 结果 %d 字符超限", max, n)
|
||||
}
|
||||
// 尾部不能有未闭合的 `<`(即最后一段里出现 `<` 却无 `>`)。
|
||||
if lt := strings.LastIndex(got, "<"); lt >= 0 && !strings.Contains(got[lt:], ">") {
|
||||
t.Fatalf("max=%d: 尾部标签被切断 -> %q", max, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetLineOmitsExcess(t *testing.T) {
|
||||
if got := assetLine(nil, 3); got != "" {
|
||||
t.Fatalf("无资产应返回空串,得到 %q", got)
|
||||
}
|
||||
if got := assetLine([]string{"a", "b"}, 3); got != "a、b" {
|
||||
t.Fatalf("未超限应全列,得到 %q", got)
|
||||
}
|
||||
// 超出上限时必须标注总数,否则读者不知道还有多少资产没列出来。
|
||||
got := assetLine([]string{"a", "b", "c", "d", "e"}, 2)
|
||||
if !strings.Contains(got, "等 5 个") {
|
||||
t.Fatalf("应标注总数 5,得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeverityAndStatusLabels(t *testing.T) {
|
||||
if AtLeast("", "low") {
|
||||
t.Fatal("空级别序数为 0,应被任何门槛挡住")
|
||||
}
|
||||
if !AtLeast("critical", "") {
|
||||
t.Fatal("空门槛应放行")
|
||||
}
|
||||
if got := StatusLabel("fixed"); got != "已修复" {
|
||||
t.Fatalf("未知状态映射,得到 %q", got)
|
||||
}
|
||||
// 未知状态原样回显,不臆造标签。
|
||||
if got := StatusLabel("weird_status"); got != "weird_status" {
|
||||
t.Fatalf("未知状态应原样回显,得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTruncateHTMLNeverCutsEntity 覆盖审计指出的一处遗漏:截断不只要避开
|
||||
// 半截标签,还要避开被切断的 HTML 实体。
|
||||
//
|
||||
// `&` 被切成 `&` 之后,一个只认实体的解析器可能拒收**整条**消息——
|
||||
// 而超长汇总消息本来就常见,代价太大。
|
||||
func TestTruncateHTMLNeverCutsEntity(t *testing.T) {
|
||||
s := "aaaa&bbbb<cccc"dddd"
|
||||
for max := 1; max <= utf8.RuneCountInString(s)+2; max++ {
|
||||
got := TruncateHTML(s, max)
|
||||
// 尾部不得出现「有 & 但没有对应 ;」的实体残片。
|
||||
if amp := strings.LastIndex(got, "&"); amp >= 0 && !strings.Contains(got[amp:], ";") {
|
||||
t.Fatalf("max=%d: 尾部留下实体残片 %q", max, got[amp:])
|
||||
}
|
||||
if strings.Contains(got, "&\x00") {
|
||||
t.Fatalf("max=%d: 出现畸形实体", max)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 签名基准值由 OpenSSL 独立算出,不是用本包自己的实现生成的——
|
||||
// 否则只能证明「代码没变」,证明不了「算法对」。
|
||||
//
|
||||
// TS=1700000000000, SECRET=SECtest123
|
||||
// 钉钉: printf '%s\n%s' "$TS" "$SECRET" | openssl dgst -sha256 -hmac "$SECRET" -binary | openssl base64 -A
|
||||
// -> w3RMHXzixTMdzr8OHJUmVLS4IoPJVdu+Ut1LE48MePE=
|
||||
// 飞书: printf '' | openssl dgst -sha256 -hmac "$(printf '%s\n%s' "$TS" "$SECRET")" -binary | openssl base64 -A
|
||||
// -> Hd4xFWQU6R6ad4nzy4ETIznzlqebqH7xcTFVmONTudo=
|
||||
const (
|
||||
signTestTSMillis = int64(1700000000000)
|
||||
signTestSecret = "SECtest123"
|
||||
dingTalkExpected = "w3RMHXzixTMdzr8OHJUmVLS4IoPJVdu+Ut1LE48MePE="
|
||||
feishuExpected = "Hd4xFWQU6R6ad4nzy4ETIznzlqebqH7xcTFVmONTudo="
|
||||
)
|
||||
|
||||
func TestDingTalkSignMatchesReference(t *testing.T) {
|
||||
got, err := dingTalkSignedURL("https://oapi.dingtalk.com/robot/send?access_token=tok", signTestSecret, time.UnixMilli(signTestTSMillis))
|
||||
if err != nil {
|
||||
t.Fatalf("签名失败: %v", err)
|
||||
}
|
||||
u, err := url.Parse(got)
|
||||
if err != nil {
|
||||
t.Fatalf("产出地址不可解析: %v", err)
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("sign") != dingTalkExpected {
|
||||
t.Errorf("签名不符\n期望 %s\n得到 %s", dingTalkExpected, q.Get("sign"))
|
||||
}
|
||||
if q.Get("timestamp") != "1700000000000" {
|
||||
t.Errorf("时间戳应为毫秒且原样带上,得到 %q", q.Get("timestamp"))
|
||||
}
|
||||
// 原有 query 参数(access_token)不能被签名覆盖掉。
|
||||
if q.Get("access_token") != "tok" {
|
||||
t.Errorf("原有 query 参数丢失,得到 %q", q.Get("access_token"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeishuSignMatchesReference(t *testing.T) {
|
||||
got := feishuSign("1700000000000", signTestSecret)
|
||||
if got != feishuExpected {
|
||||
t.Errorf("签名不符\n期望 %s\n得到 %s", feishuExpected, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignAlgorithmsDiffer 锁住两家的算法差异。它们刚好互为对方的参数顺序
|
||||
// (钉钉 key=secret,飞书 key=待签串),照另一家抄必然校验失败,
|
||||
// 这条用例确保将来重构不会把两者合并成同一个函数。
|
||||
func TestSignAlgorithmsDiffer(t *testing.T) {
|
||||
ts := "1700000000000"
|
||||
dingURL, err := dingTalkSignedURL("https://example.com/hook", signTestSecret, time.UnixMilli(signTestTSMillis))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dq, _ := url.Parse(dingURL)
|
||||
if dq.Query().Get("sign") == feishuSign(ts, signTestSecret) {
|
||||
t.Fatal("钉钉与飞书签名相同,说明其中一家的算法实现错了")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDingTalkNoSecretLeavesURLUntouched(t *testing.T) {
|
||||
// 未开启加签的机器人:不得凭空添加 timestamp/sign 参数。
|
||||
const hook = "https://oapi.dingtalk.com/robot/send?access_token=tok"
|
||||
got, err := dingTalkSignedURL(hook, "", time.UnixMilli(signTestTSMillis))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != hook {
|
||||
t.Fatalf("未配置 secret 时地址不应改动,得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateHTTPURL(t *testing.T) {
|
||||
ok := []string{"https://example.com/hook", "http://10.0.0.1:8080/x?y=1"}
|
||||
for _, s := range ok {
|
||||
if err := validateHTTPURL(s); err != nil {
|
||||
t.Errorf("%q 应被接受: %v", s, err)
|
||||
}
|
||||
}
|
||||
// file:// 之类不应放行——http.Client 对它们的处理超出预期范围。
|
||||
bad := []string{"", "file:///etc/passwd", "ftp://example.com", "https://", "gopher://x"}
|
||||
for _, s := range bad {
|
||||
if err := validateHTTPURL(s); err == nil {
|
||||
t.Errorf("%q 应被拒绝", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件覆盖两条相关的加固:
|
||||
// ① 投递地址不得把服务端当跳板打内网 / 云元数据(SSRF)
|
||||
// ② 地址校验的错误信息不得带出地址里的凭据
|
||||
//
|
||||
// 关于测试环境:本包大量用例用 127.0.0.1 上的 httptest 假接收端,守卫默认会拦下
|
||||
// 它们。所以 TestMain 里统一打开 AllowLocalTargetsEnv,而下面每个 SSRF 用例都
|
||||
// 显式把它清掉,以断言**默认拒绝**的行为。
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// 让常规用例能连本地的假接收端;SSRF 用例会自己临时清空。
|
||||
_ = os.Setenv(AllowLocalTargetsEnv, "1")
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// TestDialGuardRejectsLoopbackByDefault 是 SSRF 防护的核心断言:
|
||||
// 默认配置下,投递到环回地址必须被**连接层**拒绝。
|
||||
func TestDialGuardRejectsLoopbackByDefault(t *testing.T) {
|
||||
var hit bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hit = true
|
||||
_, _ = io.WriteString(w, `{"errcode":0}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
t.Setenv(AllowLocalTargetsEnv, "") // 关掉逃生口 = 默认行为
|
||||
_, err := (dingTalkChannel{}).Send(context.Background(),
|
||||
map[string]any{"webhook": srv.URL + "/robot/send"}, Message{Items: []Item{{Severity: "high"}}})
|
||||
if err == nil {
|
||||
t.Fatal("默认不应允许投递到环回地址")
|
||||
}
|
||||
if hit {
|
||||
t.Fatal("请求已经打到了本机服务——守卫没生效")
|
||||
}
|
||||
// 错误信息要能指导用户怎么放开(本机 SMTP 中继是合法配置)。
|
||||
if !strings.Contains(err.Error(), AllowLocalTargetsEnv) {
|
||||
t.Errorf("拒绝信息应说明如何显式放开: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDialGuardAllowsLoopbackWhenOptedIn 反向用例:显式打开后必须能用,
|
||||
// 否则本机 postfix / 内网中继这类合法部署会被一刀切废掉。
|
||||
func TestDialGuardAllowsLoopbackWhenOptedIn(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"errcode":0,"errmsg":"ok"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
t.Setenv(AllowLocalTargetsEnv, "1")
|
||||
if _, err := (dingTalkChannel{}).Send(context.Background(),
|
||||
map[string]any{"webhook": srv.URL + "/robot/send"}, Message{Items: []Item{{Severity: "high"}}}); err != nil {
|
||||
t.Fatalf("显式放开后应可投递: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBlockedDialIP(t *testing.T) {
|
||||
blocked := []string{
|
||||
"127.0.0.1", "127.1.2.3", "::1",
|
||||
"169.254.169.254", // 云元数据端点——本函数存在的主要理由
|
||||
"169.254.1.1", "fe80::1",
|
||||
"0.0.0.0", "::",
|
||||
"224.0.0.1", "ff02::1",
|
||||
"::ffff:127.0.0.1", // IPv4-mapped 形式必须还原后再判,否则是绕过口
|
||||
"",
|
||||
}
|
||||
for _, s := range blocked {
|
||||
if !isBlockedDialIP(net.ParseIP(s)) {
|
||||
t.Errorf("%s 应被拒绝", s)
|
||||
}
|
||||
}
|
||||
// RFC1918 私网**刻意放行**:内网自建 Mattermost / SMTP 中继是常见合法用法。
|
||||
// 这条断言把这个取舍固定下来——若将来有人顺手加上私网判断,这里会失败,
|
||||
// 从而逼出一次有意识的决定(而不是静默废掉一批部署)。
|
||||
allowed := []string{"10.0.0.5", "172.16.3.4", "192.168.1.10", "8.8.8.8", "2606:4700::1111"}
|
||||
for _, s := range allowed {
|
||||
if isBlockedDialIP(net.ParseIP(s)) {
|
||||
t.Errorf("%s 应被放行(私网是常见的合法投递目标)", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateHTTPURLRejectsLiteralPrivateTargets 覆盖配置阶段的前置提示:
|
||||
// 字面 IP 在保存时就该被拒,而不是等第一次投递失败。
|
||||
func TestValidateHTTPURLRejectsLiteralPrivateTargets(t *testing.T) {
|
||||
t.Setenv(AllowLocalTargetsEnv, "")
|
||||
for _, raw := range []string{
|
||||
"http://127.0.0.1:8080/hook",
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://[::1]:8080/hook",
|
||||
} {
|
||||
if err := validateHTTPURL(raw); err == nil {
|
||||
t.Errorf("%s 应在配置阶段被拒绝", raw)
|
||||
}
|
||||
}
|
||||
// 公网地址与私网地址照常通过(私网留给拨号阶段,那里不拦)。
|
||||
for _, raw := range []string{"https://oapi.dingtalk.com/robot/send", "http://10.0.0.9/hook"} {
|
||||
if err := validateHTTPURL(raw); err != nil {
|
||||
t.Errorf("%s 应通过校验: %v", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateHTTPURLErrorNeverLeaksCredentials 是审计指出我上一轮遗漏的分支。
|
||||
//
|
||||
// url.Parse **失败**时会返回 *url.Error,其 Error() 含完整原始地址。上一轮我只
|
||||
// 脱敏了 http.Client.Do 的返回错误,漏了这里;而当时补的「永久失败路径」用例
|
||||
// (file://、gopher://、ftp://)其实都能被 url.Parse 解析成功、走的是 scheme 分支,
|
||||
// 所以全绿也证明不了这条路径安全——是假保证。
|
||||
func TestValidateHTTPURLErrorNeverLeaksCredentials(t *testing.T) {
|
||||
cases := []string{
|
||||
"http://127.0.0.1/%zz?access_token=" + leakProbeToken, // 非法百分号转义
|
||||
"https://a.example.com:port/x?access_token=" + leakProbeToken, // 端口非数字
|
||||
"http://[::1?access_token=" + leakProbeToken, // 括号不配对
|
||||
}
|
||||
for _, raw := range cases {
|
||||
// 先确认这个输入**确实**让 url.Parse 失败。不做这一步的话,用例可能在
|
||||
// 毫无察觉的情况下走到别的分支(上一轮的假保证就是这么来的)。
|
||||
if _, err := url.Parse(raw); err == nil {
|
||||
t.Errorf("%q 本应解析失败,否则这条用例没有覆盖到目标分支", raw)
|
||||
continue
|
||||
}
|
||||
err := validateHTTPURL(raw)
|
||||
if err == nil {
|
||||
t.Errorf("%q 应校验失败", raw)
|
||||
continue
|
||||
}
|
||||
assertNoSecret(t, err.Error(), leakProbeToken)
|
||||
}
|
||||
// 确认渠道层的包装也没有把地址带出去。
|
||||
t.Setenv(AllowLocalTargetsEnv, "")
|
||||
err := (dingTalkChannel{}).Validate(map[string]any{"webhook": cases[0]})
|
||||
if err == nil {
|
||||
t.Fatal("非法地址应校验失败")
|
||||
}
|
||||
assertNoSecret(t, err.Error(), leakProbeToken)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// telegramTextLimit 是 Telegram sendMessage 的 text 字段上限(字符数)。
|
||||
const telegramTextLimit = 4096
|
||||
|
||||
// telegramChannel 实现 Telegram Bot API。
|
||||
//
|
||||
// 平台特性:
|
||||
// - 鉴权全部在 URL path 里(/bot<token>/sendMessage),无需加签。
|
||||
// - 用 HTML 解析模式而不是 MarkdownV2:MarkdownV2 要求转义 `_*[]()~`>#+-=|{}.!`
|
||||
// 共 18 个字符,漏一个就整条消息被拒;HTML 只需转义 & < > 三个。
|
||||
// - 业务错误同样藏在 HTTP 200 里,靠 ok 字段判断。
|
||||
type telegramChannel struct{}
|
||||
|
||||
func (telegramChannel) Kind() string { return KindTelegram }
|
||||
|
||||
// Telegram 单聊约 1 条/秒、群组 20 条/分钟。取保守值。
|
||||
func (telegramChannel) DefaultRatePerMin() int { return 20 }
|
||||
|
||||
// Bot Token 是完整凭据;chat_id 只是收件人,不算秘密(拿到它没有 Token 也发不了消息)。
|
||||
func (telegramChannel) SecretKeys() []string { return []string{"bot_token"} }
|
||||
|
||||
// base_url 决定 Token 被发往哪个 API 端点(如自建反代),改它必须重新表态 Token。
|
||||
func (telegramChannel) DestinationKeys() []string { return []string{"base_url"} }
|
||||
|
||||
func (telegramChannel) Validate(cfg map[string]any) error {
|
||||
if cfgString(cfg, "bot_token") == "" {
|
||||
return errors.New("缺少 Bot Token")
|
||||
}
|
||||
if cfgString(cfg, "chat_id") == "" {
|
||||
return errors.New("缺少 Chat ID")
|
||||
}
|
||||
if base := cfgString(cfg, "base_url"); base != "" {
|
||||
if err := validateHTTPURL(base); err != nil {
|
||||
return fmt.Errorf("API 地址无效: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c telegramChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) {
|
||||
if err := c.Validate(cfg); err != nil {
|
||||
return 0, Permanent(err)
|
||||
}
|
||||
endpoint, err := telegramEndpoint(cfg)
|
||||
if err != nil {
|
||||
return 0, Permanent(err)
|
||||
}
|
||||
text, kept := telegramHTML(m)
|
||||
payload := map[string]any{
|
||||
"chat_id": cfgString(cfg, "chat_id"),
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": false,
|
||||
}
|
||||
raw, err := doJSON(ctx, "POST", endpoint, nil, payload)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var res struct {
|
||||
OK bool `json:"ok"`
|
||||
ErrorCode int `json:"error_code"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
return 0, fmt.Errorf("解析 Telegram 响应失败: %w (%s)", err, snippet(raw))
|
||||
}
|
||||
if res.OK {
|
||||
return kept, nil
|
||||
}
|
||||
// 429 是限流,退避后重试有效;其余(400 参数错、401 token 错、403 被拉黑、
|
||||
// 404 chat 不存在)都是配置问题,重试不会自愈。
|
||||
if res.ErrorCode == 429 {
|
||||
return 0, fmt.Errorf("Telegram 限流: %s", res.Description)
|
||||
}
|
||||
return 0, Permanent(fmt.Errorf("Telegram 返回错误 %d: %s", res.ErrorCode, res.Description))
|
||||
}
|
||||
|
||||
// telegramEndpoint 拼出 sendMessage 地址。base_url 留空时用官方 API,
|
||||
// 非空时用于自建 Bot API 反代(国内网络下的常见需求)。
|
||||
func telegramEndpoint(cfg map[string]any) (string, error) {
|
||||
base := cfgString(cfg, "base_url")
|
||||
if base == "" {
|
||||
base = "https://api.telegram.org"
|
||||
}
|
||||
base = strings.TrimSuffix(base, "/")
|
||||
token := cfgString(cfg, "bot_token")
|
||||
raw := base + "/bot" + token + "/sendMessage"
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
// 不透传 err:地址里含 Bot Token,且此时连 addr 都不该回显。
|
||||
return "", fmt.Errorf("拼接 API 地址失败(API 地址:%s)", redactRequestTarget(base))
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// telegramHTML 渲染 HTML 正文,返回正文与实际写入的条目数(见 Channel.Send)。
|
||||
func telegramHTML(m Message) (string, int) {
|
||||
var b strings.Builder
|
||||
b.WriteString("<b>" + telegramEscape(markdownTitle(m)) + "</b>\n")
|
||||
if m.Batch {
|
||||
// Telegram 的上限是**字符数**,所以打包也按字符计量(runeSize)。
|
||||
footer := ""
|
||||
if m.HomeURL != "" {
|
||||
footer = fmt.Sprintf("\n\n<a href=\"%s\">在平台中查看全部</a>", telegramEscapeAttr(m.HomeURL))
|
||||
}
|
||||
kept := packItemCount(m.Items, telegramTextLimit, telegramReservedRunes, footer, runeSize, func(it Item, idx int) string {
|
||||
return telegramBatchLine(it, idx+1)
|
||||
})
|
||||
items := m.Items[:kept]
|
||||
b.Reset()
|
||||
b.WriteString("<b>" + telegramEscape(telegramBatchTitle(m, items, len(m.Items))) + "</b>")
|
||||
for i, it := range items {
|
||||
b.WriteString("\n" + telegramEscape(telegramBatchLine(it, i+1)))
|
||||
}
|
||||
b.WriteString(footer)
|
||||
return TruncateHTML(b.String(), telegramTextLimit), kept
|
||||
}
|
||||
if len(m.Items) == 0 {
|
||||
return b.String(), 0
|
||||
}
|
||||
it := m.Items[0]
|
||||
if it.IsStatusChange() {
|
||||
b.WriteString(fmt.Sprintf("\n<b>状态变更</b>:%s → %s",
|
||||
telegramEscape(StatusLabel(it.FromStatus)), telegramEscape(StatusLabel(it.ToStatus))))
|
||||
}
|
||||
if it.VulnClass != "" && it.VulnClass != it.Title() {
|
||||
b.WriteString("\n<b>类型</b>:" + telegramEscape(it.VulnClass))
|
||||
}
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
b.WriteString("\n<b>资产</b>:" + telegramEscape(a))
|
||||
}
|
||||
if s := OneLine(it.Summary, maxSummaryRunes); s != "" {
|
||||
b.WriteString("\n<b>摘要</b>:" + telegramEscape(s))
|
||||
}
|
||||
if it.DetailURL != "" {
|
||||
b.WriteString(fmt.Sprintf("\n\n<a href=\"%s\">查看详情</a>", telegramEscapeAttr(it.DetailURL)))
|
||||
}
|
||||
return TruncateHTML(b.String(), telegramTextLimit), 1
|
||||
}
|
||||
|
||||
// telegramReservedRunes 预留给消息标题与可能出现的截断提示(按字符计)。
|
||||
const telegramReservedRunes = 160
|
||||
|
||||
// telegramBatchLine 渲染汇总里的一条(未转义,由调用方统一转义)。
|
||||
func telegramBatchLine(it Item, idx int) string {
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
return fmt.Sprintf("%d. %s · %s — %s", idx, SeverityLabel(it.Severity), it.Title(), a)
|
||||
}
|
||||
return fmt.Sprintf("%d. %s · %s", idx, SeverityLabel(it.Severity), it.Title())
|
||||
}
|
||||
|
||||
// telegramBatchTitle 渲染汇总消息的标题行。条数用的是**本条实际包含**的条数,
|
||||
// 而不是本批总数——否则读者会以为消息头写的数字就是全部。
|
||||
func telegramBatchTitle(m Message, items []Item, total int) string {
|
||||
title := fmt.Sprintf("漏洞汇总 · 共 %d 条", total)
|
||||
if extra := total - len(items); extra > 0 {
|
||||
title += fmt.Sprintf("(显示前 %d 条,其余 %d 条下一条继续)", len(items), extra)
|
||||
}
|
||||
if m.WindowMinutes > 0 {
|
||||
title = fmt.Sprintf("近 %d 分钟 · %s", m.WindowMinutes, title)
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
// telegramEscape 转义 HTML 文本内容。
|
||||
// Telegram 只认这三种实体,转义后 & 之类的已有实体会被二次转义——这正是
|
||||
// 期望行为:我们要显示的是原始字符,不是让用户注入 HTML。
|
||||
func telegramEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
return s
|
||||
}
|
||||
|
||||
// telegramEscapeAttr 转义 HTML 属性值。在文本转义之外还要处理引号——
|
||||
// URL 里带引号会提前闭合 href 属性,把后面的内容变成注入点。
|
||||
func telegramEscapeAttr(s string) string {
|
||||
s = telegramEscape(s)
|
||||
s = strings.ReplaceAll(s, "\"", """)
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
// webhookChannel 是通用 Webhook 适配器:用户自定 URL、方法、请求头与 JSON 模板。
|
||||
// 它的存在让本功能不必为 Slack / Mattermost / Discord / 自建系统各写一个实现——
|
||||
// 那些平台都能被一个可配模板覆盖。
|
||||
type webhookChannel struct{}
|
||||
|
||||
func (webhookChannel) Kind() string { return KindWebhook }
|
||||
|
||||
// 通用 Webhook 没有官方限制,返回 0 表示默认不限流,由使用者按对端能力自定。
|
||||
func (webhookChannel) DefaultRatePerMin() int { return 0 }
|
||||
|
||||
// 掩码 url 与 headers:目标地址本身常带 token,自定义头里通常放着鉴权凭据,
|
||||
// 两者都会出现在接口回显里,所以都要挡。
|
||||
// 代价是编辑时若想改动其中一个头,需要重新填整组头(掩码值会被解释为「保持原值」)——
|
||||
// 这个取舍是刻意的:宁可多填一次,也不把凭据回显到浏览器。
|
||||
func (webhookChannel) SecretKeys() []string { return []string{"url", "headers"} }
|
||||
|
||||
// 目的地是 url。改 url 时必须重新表态 headers —— 否则原始 Authorization 头
|
||||
// 会被原样发到新地址,这正是掩码绕过的主路径。
|
||||
func (webhookChannel) DestinationKeys() []string { return []string{"url"} }
|
||||
|
||||
// webhookDefaultTemplate 是未填模板时的兜底请求体:一个直白的 JSON 结构,
|
||||
// 覆盖绝大多数「收一条 JSON 入库」的自建接收端。
|
||||
const webhookDefaultTemplate = `{
|
||||
"title": {{json .Title}},
|
||||
"batch": {{.Batch}},
|
||||
"count": {{.Count}},
|
||||
"items": [
|
||||
{{- range $i, $it := .Items}}
|
||||
{{- if $i}},{{end}}
|
||||
{
|
||||
"finding_id": {{$it.FindingID}},
|
||||
"name": {{json $it.Name}},
|
||||
"vulnclass": {{json $it.VulnClass}},
|
||||
"severity": {{json $it.Severity}},
|
||||
"summary": {{json $it.Summary}},
|
||||
"assets": {{json $it.Assets}},
|
||||
"detail_url": {{json $it.DetailURL}}
|
||||
}
|
||||
{{- end}}
|
||||
]
|
||||
}`
|
||||
|
||||
// webhookTemplateData 是暴露给用户模板的上下文。
|
||||
type webhookTemplateData struct {
|
||||
Title string
|
||||
Batch bool
|
||||
Count int
|
||||
Items []webhookItem
|
||||
HomeURL string
|
||||
// SentAt 是本次投递时间(RFC3339),供接收端记录。
|
||||
SentAt string
|
||||
}
|
||||
|
||||
type webhookItem struct {
|
||||
FindingID int64
|
||||
Name string
|
||||
VulnClass string
|
||||
Severity string
|
||||
SeverityLabel string
|
||||
Summary string
|
||||
Assets []string
|
||||
DetailURL string
|
||||
FromStatus string
|
||||
ToStatus string
|
||||
// StatusLabel 是状态变更的可读描述,如「待处理 → 已修复」;非状态变更时为空。
|
||||
StatusLabel string
|
||||
}
|
||||
|
||||
func (webhookChannel) Validate(cfg map[string]any) error {
|
||||
raw := cfgString(cfg, "url")
|
||||
if raw == "" {
|
||||
return errors.New("缺少目标 URL")
|
||||
}
|
||||
if err := validateHTTPURL(raw); err != nil {
|
||||
return fmt.Errorf("目标 URL 无效: %w", err)
|
||||
}
|
||||
if m := strings.ToUpper(cfgString(cfg, "method")); m != "" && m != http.MethodGet && m != http.MethodPost && m != http.MethodPut && m != http.MethodPatch {
|
||||
return fmt.Errorf("不支持的方法 %s(可用 GET/POST/PUT/PATCH)", m)
|
||||
}
|
||||
if tpl := cfgString(cfg, "body_template"); tpl != "" {
|
||||
if _, err := parseWebhookTemplate(tpl); err != nil {
|
||||
return fmt.Errorf("请求体模板语法错误: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c webhookChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) {
|
||||
if err := c.Validate(cfg); err != nil {
|
||||
return 0, Permanent(err)
|
||||
}
|
||||
method := strings.ToUpper(cfgString(cfg, "method"))
|
||||
if method == "" {
|
||||
method = http.MethodPost
|
||||
}
|
||||
|
||||
// GET 不带请求体:把内容塞进 query 超出模板能力范围,也不符合 GET 语义,
|
||||
// 所以 GET 只适合「命中即触发钩子」这类接收端。
|
||||
var payload any
|
||||
if method != http.MethodGet {
|
||||
body, err := renderWebhookBody(cfgString(cfg, "body_template"), m)
|
||||
if err != nil {
|
||||
return 0, Permanent(err)
|
||||
}
|
||||
// 模板渲染出的是字符串形式的 JSON,这里转成 json.RawMessage 原样发出,
|
||||
// 避免二次转义把用户精心构造的结构套进一个 JSON 字符串里。
|
||||
if !json.Valid([]byte(body)) {
|
||||
return 0, Permanent(errors.New("请求体模板渲染结果不是合法 JSON"))
|
||||
}
|
||||
payload = json.RawMessage(body)
|
||||
}
|
||||
|
||||
headers := cfgMap(cfg, "headers")
|
||||
if ct := cfgString(cfg, "content_type"); ct != "" {
|
||||
// 允许覆盖,但放在 headers 之后应用,保证显式配置优先。
|
||||
if headers == nil {
|
||||
headers = map[string]string{}
|
||||
}
|
||||
headers["Content-Type"] = ct
|
||||
}
|
||||
if _, err := doJSON(ctx, method, cfgString(cfg, "url"), headers, payload); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// 通用 Webhook 不截断正文(接收端是用户自己的服务,体积由 body_template 决定),
|
||||
// 因此整批都算送达。
|
||||
return len(m.Items), nil
|
||||
}
|
||||
|
||||
// renderWebhookBody 用用户模板(或默认模板)渲染请求体。
|
||||
func renderWebhookBody(tpl string, m Message) (string, error) {
|
||||
if strings.TrimSpace(tpl) == "" {
|
||||
tpl = webhookDefaultTemplate
|
||||
}
|
||||
t, err := parseWebhookTemplate(tpl)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求体模板语法错误: %w", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := t.Execute(&buf, newWebhookTemplateData(m)); err != nil {
|
||||
return "", fmt.Errorf("渲染请求体模板失败: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// parseWebhookTemplate 解析模板。
|
||||
//
|
||||
// missingkey=zero 让缺失的 map 键渲染成零值而不是报错——但本文件的上下文是结构体,
|
||||
// 主要作用是让 .Items 为空时 range 不出错。真正需要防的是 .Items 为 nil。
|
||||
func parseWebhookTemplate(tpl string) (*template.Template, error) {
|
||||
return template.New("body").Funcs(webhookTemplateFuncs).Option("missingkey=zero").Parse(tpl)
|
||||
}
|
||||
|
||||
// webhookTemplateFuncs 是暴露给模板的辅助函数。
|
||||
var webhookTemplateFuncs = template.FuncMap{
|
||||
// json 把任意值序列化成 JSON。
|
||||
//
|
||||
// 这个函数不是锦上添花而是必需的:省去它,用户只能写 {{.Title}} 直接插值,
|
||||
// 而漏洞标题里只要有引号或换行,整段请求体就不再是合法 JSON——接收端会
|
||||
// 拒收,且报错信息指向「JSON 解析失败」,完全联想不到是标题里有个引号。
|
||||
"json": func(v any) (string, error) {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
},
|
||||
// jsons 用于把 JSON 片段嵌进另一段 JSON 字符串值内部(做一层字符串转义)。
|
||||
"jsons": func(v any) (string, error) {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
quoted, err := json.Marshal(string(raw))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 去掉外层引号:调用方自己决定要不要加引号。
|
||||
return string(quoted[1 : len(quoted)-1]), nil
|
||||
},
|
||||
}
|
||||
|
||||
func newWebhookTemplateData(m Message) webhookTemplateData {
|
||||
d := webhookTemplateData{
|
||||
Title: markdownTitle(m),
|
||||
Batch: m.Batch,
|
||||
Count: len(m.Items),
|
||||
HomeURL: m.HomeURL,
|
||||
SentAt: time.Now().Format(time.RFC3339),
|
||||
Items: make([]webhookItem, 0, len(m.Items)),
|
||||
}
|
||||
for _, it := range m.Items {
|
||||
wi := webhookItem{
|
||||
FindingID: it.FindingID,
|
||||
Name: it.Name,
|
||||
VulnClass: it.VulnClass,
|
||||
Severity: it.Severity,
|
||||
SeverityLabel: SeverityLabel(it.Severity),
|
||||
Summary: it.Summary,
|
||||
Assets: append([]string{}, it.Assets...),
|
||||
DetailURL: it.DetailURL,
|
||||
FromStatus: it.FromStatus,
|
||||
ToStatus: it.ToStatus,
|
||||
}
|
||||
if it.IsStatusChange() {
|
||||
wi.StatusLabel = StatusLabel(it.FromStatus) + " → " + StatusLabel(it.ToStatus)
|
||||
}
|
||||
d.Items = append(d.Items, wi)
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件锁住通用 Webhook 模板的**能力边界**。
|
||||
//
|
||||
// 这是全包唯一「用户提供的字符串会被当代码求值」的地方,所以要明确它能做什么、
|
||||
// 不能做什么,并用测试把这些性质固定下来——否则将来有人顺手给模板上下文加个
|
||||
// 方法、或在 FuncMap 里加个 readFile,能力面就静默扩大了,而 diff 看起来
|
||||
// 只是一个无害的小函数。
|
||||
|
||||
// TestTemplateContextHasNoMethods 是最重要的一条。
|
||||
//
|
||||
// text/template 会调用导出方法({{.Foo}} 既能取字段也能调方法)。所以模板上下文
|
||||
// 只要能接触到**任何**带导出方法的类型,就等于把那些方法暴露给了模板作者。
|
||||
// 本功能的上下文刻意全是纯数据(只有导出字段、零方法)。
|
||||
//
|
||||
// 若这条失败:说明有人给 webhookTemplateData / webhookItem 加了方法。
|
||||
// 在决定放行之前,先想清楚那个方法能不能被模板用来读取不希望暴露的东西。
|
||||
func TestTemplateContextHasNoMethods(t *testing.T) {
|
||||
for _, v := range []any{webhookTemplateData{}, webhookItem{}} {
|
||||
typ := reflect.TypeOf(v)
|
||||
if n := typ.NumMethod(); n != 0 {
|
||||
var names []string
|
||||
for i := 0; i < n; i++ {
|
||||
names = append(names, typ.Method(i).Name)
|
||||
}
|
||||
t.Fatalf("%s 暴露了 %d 个方法(%s):text/template 可以调用它们,"+
|
||||
"等于把这些方法的能力开放给了模板作者", typ.Name(), n, strings.Join(names, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemplateFuncsAreMinimal 锁住暴露给模板的函数集合。
|
||||
//
|
||||
// FuncMap 里每多一个函数就多一项能力。当前只有 json / jsons,作用是把值序列化
|
||||
// 成 JSON 片段——不能读文件、不能发请求、不能执行命令。
|
||||
func TestTemplateFuncsAreMinimal(t *testing.T) {
|
||||
var got []string
|
||||
for name := range webhookTemplateFuncs {
|
||||
got = append(got, name)
|
||||
}
|
||||
sort.Strings(got)
|
||||
want := []string{"json", "jsons"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("模板函数集合变了:得到 %v,期望 %v。新增函数前请确认它不会扩大能力面"+
|
||||
"(不能读写文件、不能发起网络请求、不能执行命令)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemplateCannotReachUnknownData 覆盖模板里的越界访问:
|
||||
// 访问不存在的东西必须失败,而不是回显点什么;且失败信息不得带出内部数据。
|
||||
func TestTemplateCannotReachUnknownData(t *testing.T) {
|
||||
_, err := renderWebhookBody(`{"x": {{.Environment}}, "y": {{.Env}}}`, singleMsg())
|
||||
if err == nil {
|
||||
t.Fatal("访问不存在的字段应报错")
|
||||
}
|
||||
// 错误里不能出现模板上下文里的真实内容(漏洞标题/摘要)。
|
||||
for _, leak := range []string{"SQL注入", "参数 id"} {
|
||||
if strings.Contains(err.Error(), leak) {
|
||||
t.Errorf("模板错误泄露了消息内容 %q: %v", leak, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemplateRenderFailsPermanently 模板写错属于配置错误,重试不会自愈。
|
||||
// 若被判成可重试,一条坏模板会让每次投递都白跑三轮退避。
|
||||
func TestTemplateRenderFailsPermanently(t *testing.T) {
|
||||
cfg := map[string]any{
|
||||
"url": "https://example.com/hook",
|
||||
"body_template": `{{.Items.`,
|
||||
}
|
||||
if err := (webhookChannel{}).Validate(cfg); err == nil {
|
||||
t.Fatal("模板语法错误应在保存时就被拦下")
|
||||
}
|
||||
// 即便绕过校验直接投递,也必须判永久失败而不是反复重试。
|
||||
_, err := (webhookChannel{}).Send(context.Background(), cfg, singleMsg())
|
||||
if err == nil || !IsPermanent(err) {
|
||||
t.Fatalf("坏模板应判永久失败,得到 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemplateCanOnlyProduceJSON 覆盖「模板渲染结果必须是合法 JSON」这条约束。
|
||||
// 它顺带挡住了「用模板生成纯文本去触发别的协议」这类用法。
|
||||
func TestTemplateCanOnlyProduceJSON(t *testing.T) {
|
||||
// 合法模板能过。
|
||||
ok := map[string]any{"url": "https://example.com/hook", "body_template": `{"t":{{json .Title}}}`}
|
||||
if err := (webhookChannel{}).Validate(ok); err != nil {
|
||||
t.Fatalf("合法模板应通过校验: %v", err)
|
||||
}
|
||||
// 渲染出非 JSON 时必须拒绝(而不是原样发出去)。
|
||||
bad := map[string]any{"url": "http://127.0.0.1:1/hook", "body_template": `not json {{.Count}}`}
|
||||
_, err := (webhookChannel{}).Send(context.Background(), bad, singleMsg())
|
||||
if err == nil || !IsPermanent(err) {
|
||||
t.Fatalf("渲染出非 JSON 应判永久失败,得到 %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "合法 JSON") {
|
||||
t.Errorf("错误信息应说明是 JSON 问题,得到 %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// weComMarkdownLimit 是企微群机器人 markdown content 的硬上限(字节,非字符)。
|
||||
// 这是全部六个渠道里最紧的限制,也是 TruncateBytes 存在的主要原因。
|
||||
const weComMarkdownLimit = 4096
|
||||
|
||||
// weComChannel 实现企业微信群机器人。
|
||||
//
|
||||
// 平台特性:
|
||||
// - 唯一通过 URL 上的 key 鉴权,不支持加签——所以 webhook 地址本身就是全部凭据。
|
||||
// - markdown content 上限 4096 **字节**,超长整条被拒(不是截断)。中文 3 字节/字,
|
||||
// 意味着正文只有一千多字可写,必须客户端截断。
|
||||
// - 限流 20 条/分钟,同样靠客户端限流兜住。
|
||||
type weComChannel struct{}
|
||||
|
||||
func (weComChannel) Kind() string { return KindWeCom }
|
||||
|
||||
func (weComChannel) DefaultRatePerMin() int { return 20 }
|
||||
|
||||
// 企业微信只有 Webhook 一处凭据(URL 上的 key),且它不支持加签——
|
||||
// 整个地址就是全部凭据,没有别的字段需要掩码。
|
||||
func (weComChannel) SecretKeys() []string { return []string{"webhook"} }
|
||||
|
||||
// 企微只有 Webhook 一处字段,它既是目的地也是凭据,因此没有「改地址后残留的凭据」可言。
|
||||
func (weComChannel) DestinationKeys() []string { return []string{"webhook"} }
|
||||
|
||||
func (weComChannel) 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 weComChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) {
|
||||
if err := c.Validate(cfg); err != nil {
|
||||
return 0, Permanent(err)
|
||||
}
|
||||
// 汇总批可能很长(50 条 × 每条一行 + 前缀),4096 字节很容易超。
|
||||
// 截断在这里做而不是靠平台报错:被拒意味着这一批全丢,而截断至少送达前若干条。
|
||||
content, kept := markdownBody(m, weComMarkdownLimit)
|
||||
payload := map[string]any{
|
||||
"msgtype": "markdown",
|
||||
"markdown": map[string]any{"content": content},
|
||||
}
|
||||
raw, err := doJSON(ctx, "POST", cfgString(cfg, "webhook"), nil, payload)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var res struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
return 0, fmt.Errorf("解析企业微信响应失败: %w (%s)", err, snippet(raw))
|
||||
}
|
||||
if res.ErrCode != 0 {
|
||||
// 45009 是接口调用超过限制——平台的限流窗口会滚动,退避后重试是有效的,
|
||||
// 所以显式归为可重试。走到这里说明客户端 rate_per_min 配得过于激进,
|
||||
// 重试只是兜底,真正的修法是调低该渠道的限流值。
|
||||
if res.ErrCode == 45009 {
|
||||
return 0, fmt.Errorf("企业微信限流 %d: %s", res.ErrCode, res.ErrMsg)
|
||||
}
|
||||
// 93000 是 webhook key 无效——永久失败,重试不会自愈。
|
||||
return 0, Permanent(fmt.Errorf("企业微信返回错误 %d: %s", res.ErrCode, res.ErrMsg))
|
||||
}
|
||||
return kept, nil
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/notify"
|
||||
)
|
||||
|
||||
// 全局设置键(存在 settings 键值表里,无需建表)。
|
||||
const (
|
||||
// settingNotifyEnabled 是推送总开关。默认开:它用于维护期一键止血,
|
||||
// 而不是功能的启用条件——真正的启用条件是「有没有配渠道」。
|
||||
settingNotifyEnabled = "notify_enabled"
|
||||
// settingNotifyPublicBaseURL 是生成漏洞详情回链的外部访问地址
|
||||
// (如 https://artex.example.com)。留空则消息里不带回链按钮。
|
||||
// 项目里没有可复用的外部地址配置,所以这里新增一项。
|
||||
settingNotifyPublicBaseURL = "notify_public_base_url"
|
||||
// settingNotifyDigestMinutes 是汇总模式的周期(分钟)。
|
||||
settingNotifyDigestMinutes = "notify_digest_interval_min"
|
||||
)
|
||||
|
||||
const (
|
||||
// notifyTick 是投递引擎的轮询间隔。3 秒是该引擎实时性的上限,
|
||||
// 也是「漏洞落库」到「消息到达 IM」之间的主要延迟来源。
|
||||
notifyTick = 3 * time.Second
|
||||
// notifyLease 是领取投递时的租约时长。必须显著大于单次投递的最坏耗时
|
||||
// (notify 包的 HTTP 客户端超时 15 秒),否则会出现同一行被两个
|
||||
// dispatcher 同时投递。
|
||||
notifyLease = 3 * time.Minute
|
||||
// notifyFanOutPerTick 限制每轮分派的事件数,避免首次启用渠道时
|
||||
// 一次性把历史积压全部展开成投递任务。
|
||||
notifyFanOutPerTick = 200
|
||||
// notifyDefaultDigestMinutes 是汇总周期的默认值。
|
||||
notifyDefaultDigestMinutes = 30
|
||||
// notifyUnlimitedBurstPerTick 是渠道未设限流时的每轮投递上限。
|
||||
// 存在的意义是防止「一个渠道配成不限流 + 一次扫出上千条漏洞」把
|
||||
// 单轮循环拖成长时间阻塞。
|
||||
notifyUnlimitedBurstPerTick = 50
|
||||
// notifyMaxSendsPerChannelPerTick 是单渠道每轮最多投递几条。
|
||||
//
|
||||
// 这个上限由**租约时长**倒推:领取时给行打的是租约(notifyLease = 3 分钟),
|
||||
// 若一轮里串行投递的条数多到最坏耗时超过租约,后几条还没发完租约就过期了。
|
||||
// 单进程内无所谓(Run 是单个 goroutine 串行跑,tick 不会重入),但**两个
|
||||
// 进程连同一个库**时,对端会把租约过期的行重新领走并重复发送,还会把
|
||||
// attempts 双份递增、在原进程仍在投递时就判成失败。
|
||||
//
|
||||
// 取值:3 分钟租约 / 30 秒单次超时 = 6 是**刚好用满租约**、零余量,
|
||||
// 不能取;取 5 让最坏耗时 150 秒留出 30 秒余量。这个关系由
|
||||
// TestNotifyTickBudgetFitsWithinLease 钉死——改 notifyLease、
|
||||
// notifySendTimeout 或本值中的任意一个都会让那条断言失败。
|
||||
notifyMaxSendsPerChannelPerTick = 5
|
||||
// notifySendTimeout 是单次投递的超时。它同时决定上一条常量的取值,
|
||||
// 两者相乘不能超过 notifyLease,见 TestNotifyTickBudgetFitsWithinLease。
|
||||
notifySendTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// notifyBackoff 是失败重试的退避序列,下标为已尝试次数。
|
||||
// 3 次机会(含首次)与 db.MaxNotifyAttempts 对应,两者必须一起改。
|
||||
var notifyBackoff = []time.Duration{
|
||||
time.Second,
|
||||
5 * time.Second,
|
||||
30 * time.Second,
|
||||
}
|
||||
|
||||
// Notifier 是漏洞推送的投递引擎。
|
||||
//
|
||||
// 与 Scheduler 并列,作为独立 goroutine 运行(见 server.New)。刻意不复用
|
||||
// Scheduler 的 tick:推送的实时性要求(3 秒)与触发器的业务节奏不同,
|
||||
// 且两者的失败互不牵连——推送卡住不该影响 agent 触发。
|
||||
type Notifier struct {
|
||||
s *Server
|
||||
pg *db.DB
|
||||
|
||||
// mu 保护 buckets。渠道数量少、竞争低,一把互斥锁足够,
|
||||
// 不值得为它引入更细粒度的结构。
|
||||
mu sync.Mutex
|
||||
buckets map[int64]*notifyBucket
|
||||
}
|
||||
|
||||
// notifyBucket 是单渠道的令牌桶。
|
||||
//
|
||||
// 用令牌桶而不是「每分钟计数后清零」的滑动窗口,是因为后者的边界效应很糟:
|
||||
// 在窗口末尾发满 20 条、下一瞬间再发 20 条,对平台来说是一秒内 40 条,
|
||||
// 会被限流;令牌桶以恒定速率补充,天然避免这种突发。
|
||||
type notifyBucket struct {
|
||||
tokens float64
|
||||
lastFill time.Time
|
||||
}
|
||||
|
||||
func newNotifier(s *Server) *Notifier {
|
||||
return &Notifier{s: s, pg: s.m.pg, buckets: map[int64]*notifyBucket{}}
|
||||
}
|
||||
|
||||
// Run 循环直到 ctx 结束。由 server.New 启动一次。
|
||||
func (n *Notifier) Run(ctx context.Context) {
|
||||
if n.pg == nil {
|
||||
return
|
||||
}
|
||||
t := time.NewTicker(notifyTick)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
n.step(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// step 跑一轮:先分派新事件,再投递到期的任务。
|
||||
//
|
||||
// 任何一步失败都只记日志、不中断循环——通知系统的故障绝不能升级成进程级问题。
|
||||
// 每个 tick 都是独立的,下一轮会自然重试。
|
||||
func (n *Notifier) step(ctx context.Context) {
|
||||
if !n.enabled() {
|
||||
return
|
||||
}
|
||||
if _, _, err := n.pg.FanOutPendingEvents(ctx, notifyFanOutPerTick); err != nil {
|
||||
log.Printf("[notify] 分派事件失败: %v", err)
|
||||
return
|
||||
}
|
||||
channels, err := n.pg.ListNotificationChannels(ctx)
|
||||
if err != nil {
|
||||
log.Printf("[notify] 读取渠道失败: %v", err)
|
||||
return
|
||||
}
|
||||
baseURL := n.publicBaseURL()
|
||||
for _, ch := range channels {
|
||||
if !ch.IsEnabled() {
|
||||
continue
|
||||
}
|
||||
// 先问令牌桶这一轮还能发几条,再按这个数量去领——顺序不能反,
|
||||
// 否则被限流挡下的投递已经消耗过重试次数。
|
||||
allow := n.takeTokens(ch.ID, ch.RatePerMin, time.Now())
|
||||
if allow > notifyMaxSendsPerChannelPerTick {
|
||||
allow = notifyMaxSendsPerChannelPerTick
|
||||
}
|
||||
if allow <= 0 {
|
||||
continue
|
||||
}
|
||||
if ch.Mode == db.NotifyModeDigest {
|
||||
// 汇总也受同一份额度约束:领到的条数不能超过本轮令牌数。
|
||||
n.stepDigest(ctx, ch, allow, baseURL)
|
||||
continue
|
||||
}
|
||||
n.stepRealtime(ctx, ch, allow, baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
// stepRealtime 领取并投递某渠道的实时任务,一条漏洞一条消息。
|
||||
func (n *Notifier) stepRealtime(ctx context.Context, ch *db.NotificationChannel, allow int, baseURL string) {
|
||||
deliveries, err := n.pg.ClaimRealtimeDeliveries(ctx, ch.ID, allow, notifyLease)
|
||||
if err != nil {
|
||||
log.Printf("[notify] 领取实时投递失败 channel=%d: %v", ch.ID, err)
|
||||
return
|
||||
}
|
||||
if len(deliveries) == 0 {
|
||||
return
|
||||
}
|
||||
channel, cfg, ok := n.adapt(ch)
|
||||
if !ok {
|
||||
_ = n.pg.FailDeliveries(ctx, deliveryIDs(deliveries), fmt.Sprintf("渠道类型 %q 未注册", ch.Kind))
|
||||
return
|
||||
}
|
||||
for _, dl := range deliveries {
|
||||
msg, err := n.renderSingle(ctx, dl, baseURL)
|
||||
if err != nil {
|
||||
// 渲染失败是本地数据问题,重试不会变好。
|
||||
_ = n.pg.FailDeliveries(ctx, []int64{dl.ID}, err.Error())
|
||||
continue
|
||||
}
|
||||
n.send(ctx, channel, cfg, msg, []*db.NotificationDelivery{dl})
|
||||
}
|
||||
}
|
||||
|
||||
// stepDigest 在批次到期时把某渠道的待发投递聚合成一条消息发出。
|
||||
func (n *Notifier) stepDigest(ctx context.Context, ch *db.NotificationChannel, allow int, baseURL string) {
|
||||
window := n.digestInterval()
|
||||
due, err := n.pg.DigestBatchDue(ctx, ch.ID, window)
|
||||
if err != nil {
|
||||
log.Printf("[notify] 判断汇总批次失败 channel=%d: %v", ch.ID, err)
|
||||
return
|
||||
}
|
||||
if !due {
|
||||
return
|
||||
}
|
||||
deliveries, err := n.pg.ClaimDigestBatch(ctx, ch.ID, allow, notifyLease)
|
||||
if err != nil {
|
||||
log.Printf("[notify] 领取汇总批次失败 channel=%d: %v", ch.ID, err)
|
||||
return
|
||||
}
|
||||
if len(deliveries) == 0 {
|
||||
return
|
||||
}
|
||||
channel, cfg, ok := n.adapt(ch)
|
||||
if !ok {
|
||||
_ = n.pg.FailDeliveries(ctx, deliveryIDs(deliveries), fmt.Sprintf("渠道类型 %q 未注册", ch.Kind))
|
||||
return
|
||||
}
|
||||
msg, included, err := n.renderBatch(ctx, deliveries, baseURL, int(window.Minutes()))
|
||||
if err != nil {
|
||||
_ = n.pg.FailDeliveries(ctx, deliveryIDs(deliveries), err.Error())
|
||||
return
|
||||
}
|
||||
// 快照坏掉、没能进消息的那些投递要显式判失败。不这么做的话它们会留在
|
||||
// included 之外、既不进消息也不进失败列表——发送成功时它们的状态会被
|
||||
// 之后的批量标记漏掉,永远停在 sending 直到租约过期被反复领取。
|
||||
if skipped := excludeDeliveries(deliveries, included); len(skipped) > 0 {
|
||||
reason := "事件快照无法解析,本条漏洞无法渲染成消息"
|
||||
if fErr := n.pg.FailDeliveries(ctx, deliveryIDs(skipped), reason); fErr != nil {
|
||||
log.Printf("[notify] 标记坏快照投递失败 channel=%s ids=%v: %v", ch.Kind, deliveryIDs(skipped), fErr)
|
||||
}
|
||||
log.Printf("[notify] 跳过 %d 条快照无法解析的投递 channel=%d", len(skipped), ch.ID)
|
||||
}
|
||||
// 只把进了消息的那些交给 send:included[i] 与 msg.Items[i] 严格对应,
|
||||
// send 依赖这个对应关系把「渠道回报装下了前 K 条」落到正确的投递行上。
|
||||
n.send(ctx, channel, cfg, msg, included)
|
||||
}
|
||||
|
||||
// send 投递并按结果流转状态。
|
||||
//
|
||||
// 同一批投递(汇总模式下可能几十条)共享一个发送结果:要么送达、要么整批重试。
|
||||
// 不做逐条重试——汇总消息是一条,重发其中一部分会让批次语义错乱。
|
||||
//
|
||||
// 唯一的例外是**渠道长度上限导致的分段**:渠道回报实际只装下了前 K 条,
|
||||
// 那么第 K+1 条起必须留到下一批,而不是跟着一起被标记成功。否则被截掉的
|
||||
// 那些漏洞既不在消息里、也不在失败列表里,彻底消失。
|
||||
func (n *Notifier) send(ctx context.Context, channel notify.Channel, cfg map[string]any, msg notify.Message, deliveries []*db.NotificationDelivery) {
|
||||
// 单次投递设上限,避免某个渠道卡住把这一轮剩余渠道全部拖住。
|
||||
sendCtx, cancel := context.WithTimeout(ctx, notifySendTimeout)
|
||||
defer cancel()
|
||||
delivered, err := channel.Send(sendCtx, cfg, msg)
|
||||
if err == nil && delivered > 0 {
|
||||
if delivered > len(deliveries) {
|
||||
// 渠道回报的条数不可能超过投递数;真发生了说明渲染层算错了,
|
||||
// 按全部送达处理并把问题记下来,总好过把记录写乱。
|
||||
log.Printf("[notify] 渠道回报送达条数 %d 超过投递数 %d channel=%s,按全部送达处理",
|
||||
delivered, len(deliveries), channel.Kind())
|
||||
delivered = len(deliveries)
|
||||
}
|
||||
sent, rest := deliveries[:delivered], deliveries[delivered:]
|
||||
if err := n.pg.MarkDeliveriesSent(ctx, deliveryIDs(sent)); err != nil {
|
||||
log.Printf("[notify] 标记已送达失败 channel=%s ids=%v: %v", channel.Kind(), deliveryIDs(sent), err)
|
||||
}
|
||||
if len(rest) > 0 {
|
||||
// 本条消息已达渠道长度上限:剩下的立刻回队,由下一个 tick 续发。
|
||||
// 用 DeferDeliveries 而非 RescheduleDeliveries —— 这不是失败,
|
||||
// 不该消耗重试预算(领取时已经乐观 +1 了,那里会减回去)。
|
||||
if err := n.pg.DeferDeliveries(ctx, deliveryIDs(rest),
|
||||
fmt.Sprintf("本条消息已达渠道长度上限,仅送达前 %d 条,其余留待下一批", delivered)); err != nil {
|
||||
log.Printf("[notify] 分段续发排队失败 channel=%s ids=%v: %v", channel.Kind(), deliveryIDs(rest), err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
// 渠道既没报错也没说送达了多少条。按失败处理(走退避),
|
||||
// 免得这条投递被反复领取却永远标记不掉。
|
||||
err = fmt.Errorf("渠道未报告送达条数(delivered=%d)", delivered)
|
||||
}
|
||||
|
||||
// 失败处置**逐条**决定,而不是拿整批的最大尝试次数做判断。
|
||||
//
|
||||
// 曾经是 `if maxAttempts(deliveries) >= MaxNotifyAttempts` 整批判死,但批次里
|
||||
// 各条的尝试次数并不相同:一个已经重试两次的老投递(attempts=2)会把同一批里
|
||||
// 全新的投递(attempts=1)一起拖进 failed——新漏洞一条重试都没用上就永久丢了,
|
||||
// 与「不让老行拖新行下水」的初衷正好相反。
|
||||
permanent := notify.IsPermanent(err)
|
||||
var failIDs, exhaustedIDs []int64
|
||||
byDelay := map[time.Duration][]int64{}
|
||||
for _, dl := range deliveries {
|
||||
switch {
|
||||
case permanent:
|
||||
failIDs = append(failIDs, dl.ID)
|
||||
case dl.Attempts >= db.MaxNotifyAttempts:
|
||||
exhaustedIDs = append(exhaustedIDs, dl.ID)
|
||||
default:
|
||||
delay := notifyBackoff[min(dl.Attempts, len(notifyBackoff)-1)]
|
||||
byDelay[delay] = append(byDelay[delay], dl.ID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(failIDs) > 0 {
|
||||
if fErr := n.pg.FailDeliveries(ctx, failIDs, err.Error()); fErr != nil {
|
||||
log.Printf("[notify] 标记失败状态出错 channel=%s ids=%v: %v", channel.Kind(), failIDs, fErr)
|
||||
}
|
||||
}
|
||||
if len(exhaustedIDs) > 0 {
|
||||
reason := fmt.Sprintf("重试 %d 次后仍失败: %s", db.MaxNotifyAttempts, err)
|
||||
if fErr := n.pg.FailDeliveries(ctx, exhaustedIDs, reason); fErr != nil {
|
||||
log.Printf("[notify] 标记失败状态出错 channel=%s ids=%v: %v", channel.Kind(), exhaustedIDs, fErr)
|
||||
}
|
||||
}
|
||||
// 按延迟分组重排:只有 3 档退避,分组数天然很小,不必为每条单独发一次
|
||||
// UPDATE(那会让一个 500 条的批次产生 500 次往返)。
|
||||
for delay, group := range byDelay {
|
||||
if rErr := n.pg.RescheduleDeliveries(ctx, group, delay, err.Error()); rErr != nil {
|
||||
log.Printf("[notify] 重排投递失败 channel=%s ids=%v: %v", channel.Kind(), group, rErr)
|
||||
}
|
||||
}
|
||||
if len(failIDs)+len(exhaustedIDs) > 0 {
|
||||
log.Printf("[notify] 投递失败 channel=%d kind=%s 永久失败=%d 重试耗尽=%d 待重试=%d: %s",
|
||||
deliveries[0].ChannelID, channel.Kind(), len(failIDs), len(exhaustedIDs), len(byDelay), err)
|
||||
}
|
||||
}
|
||||
|
||||
// excludeDeliveries 返回 all 中不在 keep 里的那些(按指针身份比较)。
|
||||
// 用于找出「没能进消息」的投递——它们必须被显式处置,不能留在灰色地带。
|
||||
func excludeDeliveries(all, keep []*db.NotificationDelivery) []*db.NotificationDelivery {
|
||||
inKeep := make(map[*db.NotificationDelivery]bool, len(keep))
|
||||
for _, dl := range keep {
|
||||
inKeep[dl] = true
|
||||
}
|
||||
var out []*db.NotificationDelivery
|
||||
for _, dl := range all {
|
||||
if !inKeep[dl] {
|
||||
out = append(out, dl)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// adapt 取渠道实现并解析其配置。
|
||||
// 返回 ok=false 表示类型未注册,投递应直接判失败而不是无限重试。
|
||||
func (n *Notifier) adapt(ch *db.NotificationChannel) (notify.Channel, map[string]any, bool) {
|
||||
channel, ok := notify.Get(ch.Kind)
|
||||
if !ok {
|
||||
return nil, nil, false
|
||||
}
|
||||
var cfg map[string]any
|
||||
if len(ch.Config) > 0 {
|
||||
// 配置解析失败时给一个空 map:渠道自身的 Validate 会报出「缺哪个字段」,
|
||||
// 那个错误比 JSON 解析错误更能指导用户修复。
|
||||
_ = json.Unmarshal(ch.Config, &cfg)
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = map[string]any{}
|
||||
}
|
||||
return channel, cfg, true
|
||||
}
|
||||
|
||||
// renderSingle 渲染单条漏洞消息。
|
||||
func (n *Notifier) renderSingle(ctx context.Context, dl *db.NotificationDelivery, baseURL string) (notify.Message, error) {
|
||||
snap, err := parseSnapshot(dl)
|
||||
if err != nil {
|
||||
return notify.Message{}, err
|
||||
}
|
||||
item, err := n.itemFor(ctx, snap, baseURL)
|
||||
if err != nil {
|
||||
return notify.Message{}, err
|
||||
}
|
||||
return notify.Message{Items: []notify.Item{item}, HomeURL: baseURL}, nil
|
||||
}
|
||||
|
||||
// renderBatch 渲染汇总消息。逐条解析快照——单条坏了只跳过那一条,
|
||||
// 不让它把整批汇总拖没。
|
||||
//
|
||||
// 返回值 included 与 msg.Items **严格一一对应**(第 i 个投递 ↔ 第 i 个条目)。
|
||||
// 这个对应关系是硬要求:调用方按「渠道回报装下了前 K 条」来决定前 K 个投递
|
||||
// 标记已送达。若这里跳过了坏快照却不把跳过的投递从 included 里剔除,
|
||||
// 下标就会错位——本该失败的坏条目会被标成已送达,而好条目被误判为未送达。
|
||||
// 坏掉的那些由调用方显式标记失败,见 stepDigest。
|
||||
func (n *Notifier) renderBatch(ctx context.Context, deliveries []*db.NotificationDelivery, baseURL string, windowMinutes int) (notify.Message, []*db.NotificationDelivery, error) {
|
||||
items := make([]notify.Item, 0, len(deliveries))
|
||||
included := make([]*db.NotificationDelivery, 0, len(deliveries))
|
||||
for _, dl := range deliveries {
|
||||
snap, err := parseSnapshot(dl)
|
||||
if err != nil {
|
||||
// 坏快照不进消息,也不进 included——它的处置由调用方负责
|
||||
// (显式标记失败,而不是混在「已送达」里蒙混过关)。
|
||||
log.Printf("[notify] 汇总批次中跳过无法解析的快照 delivery=%d: %v", dl.ID, err)
|
||||
continue
|
||||
}
|
||||
item, err := n.itemFor(ctx, snap, baseURL)
|
||||
if err != nil {
|
||||
return notify.Message{}, nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
included = append(included, dl)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return notify.Message{}, nil, fmt.Errorf("汇总批次 %d 条投递全部无法解析", len(deliveries))
|
||||
}
|
||||
return notify.Message{
|
||||
Items: items,
|
||||
Batch: true,
|
||||
WindowMinutes: windowMinutes,
|
||||
HomeURL: baseURL,
|
||||
}, included, nil
|
||||
}
|
||||
|
||||
// itemFor 把事件快照渲染成待推送条目,顺带解析资产名与详情回链。
|
||||
func (n *Notifier) itemFor(ctx context.Context, snap notify.Snapshot, baseURL string) (notify.Item, error) {
|
||||
assets, err := n.pg.NotificationAssetNames(ctx, snap.AssetIDs)
|
||||
if err != nil {
|
||||
// 资产名解析失败不该阻止推送:读不到名字比收不到通知轻得多,
|
||||
// 消息里少一行资产而已。
|
||||
log.Printf("[notify] 解析资产名失败 finding=%d: %v", snap.FindingID, err)
|
||||
}
|
||||
item := notify.Item{
|
||||
FindingID: snap.FindingID,
|
||||
Name: snap.Name,
|
||||
VulnClass: snap.VulnClass,
|
||||
Severity: snap.Severity,
|
||||
Summary: snap.Summary,
|
||||
Assets: assets,
|
||||
FromStatus: snap.FromStatus,
|
||||
ToStatus: snap.ToStatus,
|
||||
}
|
||||
if baseURL != "" {
|
||||
// 详情页路由见 web/src/app/(main)/function/findings/detail/page.tsx,
|
||||
// 它从 query 参数 id 读取漏洞 id。
|
||||
item.DetailURL = fmt.Sprintf("%s/function/findings/detail?id=%d", baseURL, snap.FindingID)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// takeTokens 从渠道令牌桶里取出本轮可投递的条数。
|
||||
//
|
||||
// 桶容量为该渠道每分钟上限:积压时最多一次性冲这么多(平台规则允许),
|
||||
// 之后按恒定速率补充。ratePerMin<=0 表示不限流,返回一个有限但足够大的值,
|
||||
// 防止单轮循环被无限积压拖住。
|
||||
func (n *Notifier) takeTokens(channelID int64, ratePerMin int, now time.Time) int {
|
||||
if ratePerMin <= 0 {
|
||||
return notifyUnlimitedBurstPerTick
|
||||
}
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
b := n.buckets[channelID]
|
||||
if b == nil {
|
||||
b = ¬ifyBucket{tokens: float64(ratePerMin), lastFill: now}
|
||||
n.buckets[channelID] = b
|
||||
}
|
||||
// 按经过的真实时间补充,速率是 ratePerMin/60 每秒。
|
||||
if elapsed := now.Sub(b.lastFill).Seconds(); elapsed > 0 {
|
||||
b.tokens = minF(float64(ratePerMin), b.tokens+elapsed*float64(ratePerMin)/60)
|
||||
b.lastFill = now
|
||||
}
|
||||
// 加一个极小 epsilon 再取整:令牌数是浮点累加出来的,分两次补满时
|
||||
// 0.5 + 0.5 可能得到 0.9999999999,直接 int() 会被截成 0——
|
||||
// 数学上已满的桶却取不出令牌。1e-9 远小于一个令牌,不会放过真正的欠额。
|
||||
take := int(b.tokens + 1e-9)
|
||||
if take <= 0 {
|
||||
return 0
|
||||
}
|
||||
b.tokens -= float64(take)
|
||||
return take
|
||||
}
|
||||
|
||||
// enabled 读取总开关。
|
||||
func (n *Notifier) enabled() bool {
|
||||
return n.pg.GetBool(settingNotifyEnabled, true)
|
||||
}
|
||||
|
||||
// publicBaseURL 返回回链用的外部地址,去掉尾部斜杠。
|
||||
func (n *Notifier) publicBaseURL() string {
|
||||
v, ok, err := n.pg.GetSetting(settingNotifyPublicBaseURL)
|
||||
if err != nil || !ok {
|
||||
return ""
|
||||
}
|
||||
return trimTrailingSlash(v)
|
||||
}
|
||||
|
||||
// digestInterval 返回汇总周期,非法或未配置时回落到默认值。
|
||||
func (n *Notifier) digestInterval() time.Duration {
|
||||
v, ok, err := n.pg.GetSetting(settingNotifyDigestMinutes)
|
||||
if err != nil || !ok {
|
||||
return time.Duration(notifyDefaultDigestMinutes) * time.Minute
|
||||
}
|
||||
m := 0
|
||||
if _, err := fmt.Sscanf(v, "%d", &m); err != nil || m <= 0 {
|
||||
return time.Duration(notifyDefaultDigestMinutes) * time.Minute
|
||||
}
|
||||
return time.Duration(m) * time.Minute
|
||||
}
|
||||
|
||||
// parseSnapshot 解析投递对应事件的快照。
|
||||
func parseSnapshot(dl *db.NotificationDelivery) (notify.Snapshot, error) {
|
||||
var snap notify.Snapshot
|
||||
if len(dl.Snapshot) == 0 {
|
||||
return snap, fmt.Errorf("投递 %d 的事件快照为空", dl.ID)
|
||||
}
|
||||
if err := json.Unmarshal(dl.Snapshot, &snap); err != nil {
|
||||
return snap, fmt.Errorf("解析投递 %d 的事件快照失败: %w", dl.ID, err)
|
||||
}
|
||||
if snap.Kind == "" {
|
||||
// 事件类型以事件行为准,快照里那份可能由旧版本写过。
|
||||
snap.Kind = dl.EventKind
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
func deliveryIDs(deliveries []*db.NotificationDelivery) []int64 {
|
||||
out := make([]int64, 0, len(deliveries))
|
||||
for _, dl := range deliveries {
|
||||
out = append(out, dl.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func trimTrailingSlash(s string) string {
|
||||
for len(s) > 0 && s[len(s)-1] == '/' {
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func minF(a, b float64) float64 {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/notify"
|
||||
)
|
||||
|
||||
// 本文件是推送功能的 HTTP 接口。全部路由挂在 requireAuth 之后(见 Handler()),
|
||||
// 与其它管理接口一致。
|
||||
|
||||
// notifyChannelDTO 是渠道的对外表述。
|
||||
//
|
||||
// Config 是**掩码后**的配置:凭据字段被替换成 notify.MaskedPrefix 开头的值。
|
||||
// 前端把掩码值原样提交回来即表示「这个字段没改」,服务端据此保留库中原值
|
||||
// (见 notify.MergeConfig)。
|
||||
type notifyChannelDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Mode string `json:"mode"`
|
||||
Config map[string]any `json:"config"`
|
||||
Filter notify.Filter `json:"filter"`
|
||||
RatePerMin int `json:"rate_per_min"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// SecretKeys 告知前端哪些字段是凭据,据此渲染密码框与「留空即不改」的提示。
|
||||
// 由渠道自己声明(notify.Channel.SecretKeys),前端不硬编码渠道知识。
|
||||
SecretKeys []string `json:"secret_keys"`
|
||||
}
|
||||
|
||||
// notifyDeliveryDTO 是投递历史的对外表述。
|
||||
type notifyDeliveryDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
FindingID int64 `json:"finding_id,string"`
|
||||
EventKind string `json:"event_kind"`
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
ChannelName string `json:"channel_name"`
|
||||
ChannelKind string `json:"channel_kind"`
|
||||
State string `json:"state"`
|
||||
Attempts int `json:"attempts"`
|
||||
LastError string `json:"last_error"`
|
||||
BatchID *int64 `json:"batch_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
SentAt *time.Time `json:"sent_at,omitempty"`
|
||||
NextAttempt time.Time `json:"next_attempt_at"`
|
||||
// 消息标题摘要,让历史列表不必展开就能看出这条推的是什么。
|
||||
Title string `json:"title"`
|
||||
Severity string `json:"severity"`
|
||||
}
|
||||
|
||||
func toNotifyChannelDTO(ch *db.NotificationChannel) notifyChannelDTO {
|
||||
var cfg map[string]any
|
||||
if len(ch.Config) > 0 {
|
||||
_ = json.Unmarshal(ch.Config, &cfg)
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = map[string]any{}
|
||||
}
|
||||
secrets := []string{}
|
||||
if c, ok := notify.Get(ch.Kind); ok {
|
||||
secrets = c.SecretKeys()
|
||||
}
|
||||
return notifyChannelDTO{
|
||||
ID: ch.ID,
|
||||
Name: ch.Name,
|
||||
Kind: ch.Kind,
|
||||
Enabled: ch.IsEnabled(),
|
||||
Mode: ch.Mode,
|
||||
Config: notify.MaskConfig(ch.Kind, cfg),
|
||||
Filter: notify.ParseFilter(ch.Filter),
|
||||
RatePerMin: ch.RatePerMin,
|
||||
CreatedAt: ch.CreatedAt,
|
||||
UpdatedAt: ch.UpdatedAt,
|
||||
SecretKeys: secrets,
|
||||
}
|
||||
}
|
||||
|
||||
func toNotifyDeliveryDTO(dl *db.NotificationDelivery) notifyDeliveryDTO {
|
||||
snap, _ := parseSnapshot(dl)
|
||||
dto := notifyDeliveryDTO{
|
||||
ID: dl.ID,
|
||||
FindingID: dl.FindingID,
|
||||
EventKind: dl.EventKind,
|
||||
ChannelID: dl.ChannelID,
|
||||
ChannelName: dl.ChannelName,
|
||||
ChannelKind: dl.ChannelKind,
|
||||
State: dl.State,
|
||||
Attempts: dl.Attempts,
|
||||
LastError: dl.LastError,
|
||||
BatchID: dl.BatchID,
|
||||
CreatedAt: dl.CreatedAt,
|
||||
SentAt: dl.SentAt,
|
||||
NextAttempt: dl.NextAttemptAt,
|
||||
Severity: snap.Severity,
|
||||
}
|
||||
if snap.Name != "" {
|
||||
dto.Title = snap.Name
|
||||
} else {
|
||||
dto.Title = snap.VulnClass
|
||||
}
|
||||
return dto
|
||||
}
|
||||
|
||||
// notifyMeta 返回通知页需要的静态元数据与全局设置,一次请求拿全,
|
||||
// 避免前端为了渲染一个下拉框发三次请求。
|
||||
func (s *Server) notifyMeta(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
kinds := make([]map[string]any, 0, len(notify.Kinds()))
|
||||
for _, k := range notify.Kinds() {
|
||||
ch, _ := notify.Get(k)
|
||||
kinds = append(kinds, map[string]any{
|
||||
"kind": k,
|
||||
"default_rate_per_min": ch.DefaultRatePerMin(),
|
||||
"secret_keys": ch.SecretKeys(),
|
||||
})
|
||||
}
|
||||
baseURL, _, _ := pg.GetSetting(settingNotifyPublicBaseURL)
|
||||
digest, _, _ := pg.GetSetting(settingNotifyDigestMinutes)
|
||||
stats, err := pg.NotificationStatsSnapshot(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{
|
||||
"kinds": kinds,
|
||||
"enabled": pg.GetBool(settingNotifyEnabled, true),
|
||||
"public_base_url": baseURL,
|
||||
"digest_interval_min": digest,
|
||||
"defaults": map[string]any{
|
||||
"digest_interval_min": notifyDefaultDigestMinutes,
|
||||
},
|
||||
"stats": stats,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) notifyListChannels(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
channels, err := pg.ListNotificationChannels(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
out := make([]notifyChannelDTO, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
out = append(out, toNotifyChannelDTO(ch))
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"channels": out})
|
||||
}
|
||||
|
||||
// notifyChannelRequest 是新建/更新渠道的请求体。
|
||||
//
|
||||
// 全部业务字段用指针,以便区分「没传」与「传了零值」:PATCH 语义下,
|
||||
// 没传的字段必须保留库中原值。
|
||||
type notifyChannelRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Kind *string `json:"kind"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Mode *string `json:"mode"`
|
||||
Config map[string]any `json:"config"`
|
||||
Filter *notify.Filter `json:"filter"`
|
||||
RatePerMin *int `json:"rate_per_min"`
|
||||
}
|
||||
|
||||
func (s *Server) notifyCreateChannel(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
var req notifyChannelRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, "请求体不是合法 JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Kind == nil || !notify.ValidKind(*req.Kind) {
|
||||
writeErr(w, 400, fmt.Sprintf("渠道类型无效,可选:%s", strings.Join(notify.Kinds(), " / ")))
|
||||
return
|
||||
}
|
||||
name := ""
|
||||
if req.Name != nil {
|
||||
name = strings.TrimSpace(*req.Name)
|
||||
}
|
||||
if name == "" {
|
||||
writeErr(w, 400, "缺少渠道名称")
|
||||
return
|
||||
}
|
||||
channel, _ := notify.Get(*req.Kind)
|
||||
if err := channel.Validate(req.Config); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
ch := &db.NotificationChannel{
|
||||
Name: name,
|
||||
Kind: *req.Kind,
|
||||
Enabled: req.Enabled,
|
||||
Mode: db.NotifyModeRealtime,
|
||||
RatePerMin: channel.DefaultRatePerMin(),
|
||||
}
|
||||
if req.Mode != nil {
|
||||
if !db.ValidNotifyMode(*req.Mode) {
|
||||
writeErr(w, 400, "推送模式无效,可选:realtime / digest")
|
||||
return
|
||||
}
|
||||
ch.Mode = *req.Mode
|
||||
}
|
||||
if req.RatePerMin != nil {
|
||||
// 显式给值就照用——包括 0,它表示「不限流」,是合法配置。
|
||||
if *req.RatePerMin < 0 {
|
||||
writeErr(w, 400, "限流值不能为负")
|
||||
return
|
||||
}
|
||||
ch.RatePerMin = *req.RatePerMin
|
||||
}
|
||||
// 只有「字段缺省」才套用渠道默认值。默认值必须在这里决定而不是在 db 层:
|
||||
// 只有请求体能区分「没传这个字段」与「显式传了 0」,而两者的含义完全不同
|
||||
// (前者=用默认,后者=不限流)。db 层把 0 也当未指定,会让不限流配置不可达。
|
||||
if req.RatePerMin == nil {
|
||||
ch.RatePerMin = channel.DefaultRatePerMin()
|
||||
}
|
||||
if req.Filter != nil {
|
||||
// 写入时校验取值受限的过滤字段(如 min_severity)。详见 notify.Filter.Validate:
|
||||
// 门槛打错字会让过滤器静默失效变成全推,必须在入口拦掉。
|
||||
if err := req.Filter.Validate(); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
raw, _ := json.Marshal(req.Filter)
|
||||
ch.Filter = raw
|
||||
}
|
||||
rawCfg, _ := json.Marshal(req.Config)
|
||||
ch.Config = rawCfg
|
||||
|
||||
id, err := pg.SaveNotificationChannel(r.Context(), ch)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"id": id})
|
||||
}
|
||||
|
||||
func (s *Server) notifyUpdateChannel(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok {
|
||||
writeErr(w, 400, "渠道 id 无效")
|
||||
return
|
||||
}
|
||||
current, err := pg.NotificationChannelByID(r.Context(), id)
|
||||
if err != nil {
|
||||
notifyChannelLookupErr(w, err)
|
||||
return
|
||||
}
|
||||
var req notifyChannelRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, "请求体不是合法 JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// kind 允许修改,但改类型意味着凭据字段整套替换,不能与旧配置合并。
|
||||
kind := current.Kind
|
||||
if req.Kind != nil {
|
||||
if !notify.ValidKind(*req.Kind) {
|
||||
writeErr(w, 400, fmt.Sprintf("渠道类型无效,可选:%s", strings.Join(notify.Kinds(), " / ")))
|
||||
return
|
||||
}
|
||||
kind = *req.Kind
|
||||
}
|
||||
channel, _ := notify.Get(kind)
|
||||
|
||||
var stored map[string]any
|
||||
if kind == current.Kind {
|
||||
if len(current.Config) > 0 {
|
||||
_ = json.Unmarshal(current.Config, &stored)
|
||||
}
|
||||
}
|
||||
if stored == nil {
|
||||
stored = map[string]any{}
|
||||
}
|
||||
// 用 PrepareConfigUpdate 而不是裸的 MergeConfig:目标地址变更时必须让操作者
|
||||
// 对凭据字段重新表态,否则「只改地址、凭据沿用」会把库里的真凭据发到新地址。
|
||||
merged, err := notify.PrepareConfigUpdate(kind, stored, req.Config)
|
||||
if err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if err := channel.Validate(merged); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
rawCfg, _ := json.Marshal(merged)
|
||||
|
||||
ch := &db.NotificationChannel{
|
||||
ID: id,
|
||||
Name: current.Name,
|
||||
Kind: kind,
|
||||
Enabled: current.Enabled,
|
||||
Mode: current.Mode,
|
||||
Config: rawCfg,
|
||||
Filter: current.Filter,
|
||||
RatePerMin: current.RatePerMin,
|
||||
}
|
||||
if req.Name != nil {
|
||||
if ch.Name = strings.TrimSpace(*req.Name); ch.Name == "" {
|
||||
writeErr(w, 400, "渠道名称不能为空")
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
ch.Enabled = req.Enabled
|
||||
}
|
||||
if req.Mode != nil {
|
||||
if !db.ValidNotifyMode(*req.Mode) {
|
||||
writeErr(w, 400, "推送模式无效,可选:realtime / digest")
|
||||
return
|
||||
}
|
||||
ch.Mode = *req.Mode
|
||||
}
|
||||
if req.RatePerMin != nil {
|
||||
if *req.RatePerMin < 0 {
|
||||
writeErr(w, 400, "限流值不能为负")
|
||||
return
|
||||
}
|
||||
ch.RatePerMin = *req.RatePerMin
|
||||
}
|
||||
if req.Filter != nil {
|
||||
if err := req.Filter.Validate(); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
raw, _ := json.Marshal(req.Filter)
|
||||
ch.Filter = raw
|
||||
}
|
||||
|
||||
// 走 SetNotificationChannelEnabled 而非 SaveNotificationChannel 的路径,
|
||||
// 是为了让「停用」同时把存量待发投递标记为 skipped,避免重新启用时收到
|
||||
// 一批已过时的积压消息。
|
||||
enabledChanged := ch.Enabled != nil && current.Enabled != nil && *ch.Enabled != *current.Enabled
|
||||
if enabledChanged {
|
||||
// 先把配置更新落库(此时 enabled 用旧值,避免提前触发跳过逻辑),
|
||||
// 再单独切开关。两步之间没有并发窗口:本接口是唯一改这两个字段的入口。
|
||||
prev := ch.Enabled
|
||||
ch.Enabled = current.Enabled
|
||||
if _, err := pg.SaveNotificationChannel(r.Context(), ch); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if err := pg.SetNotificationChannelEnabled(r.Context(), id, *prev); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"id": id})
|
||||
return
|
||||
}
|
||||
if _, err := pg.SaveNotificationChannel(r.Context(), ch); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"id": id})
|
||||
}
|
||||
|
||||
func (s *Server) notifyDeleteChannel(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok {
|
||||
writeErr(w, 400, "渠道 id 无效")
|
||||
return
|
||||
}
|
||||
if err := pg.DeleteNotificationChannel(r.Context(), id); err != nil {
|
||||
notifyChannelLookupErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// notifyTestChannel 用当前保存的配置发一条测试消息。
|
||||
//
|
||||
// 直接调用渠道 Send 而不经投递队列:测试的目的是立刻告诉用户「这套配置能不能
|
||||
// 发出去」,走队列会把结果藏进投递历史,用户得再去翻一遍才知道成没成。
|
||||
// 因此本接口是**同步**的,超时上限由 notify 包的 HTTP 客户端决定(15 秒)。
|
||||
func (s *Server) notifyTestChannel(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok {
|
||||
writeErr(w, 400, "渠道 id 无效")
|
||||
return
|
||||
}
|
||||
ch, err := pg.NotificationChannelByID(r.Context(), id)
|
||||
if err != nil {
|
||||
notifyChannelLookupErr(w, err)
|
||||
return
|
||||
}
|
||||
channel, ok := notify.Get(ch.Kind)
|
||||
if !ok {
|
||||
writeErr(w, 400, fmt.Sprintf("渠道类型 %q 未注册", ch.Kind))
|
||||
return
|
||||
}
|
||||
var cfg map[string]any
|
||||
if len(ch.Config) > 0 {
|
||||
_ = json.Unmarshal(ch.Config, &cfg)
|
||||
}
|
||||
if err := channel.Validate(cfg); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
msg := notifyTestMessage(s.notifierBaseURL(pg))
|
||||
start := time.Now()
|
||||
// 测试消息只有一条,送达条数这里不需要(渠道长度上限对单条消息而言
|
||||
// 由截断兜底,不涉及分段)。
|
||||
if _, err := channel.Send(r.Context(), cfg, msg); err != nil {
|
||||
// 把渠道返回的原始错误如实回给用户——这是他们调试配置的唯一线索。
|
||||
writeErr(w, 502, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{
|
||||
"ok": true,
|
||||
"latency_ms": time.Since(start).Milliseconds(),
|
||||
})
|
||||
}
|
||||
|
||||
// notifyTestMessage 构造测试消息。刻意用一眼能看出是测试的内容:
|
||||
// 收到的人不应该把它误判成真实漏洞。
|
||||
func notifyTestMessage(baseURL string) notify.Message {
|
||||
return notify.Message{
|
||||
Items: []notify.Item{{
|
||||
FindingID: 0,
|
||||
Name: "测试消息 · 渠道配置正常",
|
||||
VulnClass: "连通性测试",
|
||||
Severity: "low",
|
||||
Summary: "这是 ARTEX 推送渠道的测试消息,收到即表示该渠道配置可用。",
|
||||
Assets: []string{"artex.example.com"},
|
||||
DetailURL: baseURL,
|
||||
}},
|
||||
HomeURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
// notifierBaseURL 读回链用的外部地址。
|
||||
func (s *Server) notifierBaseURL(pg *db.DB) string {
|
||||
v, _, _ := pg.GetSetting(settingNotifyPublicBaseURL)
|
||||
return trimTrailingSlash(v)
|
||||
}
|
||||
|
||||
func (s *Server) notifyListDeliveries(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
f := db.NotificationDeliveryFilter{
|
||||
State: r.URL.Query().Get("state"),
|
||||
EventKind: r.URL.Query().Get("event_kind"),
|
||||
}
|
||||
if v := r.URL.Query().Get("channel_id"); v != "" {
|
||||
f.ChannelID = int64(atoiDefault(v, 0))
|
||||
}
|
||||
page := queryInt(r, "page", 1)
|
||||
pageSize := queryInt(r, "page_size", 50)
|
||||
items, total, err := pg.ListNotificationDeliveries(r.Context(), f, page, pageSize)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
out := make([]notifyDeliveryDTO, 0, len(items))
|
||||
for _, dl := range items {
|
||||
out = append(out, toNotifyDeliveryDTO(dl))
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"deliveries": out, "total": total, "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
func (s *Server) notifyRetryDelivery(w http.ResponseWriter, r *http.Request) {
|
||||
pg := s.pg(w)
|
||||
if pg == nil {
|
||||
return
|
||||
}
|
||||
id, ok := pathInt(r, "id")
|
||||
if !ok {
|
||||
writeErr(w, 400, "投递 id 无效")
|
||||
return
|
||||
}
|
||||
if err := pg.RetryNotificationDelivery(r.Context(), id); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// notifyChannelLookupErr 把「渠道不存在」翻译成 404,其余错误 500。
|
||||
func notifyChannelLookupErr(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, db.ErrNotificationChannelNotFound) {
|
||||
writeErr(w, 404, "通知渠道不存在")
|
||||
return
|
||||
}
|
||||
writeErr(w, 500, err.Error())
|
||||
}
|
||||
@@ -0,0 +1,895 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/notify"
|
||||
)
|
||||
|
||||
// 本文件覆盖推送功能的端到端行为:漏洞落库 → 事件 → 分派 → 真发 HTTP。
|
||||
//
|
||||
// 一个安全上的注意点:这些用例**不调用全局的 Notifier.step()**,只对自己创建的
|
||||
// 渠道调用 stepRealtime/stepDigest。原因是 step() 会遍历库里所有启用渠道——
|
||||
// 在一个已配好真实钉钉/企微机器人的开发库上跑测试,全局 step 会把测试期间
|
||||
// 产生的漏洞真推到那些群里。逐渠道调用把影响面严格限制在测试自造的假接收端上。
|
||||
//
|
||||
// 清理:用例结束时删掉本用例产生的事件(级联删投递)与渠道,不给真实渠道留积压。
|
||||
//
|
||||
// 断言口径:stepRealtime/stepDigest 不返回值、内部记日志,因此这里断言的是
|
||||
// **可观测的外部行为**(假接收端收到了什么、投递行落到什么状态),而不是函数的
|
||||
// 返回值——这比对返回值打桩更接近真实调用路径。
|
||||
|
||||
// notifyFixture 是本文件用例的公共装置。
|
||||
type notifyFixture struct {
|
||||
s *Server
|
||||
pg *db.DB
|
||||
request func(method, path, body string) *httptest.ResponseRecorder
|
||||
n *Notifier
|
||||
// 自建的 task/exploration:用例把漏洞记到这里,与其它用例的数据隔离。
|
||||
taskID int64
|
||||
expID int64
|
||||
// cleanupMark 之后产生的事件在清理时一并删除。
|
||||
cleanupMark int64
|
||||
}
|
||||
|
||||
func newNotifyFixture(t *testing.T) *notifyFixture {
|
||||
t.Helper()
|
||||
// 本文件的所有假接收端都跑在 127.0.0.1 上,而投递默认拒绝环回地址
|
||||
// (防 SSRF 打到同机服务与云元数据)。测试显式打开这个开关;
|
||||
// 守卫「默认拒绝」的行为由 notify 包的 ssrf_test.go 覆盖。
|
||||
t.Setenv(notify.AllowLocalTargetsEnv, "1")
|
||||
s, _, request := trafficEvidenceServer(t)
|
||||
pg := s.m.pg
|
||||
|
||||
// 自建一个 task:共享装置 trafficEvidenceServer 造的 task 拿不到 exploration id,
|
||||
// 而记录漏洞必须提供它。
|
||||
task, err := s.m.CreateTask("通知推送测试", "推送行为验证", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
taskID, err := strconv.ParseInt(task.ID, 10, 64)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { pg.Exec(`DELETE FROM tasks WHERE id=$1`, taskID) })
|
||||
|
||||
var mark int64
|
||||
if err := pg.QueryRow(`SELECT COALESCE(max(id),0) FROM notification_events`).Scan(&mark); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 让装置自成闭环:把建 fixture 之前就存在的事件一次性标记为已分派。
|
||||
//
|
||||
// 为什么必须做:FanOutPendingEvents 是**全局**的,会把库里所有未分派事件
|
||||
// 展开到所有匹配渠道上。而共享装置 trafficEvidenceServer 自己就会记一条漏洞
|
||||
// (正是它返回的那个初始 finding),其它用例也可能有残留。不隔离的话,
|
||||
// 这些杂散事件会被分派到本用例的渠道上,让「应有 N 条投递」这类断言
|
||||
// 时对时错——而且错法取决于用例执行顺序,比直接失败更难查。
|
||||
if _, err := pg.Exec(`UPDATE notification_events SET fanned_out = true WHERE id <= $1 AND NOT fanned_out`, mark); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f := ¬ifyFixture{s: s, pg: pg, request: request, n: newNotifier(s), taskID: taskID, expID: task.ExpID, cleanupMark: mark}
|
||||
t.Cleanup(func() {
|
||||
if _, err := pg.Exec(`DELETE FROM notification_events WHERE id > $1`, f.cleanupMark); err != nil {
|
||||
t.Logf("清理通知事件失败: %v", err)
|
||||
}
|
||||
})
|
||||
// 总开关必须是开的(其它用例可能关过它)。
|
||||
if err := pg.SetBool(settingNotifyEnabled, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// record 走真实的证据写入路径落一条漏洞,返回 finding id。
|
||||
// 这条路会在**同一事务**里登记推送事件——正是本功能的挂点。
|
||||
func (f *notifyFixture) record(t *testing.T, vulnclass, severity string) int64 {
|
||||
t.Helper()
|
||||
out, err := f.s.evidenceStore().Record(context.Background(), db.RecordFindingInput{
|
||||
TaskID: f.taskID,
|
||||
ExplorationID: f.expID,
|
||||
Worker: "test",
|
||||
VulnClass: vulnclass,
|
||||
Name: vulnclass,
|
||||
Severity: severity,
|
||||
Summary: vulnclass + " 的摘要",
|
||||
Evidence: "poc",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("记录漏洞失败: %v", err)
|
||||
}
|
||||
return out.FindingID
|
||||
}
|
||||
|
||||
// channel 读回渠道配置(供逐渠道调用 stepX 使用)。
|
||||
func (f *notifyFixture) channel(t *testing.T, id int64) *db.NotificationChannel {
|
||||
t.Helper()
|
||||
ch, err := f.pg.NotificationChannelByID(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("读渠道失败: %v", err)
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
// deliver 分派事件并只对指定渠道跑一轮投递。
|
||||
func (f *notifyFixture) deliver(t *testing.T, chID int64, baseURL string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
if _, _, err := f.pg.FanOutPendingEvents(ctx, 500); err != nil {
|
||||
t.Fatalf("分派失败: %v", err)
|
||||
}
|
||||
f.n.stepRealtime(ctx, f.channel(t, chID), 50, baseURL)
|
||||
}
|
||||
|
||||
// createChannel 通过 HTTP 接口建渠道,顺带覆盖接口自身的校验路径。
|
||||
func (f *notifyFixture) createChannel(t *testing.T, payload map[string]any) int64 {
|
||||
t.Helper()
|
||||
raw, _ := json.Marshal(payload)
|
||||
r := f.request("POST", "/api/notify/channels", string(raw))
|
||||
if r.Code != 200 {
|
||||
t.Fatalf("建渠道失败 %d: %s", r.Code, r.Body)
|
||||
}
|
||||
var res struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(r.Body.Bytes(), &res); err != nil || res.ID == 0 {
|
||||
t.Fatalf("建渠道返回异常: %s (%v)", r.Body, err)
|
||||
}
|
||||
t.Cleanup(func() { f.pg.Exec(`DELETE FROM notification_channels WHERE id=$1`, res.ID) })
|
||||
return res.ID
|
||||
}
|
||||
|
||||
// fakeWebhook 是记录收到的请求体的假接收端。
|
||||
type fakeWebhook struct {
|
||||
*httptest.Server
|
||||
mu sync.Mutex
|
||||
bodies []map[string]any
|
||||
}
|
||||
|
||||
func newFakeWebhook(t *testing.T) *fakeWebhook {
|
||||
t.Helper()
|
||||
f := &fakeWebhook{}
|
||||
f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
var body map[string]any
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
f.mu.Lock()
|
||||
f.bodies = append(f.bodies, body)
|
||||
f.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"errcode":0,"errmsg":"ok"}`)
|
||||
}))
|
||||
t.Cleanup(f.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeWebhook) count() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.bodies)
|
||||
}
|
||||
|
||||
func (f *fakeWebhook) body(t *testing.T, i int) map[string]any {
|
||||
t.Helper()
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if i >= len(f.bodies) {
|
||||
t.Fatalf("假接收端只收到 %d 条请求,取不到第 %d 条", len(f.bodies), i)
|
||||
}
|
||||
return f.bodies[i]
|
||||
}
|
||||
|
||||
func (f *fakeWebhook) last(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
if f.count() == 0 {
|
||||
t.Fatal("假接收端没有收到任何请求")
|
||||
}
|
||||
return f.body(t, f.count()-1)
|
||||
}
|
||||
|
||||
// markdownText 从请求体里取出正文,兼容各家的字段名差异:
|
||||
// 钉钉 markdown 用 `text`、ActionCard 用 `text`、企业微信 markdown 用 `content`。
|
||||
func markdownText(t *testing.T, body map[string]any) string {
|
||||
t.Helper()
|
||||
for _, key := range []string{"markdown", "actionCard"} {
|
||||
section, ok := body[key].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, field := range []string{"text", "content"} {
|
||||
if s, ok := section[field].(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("请求体里没有可识别的正文: %v", body)
|
||||
return ""
|
||||
}
|
||||
|
||||
// agePendingBatch 把该渠道的待发投递催老,用于测试汇总批次到期。
|
||||
func (f *notifyFixture) agePendingBatch(t *testing.T, chID int64) {
|
||||
t.Helper()
|
||||
if _, err := f.pg.Exec(`UPDATE notification_deliveries SET created_at = now() - interval '2 hours'
|
||||
WHERE channel_id=$1 AND state=$2`, chID, db.NotifyStatePending); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyEndToEndRealtimeDelivery(t *testing.T) {
|
||||
f := newNotifyFixture(t)
|
||||
hook := newFakeWebhook(t)
|
||||
chID := f.createChannel(t, map[string]any{
|
||||
"name": "实时推送",
|
||||
"kind": notify.KindDingTalk,
|
||||
"config": map[string]any{"webhook": hook.URL},
|
||||
})
|
||||
f.record(t, "SQL注入", "high")
|
||||
f.deliver(t, chID, "")
|
||||
|
||||
if hook.count() != 1 {
|
||||
t.Fatalf("应发出 1 条消息,实际 %d", hook.count())
|
||||
}
|
||||
text := markdownText(t, hook.last(t))
|
||||
for _, want := range []string{"SQL注入", "高危", "摘要"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("消息正文缺少 %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
// 投递应流转为 sent。
|
||||
var pending int
|
||||
if err := f.pg.QueryRow(`SELECT count(*) FROM notification_deliveries WHERE channel_id=$1 AND state <> $2`,
|
||||
chID, db.NotifyStateSent).Scan(&pending); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pending != 0 {
|
||||
t.Fatalf("投递后仍有 %d 条未标记 sent", pending)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyChannelAPIMasksSecretsAndPreservesOnUpdate(t *testing.T) {
|
||||
f := newNotifyFixture(t)
|
||||
chID := f.createChannel(t, map[string]any{
|
||||
"name": "掩码用例",
|
||||
"kind": notify.KindDingTalk,
|
||||
"config": map[string]any{"webhook": "https://oapi.dingtalk.com/robot/send?access_token=abc123456", "secret": "SECabcdef123456"},
|
||||
})
|
||||
|
||||
r := f.request("GET", "/api/notify/channels", "")
|
||||
if r.Code != 200 {
|
||||
t.Fatalf("列渠道失败 %d: %s", r.Code, r.Body)
|
||||
}
|
||||
if strings.Contains(r.Body.String(), "abc123456") || strings.Contains(r.Body.String(), "SECabcdef123456") {
|
||||
t.Fatalf("接口回显泄露了凭据: %s", r.Body)
|
||||
}
|
||||
var listed struct {
|
||||
Channels []struct {
|
||||
ID int64 `json:"id"`
|
||||
Config map[string]any `json:"config"`
|
||||
SecretKeys []string `json:"secret_keys"`
|
||||
} `json:"channels"`
|
||||
}
|
||||
if err := json.Unmarshal(r.Body.Bytes(), &listed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var mine *struct {
|
||||
ID int64 `json:"id"`
|
||||
Config map[string]any `json:"config"`
|
||||
SecretKeys []string `json:"secret_keys"`
|
||||
}
|
||||
for i := range listed.Channels {
|
||||
if listed.Channels[i].ID == chID {
|
||||
mine = &listed.Channels[i]
|
||||
}
|
||||
}
|
||||
if mine == nil {
|
||||
t.Fatal("新建的渠道未出现在列表里")
|
||||
}
|
||||
if !notify.IsMasked(fmt.Sprint(mine.Config["webhook"])) || !notify.IsMasked(fmt.Sprint(mine.Config["secret"])) {
|
||||
t.Fatalf("凭据字段应为掩码值: %v", mine.Config)
|
||||
}
|
||||
if len(mine.SecretKeys) == 0 {
|
||||
t.Fatal("接口应告知前端哪些字段是凭据")
|
||||
}
|
||||
|
||||
// PATCH 只改名 + 回传掩码凭据:真凭据必须原样保留。
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"name": "改名后",
|
||||
"config": map[string]any{"webhook": fmt.Sprint(mine.Config["webhook"]), "secret": fmt.Sprint(mine.Config["secret"])},
|
||||
})
|
||||
if r := f.request("PATCH", fmt.Sprintf("/api/notify/channels/%d", chID), string(body)); r.Code != 200 {
|
||||
t.Fatalf("更新失败 %d: %s", r.Code, r.Body)
|
||||
}
|
||||
cfg := f.channelConfig(t, chID)
|
||||
if cfg["webhook"] != "https://oapi.dingtalk.com/robot/send?access_token=abc123456" {
|
||||
t.Fatalf("掩码回传把真凭据覆盖了: %v", cfg["webhook"])
|
||||
}
|
||||
if cfg["secret"] != "SECabcdef123456" {
|
||||
t.Fatalf("掩码回传把 secret 覆盖了: %v", cfg["secret"])
|
||||
}
|
||||
if f.channel(t, chID).Name != "改名后" {
|
||||
t.Fatal("名字未更新")
|
||||
}
|
||||
|
||||
// 显式清空 secret 应生效(区别于「回传掩码=保持不变」)。
|
||||
body, _ = json.Marshal(map[string]any{"config": map[string]any{"secret": ""}})
|
||||
if r := f.request("PATCH", fmt.Sprintf("/api/notify/channels/%d", chID), string(body)); r.Code != 200 {
|
||||
t.Fatalf("清空 secret 失败 %d: %s", r.Code, r.Body)
|
||||
}
|
||||
if _, still := f.channelConfig(t, chID)["secret"]; still {
|
||||
t.Fatal("空串应清空 secret")
|
||||
}
|
||||
}
|
||||
|
||||
func (f *notifyFixture) channelConfig(t *testing.T, id int64) map[string]any {
|
||||
t.Helper()
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal(f.channel(t, id).Config, &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestNotifyChannelAPICreateValidation(t *testing.T) {
|
||||
f := newNotifyFixture(t)
|
||||
cases := []struct {
|
||||
name string
|
||||
payload map[string]any
|
||||
wantSub string
|
||||
}{
|
||||
{"类型非法", map[string]any{"name": "x", "kind": "nope", "config": map[string]any{}}, "渠道类型无效"},
|
||||
{"缺名称", map[string]any{"kind": notify.KindDingTalk, "config": map[string]any{"webhook": "https://e.com/h"}}, "缺少渠道名称"},
|
||||
{"缺 webhook", map[string]any{"name": "x", "kind": notify.KindDingTalk, "config": map[string]any{}}, "Webhook"},
|
||||
{"webhook 协议非法", map[string]any{"name": "x", "kind": notify.KindDingTalk, "config": map[string]any{"webhook": "file:///etc/passwd"}}, "Webhook 地址无效"},
|
||||
{"模式非法", map[string]any{"name": "x", "kind": notify.KindDingTalk, "mode": "sometimes", "config": map[string]any{"webhook": "https://e.com/h"}}, "推送模式无效"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
raw, _ := json.Marshal(tc.payload)
|
||||
r := f.request("POST", "/api/notify/channels", string(raw))
|
||||
if r.Code != 400 {
|
||||
t.Fatalf("应返回 400,得到 %d: %s", r.Code, r.Body)
|
||||
}
|
||||
if !strings.Contains(r.Body.String(), tc.wantSub) {
|
||||
t.Fatalf("错误信息应提到 %q,得到 %s", tc.wantSub, r.Body)
|
||||
}
|
||||
})
|
||||
}
|
||||
if r := f.request("DELETE", "/api/notify/channels/99999999", ""); r.Code != 404 {
|
||||
t.Fatalf("删除不存在的渠道应 404,得到 %d", r.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyFilterBlocksBelowThreshold(t *testing.T) {
|
||||
f := newNotifyFixture(t)
|
||||
hook := newFakeWebhook(t)
|
||||
chID := f.createChannel(t, map[string]any{
|
||||
"name": "仅严重",
|
||||
"kind": notify.KindDingTalk,
|
||||
"config": map[string]any{"webhook": hook.URL},
|
||||
"filter": map[string]any{"min_severity": "critical"},
|
||||
})
|
||||
f.record(t, "低危问题", "low")
|
||||
if _, _, err := f.pg.FanOutPendingEvents(context.Background(), 500); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var n int
|
||||
if err := f.pg.QueryRow(`SELECT count(*) FROM notification_deliveries WHERE channel_id=$1`, chID).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("低于阈值的漏洞不该产生投递,得到 %d 条", n)
|
||||
}
|
||||
f.n.stepRealtime(context.Background(), f.channel(t, chID), 50, "")
|
||||
if hook.count() != 0 {
|
||||
t.Fatal("被过滤的漏洞不应发出消息")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyDigestBatchesMultipleFindingsIntoOneMessage(t *testing.T) {
|
||||
f := newNotifyFixture(t)
|
||||
hook := newFakeWebhook(t)
|
||||
chID := f.createChannel(t, map[string]any{
|
||||
"name": "汇总推送",
|
||||
"kind": notify.KindDingTalk,
|
||||
"mode": db.NotifyModeDigest,
|
||||
"config": map[string]any{"webhook": hook.URL},
|
||||
})
|
||||
for i := 0; i < 3; i++ {
|
||||
f.record(t, fmt.Sprintf("汇总漏洞%d", i+1), "high")
|
||||
}
|
||||
ctx := context.Background()
|
||||
if _, _, err := f.pg.FanOutPendingEvents(ctx, 500); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ch := f.channel(t, chID)
|
||||
|
||||
// 未到期:不发。
|
||||
f.n.stepDigest(ctx, ch, 50, "")
|
||||
if hook.count() != 0 {
|
||||
t.Fatal("汇总批次未到期就发了")
|
||||
}
|
||||
|
||||
// 催老批次后:三条合成一条消息。
|
||||
f.agePendingBatch(t, chID)
|
||||
f.n.stepDigest(ctx, ch, 50, "")
|
||||
if got := hook.count(); got != 1 {
|
||||
t.Fatalf("三条应汇总成一条消息,实际发了 %d 条", got)
|
||||
}
|
||||
text := markdownText(t, hook.last(t))
|
||||
if !strings.Contains(text, "近") || !strings.Contains(text, "3 个漏洞") {
|
||||
t.Fatalf("汇总消息缺少条数/时间窗文案:\n%s", text)
|
||||
}
|
||||
for i := 1; i <= 3; i++ {
|
||||
if !strings.Contains(text, fmt.Sprintf("汇总漏洞%d", i)) {
|
||||
t.Fatalf("汇总消息缺少第 %d 条:\n%s", i, text)
|
||||
}
|
||||
}
|
||||
// 同一批次应共享 batch_id。
|
||||
var distinct, total int
|
||||
if err := f.pg.QueryRow(`SELECT count(DISTINCT batch_id), count(*) FROM notification_deliveries WHERE channel_id=$1`, chID).Scan(&distinct, &total); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 3 || distinct != 1 {
|
||||
t.Fatalf("三条投递应共享一个 batch_id,得到 distinct=%d total=%d", distinct, total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyDisabledChannelDoesNotSend(t *testing.T) {
|
||||
f := newNotifyFixture(t)
|
||||
hook := newFakeWebhook(t)
|
||||
chID := f.createChannel(t, map[string]any{
|
||||
"name": "停用渠道",
|
||||
"kind": notify.KindDingTalk,
|
||||
"enabled": false,
|
||||
"config": map[string]any{"webhook": hook.URL},
|
||||
})
|
||||
f.record(t, "停用期间的漏洞", "critical")
|
||||
if _, _, err := f.pg.FanOutPendingEvents(context.Background(), 500); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var n int
|
||||
if err := f.pg.QueryRow(`SELECT count(*) FROM notification_deliveries WHERE channel_id=$1`, chID).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("停用渠道不该产生投递,得到 %d 条", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyStatusChangeDelivery(t *testing.T) {
|
||||
f := newNotifyFixture(t)
|
||||
hook := newFakeWebhook(t)
|
||||
chID := f.createChannel(t, map[string]any{
|
||||
"name": "状态变更订阅",
|
||||
"kind": notify.KindDingTalk,
|
||||
"config": map[string]any{"webhook": hook.URL},
|
||||
"filter": map[string]any{"on_status_change": true},
|
||||
})
|
||||
finding := f.record(t, "状态变更用例", "high")
|
||||
r := f.request("PATCH", fmt.Sprintf("/api/exploration/findings/%d", finding), `{"status":"fixed"}`)
|
||||
if r.Code != 200 {
|
||||
t.Fatalf("改状态失败 %d: %s", r.Code, r.Body)
|
||||
}
|
||||
f.deliver(t, chID, "")
|
||||
|
||||
// 应有两条:fixed 那一条是状态变更;finding_created 那条也可能在同一轮发出。
|
||||
// 状态变更的实际上更晚创建,但不依赖顺序,全量找。
|
||||
found := false
|
||||
for i := 0; i < hook.count(); i++ {
|
||||
text := markdownText(t, hook.body(t, i))
|
||||
if strings.Contains(text, "状态变更") && strings.Contains(text, "已修复") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("没有收到含「状态变更 → 已修复」的消息(共 %d 条)", hook.count())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyStatusChangeSuppressedByDefault(t *testing.T) {
|
||||
f := newNotifyFixture(t)
|
||||
hook := newFakeWebhook(t)
|
||||
chID := f.createChannel(t, map[string]any{
|
||||
"name": "不订阅状态变更",
|
||||
"kind": notify.KindDingTalk,
|
||||
"config": map[string]any{"webhook": hook.URL},
|
||||
})
|
||||
finding := f.record(t, "不订阅变更", "high")
|
||||
if r := f.request("PATCH", fmt.Sprintf("/api/exploration/findings/%d", finding), `{"status":"false_positive"}`); r.Code != 200 {
|
||||
t.Fatalf("改状态失败 %d: %s", r.Code, r.Body)
|
||||
}
|
||||
if _, _, err := f.pg.FanOutPendingEvents(context.Background(), 500); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var n int
|
||||
if err := f.pg.QueryRow(`SELECT count(*) FROM notification_deliveries d
|
||||
JOIN notification_events e ON e.id = d.event_id
|
||||
WHERE d.channel_id=$1 AND e.kind=$2`, chID, notify.EventFindingStatusChanged).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("未订阅状态变更的渠道不该收到状态变更投递,得到 %d 条", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyTestMessageEndpoint(t *testing.T) {
|
||||
f := newNotifyFixture(t)
|
||||
hook := newFakeWebhook(t)
|
||||
chID := f.createChannel(t, map[string]any{
|
||||
"name": "测试发送",
|
||||
"kind": notify.KindDingTalk,
|
||||
"config": map[string]any{"webhook": hook.URL},
|
||||
})
|
||||
if r := f.request("POST", fmt.Sprintf("/api/notify/channels/%d/test", chID), ""); r.Code != 200 {
|
||||
t.Fatalf("测试发送失败 %d: %s", r.Code, r.Body)
|
||||
}
|
||||
if hook.count() != 1 {
|
||||
t.Fatalf("假接收端应收到 1 条测试消息,得到 %d", hook.count())
|
||||
}
|
||||
// 测试消息必须一眼能看出是测试,不能被误当成真实漏洞。
|
||||
if text := markdownText(t, hook.last(t)); !strings.Contains(text, "测试") {
|
||||
t.Fatalf("测试消息应标明是测试: %s", text)
|
||||
}
|
||||
// 配置坏掉时应把渠道的原始错误如实回给用户。
|
||||
badID := f.createChannel(t, map[string]any{
|
||||
"name": "坏地址",
|
||||
"kind": notify.KindDingTalk,
|
||||
"config": map[string]any{"webhook": "http://127.0.0.1:1/hook"},
|
||||
})
|
||||
if r := f.request("POST", fmt.Sprintf("/api/notify/channels/%d/test", badID), ""); r.Code != 502 {
|
||||
t.Fatalf("投递失败应回 502,得到 %d: %s", r.Code, r.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyDeliveriesHistoryAndRetry(t *testing.T) {
|
||||
f := newNotifyFixture(t)
|
||||
// 指向必然失败的地址,制造 failed 投递。
|
||||
chID := f.createChannel(t, map[string]any{
|
||||
"name": "失败重试",
|
||||
"kind": notify.KindDingTalk,
|
||||
"config": map[string]any{"webhook": "http://127.0.0.1:1/hook"},
|
||||
})
|
||||
f.record(t, "会失败的推送", "high")
|
||||
ctx := context.Background()
|
||||
if _, _, err := f.pg.FanOutPendingEvents(ctx, 500); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ch := f.channel(t, chID)
|
||||
// 连投到耗尽重试预算。
|
||||
for i := 0; i < db.MaxNotifyAttempts; i++ {
|
||||
f.n.stepRealtime(ctx, ch, 50, "")
|
||||
if _, err := f.pg.Exec(`UPDATE notification_deliveries SET next_attempt_at = now() - interval '1 minute' WHERE channel_id=$1`, chID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var state string
|
||||
if err := f.pg.QueryRow(`SELECT state FROM notification_deliveries WHERE channel_id=$1`, chID).Scan(&state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state != db.NotifyStateFailed {
|
||||
t.Fatalf("重试耗尽后应为 failed,得到 %s", state)
|
||||
}
|
||||
|
||||
r := f.request("GET", fmt.Sprintf("/api/notify/deliveries?channel_id=%d&state=failed", chID), "")
|
||||
if r.Code != 200 {
|
||||
t.Fatalf("查历史失败 %d: %s", r.Code, r.Body)
|
||||
}
|
||||
var hist struct {
|
||||
Deliveries []struct {
|
||||
ID int64 `json:"id"`
|
||||
State string `json:"state"`
|
||||
LastError string `json:"last_error"`
|
||||
Attempts int `json:"attempts"`
|
||||
Title string `json:"title"`
|
||||
} `json:"deliveries"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(r.Body.Bytes(), &hist); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hist.Total != 1 || len(hist.Deliveries) != 1 {
|
||||
t.Fatalf("应查到 1 条失败投递,得到 total=%d len=%d", hist.Total, len(hist.Deliveries))
|
||||
}
|
||||
if hist.Deliveries[0].LastError == "" {
|
||||
t.Fatal("历史里应带上失败原因,否则用户无法排查")
|
||||
}
|
||||
if hist.Deliveries[0].Attempts < db.MaxNotifyAttempts {
|
||||
t.Fatalf("尝试次数应被记录,得到 %d", hist.Deliveries[0].Attempts)
|
||||
}
|
||||
if hist.Deliveries[0].Title != "会失败的推送" {
|
||||
t.Fatalf("历史应带出漏洞标题,得到 %q", hist.Deliveries[0].Title)
|
||||
}
|
||||
|
||||
// 手动重发:应回到 pending 且清零计数。
|
||||
if r := f.request("POST", fmt.Sprintf("/api/notify/deliveries/%d/retry", hist.Deliveries[0].ID), ""); r.Code != 200 {
|
||||
t.Fatalf("重发失败 %d: %s", r.Code, r.Body)
|
||||
}
|
||||
var attempts int
|
||||
if err := f.pg.QueryRow(`SELECT state, attempts FROM notification_deliveries WHERE id=$1`, hist.Deliveries[0].ID).Scan(&state, &attempts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state != db.NotifyStatePending || attempts != 0 {
|
||||
t.Fatalf("重发后应为 pending 且 attempts=0,得到 %s/%d", state, attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyMetaAndSettingsRoundTrip(t *testing.T) {
|
||||
f := newNotifyFixture(t)
|
||||
r := f.request("GET", "/api/notify/meta", "")
|
||||
if r.Code != 200 {
|
||||
t.Fatalf("meta 失败: %s", r.Body)
|
||||
}
|
||||
var meta struct {
|
||||
Kinds []struct {
|
||||
Kind string `json:"kind"`
|
||||
SecretKeys []string `json:"secret_keys"`
|
||||
} `json:"kinds"`
|
||||
}
|
||||
if err := json.Unmarshal(r.Body.Bytes(), &meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(meta.Kinds) != len(notify.Kinds()) {
|
||||
t.Fatalf("meta 应列出全部 %d 个渠道,得到 %d", len(notify.Kinds()), len(meta.Kinds))
|
||||
}
|
||||
for _, k := range meta.Kinds {
|
||||
if len(k.SecretKeys) == 0 {
|
||||
t.Errorf("渠道 %s 未上报凭据字段", k.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
// 三项全局设置往返。尾部斜杠应被规范化掉,否则回链会拼出 "//function/..."。
|
||||
if r := f.request("PUT", "/api/settings", `{"notify_public_base_url":"https://artex.example.com/","notify_digest_interval_min":15,"notify_enabled":true}`); r.Code != 200 {
|
||||
t.Fatalf("写设置失败 %d: %s", r.Code, r.Body)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
f.pg.Exec(`DELETE FROM settings WHERE key IN ($1,$2)`, settingNotifyPublicBaseURL, settingNotifyDigestMinutes)
|
||||
})
|
||||
payload := f.s.settingsPayload()
|
||||
if payload["notify_public_base_url"] != "https://artex.example.com" {
|
||||
t.Fatalf("回链地址未规范化: %v", payload["notify_public_base_url"])
|
||||
}
|
||||
if payload["notify_digest_interval_min"] != 15 {
|
||||
t.Fatalf("汇总周期未生效: %v", payload["notify_digest_interval_min"])
|
||||
}
|
||||
|
||||
// 非法值应被拒。
|
||||
for _, body := range []string{
|
||||
`{"notify_public_base_url":"ftp://x"}`,
|
||||
`{"notify_digest_interval_min":0}`,
|
||||
`{"notify_digest_interval_min":99999}`,
|
||||
} {
|
||||
if r := f.request("PUT", "/api/settings", body); r.Code != 400 {
|
||||
t.Errorf("%s 应返回 400,得到 %d", body, r.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyDeepLinkUsesPublicBaseURL 覆盖回链拼接:配了 public_base_url 时
|
||||
// 单条消息必须用带按钮的 ActionCard,且链接指向漏洞详情页。
|
||||
func TestNotifyDeepLinkUsesPublicBaseURL(t *testing.T) {
|
||||
f := newNotifyFixture(t)
|
||||
hook := newFakeWebhook(t)
|
||||
chID := f.createChannel(t, map[string]any{
|
||||
"name": "回链",
|
||||
"kind": notify.KindDingTalk,
|
||||
"config": map[string]any{"webhook": hook.URL},
|
||||
})
|
||||
finding := f.record(t, "带回链的漏洞", "high")
|
||||
f.deliver(t, chID, "https://artex.example.com")
|
||||
|
||||
body := hook.last(t)
|
||||
card, _ := body["actionCard"].(map[string]any)
|
||||
if card == nil {
|
||||
t.Fatalf("有回链时应用 ActionCard,得到 msgtype=%v", body["msgtype"])
|
||||
}
|
||||
want := fmt.Sprintf("https://artex.example.com/function/findings/detail?id=%d", finding)
|
||||
if card["singleURL"] != want {
|
||||
t.Fatalf("回链不对\n期望 %s\n得到 %v", want, card["singleURL"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyNoDeepLinkWithoutBaseURL 反向覆盖:没配外部地址时不该产生坏链接
|
||||
// (比如指向 localhost 或相对路径),应退回纯 markdown。
|
||||
func TestNotifyNoDeepLinkWithoutBaseURL(t *testing.T) {
|
||||
f := newNotifyFixture(t)
|
||||
hook := newFakeWebhook(t)
|
||||
chID := f.createChannel(t, map[string]any{
|
||||
"name": "无回链",
|
||||
"kind": notify.KindDingTalk,
|
||||
"config": map[string]any{"webhook": hook.URL},
|
||||
})
|
||||
f.record(t, "无回链的漏洞", "high")
|
||||
f.deliver(t, chID, "")
|
||||
|
||||
body := hook.last(t)
|
||||
if body["msgtype"] != "markdown" {
|
||||
t.Fatalf("未配外部地址时应发 markdown,得到 %v", body["msgtype"])
|
||||
}
|
||||
if text := markdownText(t, body); strings.Contains(text, "查看详情") {
|
||||
t.Fatalf("未配外部地址时不该出现详情链接:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyDigestSegmentsAndDefersRemainder 是「静默丢失」修复的端到端证据。
|
||||
//
|
||||
// 汇总消息受渠道长度上限约束(企微 4096 字节),一批装不下时必须**按整条**切分:
|
||||
// 装进本条的那些标记已送达,其余回到队列等下一条。曾经的实现是把整批标记
|
||||
// 成功——被截掉的那些既不在消息里、也不在失败列表里,投递历史还显示成功,
|
||||
// 漏洞就这么没了。
|
||||
//
|
||||
// 断言四件事:① 只标记了实际装下的条数 ② 其余仍是待发 ③ 被推迟的条目
|
||||
// **没有消耗重试次数** ④ 再跑一轮能把剩下的发出去(不会卡死)。
|
||||
func TestNotifyDigestSegmentsAndDefersRemainder(t *testing.T) {
|
||||
f := newNotifyFixture(t)
|
||||
hook := newFakeWebhook(t)
|
||||
// 用企业微信:markdown 上限 4096 字节,是六个渠道里最紧的。
|
||||
chID := f.createChannel(t, map[string]any{
|
||||
"name": "分段汇总",
|
||||
"kind": notify.KindWeCom,
|
||||
"mode": db.NotifyModeDigest,
|
||||
"config": map[string]any{"webhook": hook.URL},
|
||||
})
|
||||
const total = 60
|
||||
// 标题取长一点,保证 60 条远超 4096 字节,必然分段。
|
||||
longName := strings.Repeat("超长漏洞名称", 6)
|
||||
for i := 0; i < total; i++ {
|
||||
f.record(t, longName+strconv.Itoa(i+1), "high")
|
||||
}
|
||||
ctx := context.Background()
|
||||
if _, _, err := f.pg.FanOutPendingEvents(ctx, 500); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.agePendingBatch(t, chID)
|
||||
ch := f.channel(t, chID)
|
||||
|
||||
f.n.stepDigest(ctx, ch, 50, "")
|
||||
if hook.count() != 1 {
|
||||
t.Fatalf("应只发出一条消息,得到 %d", hook.count())
|
||||
}
|
||||
|
||||
var sent, pending int
|
||||
if err := f.pg.QueryRow(`SELECT
|
||||
count(*) FILTER (WHERE state=$2),
|
||||
count(*) FILTER (WHERE state=$3)
|
||||
FROM notification_deliveries WHERE channel_id=$1`, chID, db.NotifyStateSent, db.NotifyStatePending).
|
||||
Scan(&sent, &pending); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sent == 0 {
|
||||
t.Fatal("应有条目被标记为已送达")
|
||||
}
|
||||
if pending == 0 {
|
||||
t.Fatalf("一批 %d 条不可能全装进 4096 字节,应有剩余待发;sent=%d", total, sent)
|
||||
}
|
||||
if sent+pending != total {
|
||||
t.Fatalf("条目数对不上:sent=%d pending=%d total=%d(既没送达也没待发=丢失)", sent, pending, total)
|
||||
}
|
||||
// 消息正文必须如实告知还有多少条没包含在本条里。
|
||||
if text := markdownText(t, hook.last(t)); !strings.Contains(text, "其余") {
|
||||
t.Fatalf("消息应说明还有条目未包含在本条:\n%.400s", text)
|
||||
}
|
||||
|
||||
// 被推迟的条目不得消耗重试预算:领取时 attempts 已乐观 +1,推迟时要减回去。
|
||||
var maxAttempts int
|
||||
if err := f.pg.QueryRow(`SELECT COALESCE(max(attempts),0) FROM notification_deliveries
|
||||
WHERE channel_id=$1 AND state=$2`, chID, db.NotifyStatePending).Scan(&maxAttempts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if maxAttempts > 0 {
|
||||
t.Fatalf("被推迟的条目不该消耗重试次数(否则几条之后就会被判失败),得到 attempts=%d", maxAttempts)
|
||||
}
|
||||
|
||||
// 反复跑直到收敛。断言的是**最终全部送达**且中途确实分了多轮——
|
||||
// 这比「第二轮发完」更强:它证明分段不会卡死、也不会把剩余条目丢掉。
|
||||
rounds := 0
|
||||
for {
|
||||
var undelivered int
|
||||
if err := f.pg.QueryRow(`SELECT count(*) FROM notification_deliveries
|
||||
WHERE channel_id=$1 AND state <> $2 AND state <> $3`, chID, db.NotifyStateSent, db.NotifyStateFailed).
|
||||
Scan(&undelivered); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if undelivered == 0 {
|
||||
break
|
||||
}
|
||||
rounds++
|
||||
if rounds > total+5 {
|
||||
t.Fatalf("分段投递不收敛:跑了 %d 轮仍有 %d 条悬而未决", rounds, undelivered)
|
||||
}
|
||||
before := hook.count()
|
||||
f.n.stepDigest(ctx, ch, 50, "")
|
||||
if hook.count() == before {
|
||||
t.Fatalf("第 %d 轮没有任何进展,剩余 %d 条会永久卡住", rounds, undelivered)
|
||||
}
|
||||
}
|
||||
if rounds < 2 {
|
||||
t.Fatalf("一条 4096 字节的消息装不下 %d 条长标题漏洞,应分多轮发出,实际只用了 %d 轮", total, rounds)
|
||||
}
|
||||
// 首轮之后的每一轮都应是**纯续发**,不存在被渠道拒绝的条目。
|
||||
var failed int
|
||||
if err := f.pg.QueryRow(`SELECT count(*) FROM notification_deliveries WHERE channel_id=$1 AND state=$2`,
|
||||
chID, db.NotifyStateFailed).Scan(&failed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failed != 0 {
|
||||
t.Fatalf("假接收端始终返回成功,不该有失败条目,得到 %d", failed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyBackoffTableMatchesAttemptBudget 是防漂移断言。
|
||||
//
|
||||
// 重试预算(db.MaxNotifyAttempts)与退避序列表(notifyBackoff)分居两个包:
|
||||
// 前者是状态机的策略、后者是引擎的执行节拍。若只改其中一个——比如把预算提到 5
|
||||
// 次而忘了加退避档位——代码不会报错,只会让第 4、5 次重试沿用最后一档间隔,
|
||||
// 表现为「重试节奏莫名变慢」,排查时很难联想到是这里。
|
||||
// 断言两者长度一致,让这种漂移在 CI 里就暴露。
|
||||
func TestNotifyBackoffTableMatchesAttemptBudget(t *testing.T) {
|
||||
if len(notifyBackoff) != db.MaxNotifyAttempts {
|
||||
t.Fatalf("退避档位数(%d)与最大尝试次数(%d)不一致——改一个必须同时改另一个",
|
||||
len(notifyBackoff), db.MaxNotifyAttempts)
|
||||
}
|
||||
// 退避间隔必须单调不减,否则重试会越试越急,反而加剧限流。
|
||||
for i := 1; i < len(notifyBackoff); i++ {
|
||||
if notifyBackoff[i] < notifyBackoff[i-1] {
|
||||
t.Fatalf("退避间隔必须单调不减:第 %d 档 %v < 第 %d 档 %v",
|
||||
i, notifyBackoff[i], i-1, notifyBackoff[i-1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyRateLimitDoesNotConsumeRetryBudget 锁住「先取令牌再领取」的顺序。
|
||||
// 若反了(先领后弃),被限流挡下的投递已经计过一次 attempts,
|
||||
// 预算会被纯粹的等待耗光,最后落进 failed。
|
||||
func TestNotifyRateLimitDoesNotConsumeRetryBudget(t *testing.T) {
|
||||
// 只测令牌桶本身,不需要 Server(也不该为它造一个)。
|
||||
n := &Notifier{buckets: map[int64]*notifyBucket{}}
|
||||
now := time.Now()
|
||||
// 每分钟 1 条:满桶时最多 1 条。
|
||||
if got := n.takeTokens(1, 1, now); got != 1 {
|
||||
t.Fatalf("满桶时每分钟 1 条应取 1 个令牌,得到 %d", got)
|
||||
}
|
||||
if got := n.takeTokens(1, 1, now.Add(time.Millisecond)); got != 0 {
|
||||
t.Fatalf("令牌耗尽后应立即返回 0,得到 %d", got)
|
||||
}
|
||||
if got := n.takeTokens(1, 1, now.Add(30*time.Second)); got != 0 {
|
||||
t.Fatalf("半程不应补满一个令牌,得到 %d", got)
|
||||
}
|
||||
if got := n.takeTokens(1, 1, now.Add(time.Minute)); got != 1 {
|
||||
t.Fatalf("满一个周期应补回 1 个令牌,得到 %d", got)
|
||||
}
|
||||
// 不限流渠道走有限上限,避免单轮被无限积压拖住。
|
||||
if got := n.takeTokens(2, 0, now); got != notifyUnlimitedBurstPerTick {
|
||||
t.Fatalf("不限流应返回每轮上限 %d,得到 %d", notifyUnlimitedBurstPerTick, got)
|
||||
}
|
||||
// 渠道之间的令牌桶互相独立。
|
||||
if got := n.takeTokens(1, 1, now.Add(time.Millisecond)); got != 0 {
|
||||
t.Fatalf("渠道 1 的桶应仍然为空,得到 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyTickBudgetFitsWithinLease 是又一条防漂移断言。
|
||||
//
|
||||
// 单渠道每轮的投递条数上限(notifyMaxSendsPerChannelPerTick)是从租约时长倒推的:
|
||||
// 一轮里串行投递的最坏耗时必须 < 租约,否则后几条还没发完租约就过期,
|
||||
// 多实例部署时对端会把它们重新领走、重复发送。这三个常量分处不同位置,
|
||||
// 改任意一个都可能打破关系而不会有任何报错——所以在这里钉死。
|
||||
func TestNotifyTickBudgetFitsWithinLease(t *testing.T) {
|
||||
worst := time.Duration(notifyMaxSendsPerChannelPerTick) * notifySendTimeout
|
||||
if worst >= notifyLease {
|
||||
t.Fatalf("单渠道一轮的最坏耗时 %v 不应达到或超过租约 %v"+
|
||||
"(notifyMaxSendsPerChannelPerTick=%d × notifySendTimeout=%v)——"+
|
||||
"改这三个常量中的任意一个都要同步检查另外两个",
|
||||
worst, notifyLease, notifyMaxSendsPerChannelPerTick, notifySendTimeout)
|
||||
}
|
||||
}
|
||||
+82
-2
@@ -228,6 +228,9 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
|
||||
go s.evidenceStore().RunGC(s.ctx)
|
||||
s.seedPythonInterpreter() // 自定义脚本工具:开机检测 python 解释器入库(仅空时)
|
||||
go newScheduler(s).Run(s.ctx) // P3 触发器调度(定时/finding/目标事件),仅自定义 agent
|
||||
// 漏洞 IM 推送投递引擎。与 Scheduler 并列但独立:推送的实时性要求(3s)
|
||||
// 与触发器的业务节奏不同,且两者失败互不牵连——推送卡住不该影响 agent 触发。
|
||||
go newNotifier(s).Run(s.ctx)
|
||||
// Fill the tool cache for any enabled MCP that has none yet (notably the
|
||||
// seeded browser MCP on first run). Async so it never blocks startup.
|
||||
go s.discoverEmptyMCPsOnStartup()
|
||||
@@ -796,6 +799,16 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/llm/records/{id}", s.pgGetLLMRecord)
|
||||
mux.HandleFunc("GET /api/settings", s.getSettings)
|
||||
mux.HandleFunc("PUT /api/settings", s.putSettings)
|
||||
// 漏洞 IM 推送。渠道是「多实例 + 各自过滤规则」的资源,因此独立成一组
|
||||
// REST 接口,而不是塞进扁平的 /api/settings 键值里。
|
||||
mux.HandleFunc("GET /api/notify/meta", s.notifyMeta)
|
||||
mux.HandleFunc("GET /api/notify/channels", s.notifyListChannels)
|
||||
mux.HandleFunc("POST /api/notify/channels", s.notifyCreateChannel)
|
||||
mux.HandleFunc("PATCH /api/notify/channels/{id}", s.notifyUpdateChannel)
|
||||
mux.HandleFunc("DELETE /api/notify/channels/{id}", s.notifyDeleteChannel)
|
||||
mux.HandleFunc("POST /api/notify/channels/{id}/test", s.notifyTestChannel)
|
||||
mux.HandleFunc("GET /api/notify/deliveries", s.notifyListDeliveries)
|
||||
mux.HandleFunc("POST /api/notify/deliveries/{id}/retry", s.notifyRetryDelivery)
|
||||
mux.HandleFunc("POST /api/settings/web-search/test", s.testWebSearch)
|
||||
mux.HandleFunc("GET /api/report", s.getReport)
|
||||
mux.HandleFunc("GET /api/chat/mentions", s.searchChatMentions)
|
||||
@@ -2266,15 +2279,21 @@ func (s *Server) patchFinding(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 400, "bad status: "+*body.Status)
|
||||
return
|
||||
}
|
||||
n, err := s.m.pg.SetFindingStatus(id, *body.Status)
|
||||
// 走带通知的版本:状态更新与「状态变更推送事件」在同一事务里落库,
|
||||
// 避免出现状态已改而推送事件丢失的窗口。事件登记失败不影响状态更新,
|
||||
// 所以只记日志、不向调用方报错。
|
||||
from, found, notified, err := s.m.pg.SetFindingStatusWithNotify(r.Context(), id, *body.Status)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
if !found {
|
||||
writeErr(w, 404, "finding not found")
|
||||
return
|
||||
}
|
||||
if !notified && from != *body.Status {
|
||||
log.Printf("[notify] 状态变更事件未登记 finding=%d %s→%s(状态已更新)", id, from, *body.Status)
|
||||
}
|
||||
}
|
||||
if body.Severity != nil {
|
||||
if !db.ValidSeverity(*body.Severity) {
|
||||
@@ -3293,9 +3312,34 @@ func (s *Server) settingsPayload() map[string]any {
|
||||
// 实验功能:noa 模型驱动上下文压缩(默认关)。开启后平台接入的四类 agent 由 noa
|
||||
// 接管上下文压缩,取代内置 compaction;每 run 读一次,对之后启动的 run 生效。
|
||||
"noa_compaction": s.m.NoaCompactionEnabled(),
|
||||
// 漏洞 IM 推送的全局项。渠道本身是独立资源,走 /api/notify/* 管理;
|
||||
// 这里只放「作用于全部渠道」的三项。
|
||||
"notify_enabled": s.m.pg.GetBool(settingNotifyEnabled, true),
|
||||
"notify_public_base_url": notifyPublicBaseURL(s.m.pg),
|
||||
"notify_digest_interval_min": notifyDigestIntervalMin(s.m.pg),
|
||||
}
|
||||
}
|
||||
|
||||
// notifyPublicBaseURL 读推送回链用的外部地址。
|
||||
func notifyPublicBaseURL(pg *db.DB) string {
|
||||
v, _, _ := pg.GetSetting(settingNotifyPublicBaseURL)
|
||||
return v
|
||||
}
|
||||
|
||||
// notifyDigestIntervalMin 读汇总周期(分钟),非法或未配置时回落到默认值。
|
||||
// 回显默认值而不是空串,UI 才能把当前生效值填进输入框。
|
||||
func notifyDigestIntervalMin(pg *db.DB) int {
|
||||
v, ok, _ := pg.GetSetting(settingNotifyDigestMinutes)
|
||||
if !ok {
|
||||
return notifyDefaultDigestMinutes
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil || n <= 0 {
|
||||
return notifyDefaultDigestMinutes
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// pgDetectPython re-runs interpreter detection, stores + returns it.
|
||||
func (s *Server) pgDetectPython(w http.ResponseWriter, r *http.Request) {
|
||||
p := detectPython()
|
||||
@@ -3340,6 +3384,11 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
|
||||
ConstraintsInjectWorker *bool `json:"constraints_inject_worker"`
|
||||
// 实验功能:noa 上下文压缩开关(默认关);每 run 读,对之后启动的 run 生效,无需重建 agent。
|
||||
NoaCompaction *bool `json:"noa_compaction"`
|
||||
// 漏洞 IM 推送的全局项。三者都由投递引擎每轮读一次,改完即时生效,
|
||||
// 不需要重建 agent 或重启。
|
||||
NotifyEnabled *bool `json:"notify_enabled"`
|
||||
NotifyBaseURL *string `json:"notify_public_base_url"`
|
||||
NotifyDigestMins *int `json:"notify_digest_interval_min"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
@@ -3364,6 +3413,37 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// 推送全局项:投递引擎每轮重新读取,所以即时生效、无需重启。
|
||||
if req.NotifyEnabled != nil {
|
||||
if err := s.m.pg.SetBool(settingNotifyEnabled, *req.NotifyEnabled); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.NotifyBaseURL != nil {
|
||||
// 统一裁掉尾部斜杠:回链拼接用的是 fmt.Sprintf("%s/function/..."),
|
||||
// 留着尾部斜杠会产出 "//function/..." 这种双斜杠路径。
|
||||
base := trimTrailingSlash(strings.TrimSpace(*req.NotifyBaseURL))
|
||||
if base != "" && !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
|
||||
writeErr(w, 400, "回链地址需以 http:// 或 https:// 开头")
|
||||
return
|
||||
}
|
||||
if err := s.m.pg.SetSetting(settingNotifyPublicBaseURL, base); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.NotifyDigestMins != nil {
|
||||
// 下限 1 分钟:更短的周期等于实时推送,那样应该直接把渠道改成 realtime 模式。
|
||||
if *req.NotifyDigestMins < 1 || *req.NotifyDigestMins > 24*60 {
|
||||
writeErr(w, 400, "汇总周期需在 1 到 1440 分钟之间")
|
||||
return
|
||||
}
|
||||
if err := s.m.pg.SetSetting(settingNotifyDigestMinutes, strconv.Itoa(*req.NotifyDigestMins)); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Workers != nil {
|
||||
if err := s.m.SetWorkers(*req.Workers); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// 渠道字段表与配置值的解析工具。
|
||||
//
|
||||
// 与页面拆开是因为这一份是**数据**而不是视图:它描述每种渠道有哪些字段、
|
||||
// 各自该用什么控件,以及表单文本到配置值(JSON)的双向转换。
|
||||
// 单独放一个文件后,新增渠道只需要动这里,页面本身不必改。
|
||||
// 渠道类型的展示名与简介。放在前端是因为它只影响文案,后端不需要知道。
|
||||
export const KIND_LABEL: Record<string, string> = {
|
||||
dingtalk: "钉钉",
|
||||
feishu: "飞书",
|
||||
wecom: "企业微信",
|
||||
webhook: "通用 Webhook",
|
||||
telegram: "Telegram",
|
||||
email: "邮件",
|
||||
};
|
||||
|
||||
// 各渠道的配置字段定义。
|
||||
//
|
||||
// 这里刻意保留一份前端字段表,而不是让后端下发 schema:后端只负责
|
||||
// Validate(必填/格式),UI 需要的是布局与控件类型,两者关注的不是同一件事。
|
||||
// 唯一的耦合点是 secret_keys —— 哪些字段该渲染成密码框由后端给出,
|
||||
// 因为只有渠道实现自己清楚哪些值算凭据(企业微信的整个 Webhook 就是凭据,
|
||||
// 而钉钉的只是其中一个 secret)。新增渠道时这里少一个条目只会让表单变空白,
|
||||
// 不会静默出错(下面的 hasFields 会提示)。
|
||||
export type FieldKind = "text" | "password" | "number" | "select" | "textarea" | "switch" | "kv" | "list";
|
||||
export interface FieldDef {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: FieldKind;
|
||||
placeholder?: string;
|
||||
help?: string;
|
||||
options?: { value: string; label: string }[];
|
||||
}
|
||||
export const CHANNEL_FIELDS: Record<string, FieldDef[]> = {
|
||||
dingtalk: [
|
||||
{
|
||||
key: "webhook",
|
||||
label: "Webhook 地址",
|
||||
kind: "text",
|
||||
placeholder: "https://oapi.dingtalk.com/robot/send?access_token=...",
|
||||
},
|
||||
{
|
||||
key: "secret",
|
||||
label: "加签密钥",
|
||||
kind: "password",
|
||||
help: "机器人安全设置选「加签」时填写;选「自定义关键词」或未开启安全设置则留空",
|
||||
},
|
||||
],
|
||||
feishu: [
|
||||
{
|
||||
key: "webhook",
|
||||
label: "Webhook 地址",
|
||||
kind: "text",
|
||||
placeholder: "https://open.feishu.cn/open-apis/bot/v2/hook/...",
|
||||
},
|
||||
{ key: "secret", label: "签名校验密钥", kind: "password", help: "机器人开启「签名校验」时填写,否则留空" },
|
||||
],
|
||||
wecom: [
|
||||
{
|
||||
key: "webhook",
|
||||
label: "Webhook 地址",
|
||||
kind: "text",
|
||||
placeholder: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...",
|
||||
},
|
||||
],
|
||||
webhook: [
|
||||
{ key: "url", label: "目标 URL", kind: "text", placeholder: "https://your-endpoint.example.com/hook" },
|
||||
{
|
||||
key: "method",
|
||||
label: "请求方法",
|
||||
kind: "select",
|
||||
options: [
|
||||
{ value: "POST", label: "POST(带请求体)" },
|
||||
{ value: "PUT", label: "PUT(带请求体)" },
|
||||
{ value: "PATCH", label: "PATCH(带请求体)" },
|
||||
{ value: "GET", label: "GET(不带请求体)" },
|
||||
],
|
||||
},
|
||||
{ key: "headers", label: "自定义请求头", kind: "kv", help: "每行 KEY=VALUE,例如 Authorization=Bearer xxx" },
|
||||
{
|
||||
key: "body_template",
|
||||
label: "请求体模板",
|
||||
kind: "textarea",
|
||||
help:
|
||||
"留空用内置默认模板。变量:{{.Title}} {{.Batch}} {{.Count}} {{.HomeURL}} {{.SentAt}}," +
|
||||
"以及 range .Items 下的 .Name/.VulnClass/.Severity/.Summary/.Assets/.DetailURL/.StatusLabel。" +
|
||||
"插入字符串请用 {{json .Xxx}} 而不是 {{.Xxx}},否则标题里的引号会破坏 JSON。",
|
||||
},
|
||||
],
|
||||
telegram: [
|
||||
{ key: "bot_token", label: "Bot Token", kind: "password", placeholder: "123456:ABC-DEF..." },
|
||||
{ key: "chat_id", label: "Chat ID", kind: "text", placeholder: "-1001234567890" },
|
||||
{
|
||||
key: "base_url",
|
||||
label: "API 地址",
|
||||
kind: "text",
|
||||
placeholder: "https://api.telegram.org",
|
||||
help: "留空用官方地址;自建 Bot API 反代时填写",
|
||||
},
|
||||
],
|
||||
email: [
|
||||
{ key: "host", label: "SMTP 服务器", kind: "text", placeholder: "smtp.example.com" },
|
||||
{
|
||||
key: "port",
|
||||
label: "端口",
|
||||
kind: "number",
|
||||
placeholder: "587",
|
||||
help: "587 走 STARTTLS;465 请把「隐式 TLS」打开",
|
||||
},
|
||||
{ key: "username", label: "账号", kind: "text" },
|
||||
{ key: "password", label: "密码 / 授权码", kind: "password" },
|
||||
{ key: "from", label: "发件人", kind: "text", placeholder: "[email protected]" },
|
||||
{ key: "to", label: "收件人", kind: "list", help: "多个地址用逗号分隔" },
|
||||
{ key: "tls", label: "隐式 TLS", kind: "switch", help: "465 端口打开;587 保持关闭(会自动 STARTTLS)" },
|
||||
],
|
||||
};
|
||||
|
||||
export const SEVERITY_OPTIONS = [
|
||||
{ value: "", label: "不限" },
|
||||
{ value: "low", label: "低危及以上" },
|
||||
{ value: "medium", label: "中危及以上" },
|
||||
{ value: "high", label: "高危及以上" },
|
||||
{ value: "critical", label: "仅严重" },
|
||||
];
|
||||
|
||||
export type ChannelForm = {
|
||||
name: string;
|
||||
kind: string;
|
||||
mode: "realtime" | "digest";
|
||||
enabled: boolean;
|
||||
ratePerMin: string;
|
||||
config: Record<string, unknown>;
|
||||
minSeverity: string;
|
||||
includeText: string;
|
||||
excludeText: string;
|
||||
taskIDsText: string;
|
||||
assetIDsText: string;
|
||||
onStatusChange: boolean;
|
||||
};
|
||||
|
||||
export const emptyForm = (kind: string): ChannelForm => ({
|
||||
name: "",
|
||||
kind,
|
||||
mode: "realtime",
|
||||
enabled: true,
|
||||
ratePerMin: "",
|
||||
config: {},
|
||||
minSeverity: "",
|
||||
includeText: "",
|
||||
excludeText: "",
|
||||
taskIDsText: "",
|
||||
assetIDsText: "",
|
||||
onStatusChange: false,
|
||||
});
|
||||
|
||||
// parseKV 解析「每行 KEY=VALUE」的文本域。
|
||||
export function parseKV(text: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const line of text.split("\n")) {
|
||||
const t = line.trim();
|
||||
if (!t) continue;
|
||||
const i = t.indexOf("=");
|
||||
if (i > 0) out[t.slice(0, i).trim()] = t.slice(i + 1).trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// parseIDs 解析逗号/空白分隔的 id 列表。
|
||||
export function parseIDs(text: string): number[] {
|
||||
return text
|
||||
.split(/[\s,,]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((s) => Number(s))
|
||||
.filter((n) => Number.isFinite(n) && n > 0);
|
||||
}
|
||||
// parseKeywords 解析行/逗号分隔的关键词列表(漏洞类型名可能含空格,所以按行或逗号切)。
|
||||
export function parseKeywords(text: string): string[] {
|
||||
return text
|
||||
.split(/[\n,,]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon } from "lucide-react";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { NotificationFilter } from "@/lib/types";
|
||||
|
||||
// asText / inputType 是本文件内的取值辅助(与控件渲染强相关),不放在 channel-fields。
|
||||
import { type FieldDef, type FieldKind, SEVERITY_OPTIONS } from "./channel-fields";
|
||||
|
||||
// asText 把任意配置值渲染成输入框可用的字符串。
|
||||
// config 来自 JSON,值可能是 string / number / boolean / array / null,
|
||||
// 这里只关心「能不能塞进文本框」,具体序列化由 buildConfig 负责。
|
||||
function asText(v: unknown): string {
|
||||
if (typeof v === "string") return v;
|
||||
if (v === null || v === undefined) return "";
|
||||
return String(v);
|
||||
}
|
||||
|
||||
// inputType 把字段类型映射到 input 的 type 属性。
|
||||
function inputType(kind: FieldKind): "text" | "password" | "number" {
|
||||
if (kind === "password") return "password";
|
||||
if (kind === "number") return "number";
|
||||
return "text";
|
||||
}
|
||||
|
||||
// ConfigField 按字段定义渲染对应的控件。
|
||||
//
|
||||
// 掩码字段的处理是这里唯一的讲究:输入框**不显示**掩码值本身,只显示一行
|
||||
// 「已保存」提示。这样界面上就只有一个规则——框里有字就是用户填的,
|
||||
// 空框就是空值。若把 "__masked__:…abc123" 塞进输入框,用户会以为那是要自己
|
||||
// 删掉的占位文本,反而更容易误清凭据。
|
||||
export function ConfigField({
|
||||
def,
|
||||
value,
|
||||
isSecret,
|
||||
onChange,
|
||||
}: {
|
||||
def: FieldDef;
|
||||
value: unknown;
|
||||
isSecret: boolean;
|
||||
onChange: (v: unknown) => void;
|
||||
}) {
|
||||
const id = `n-cfg-${def.key}`;
|
||||
const raw = asText(value);
|
||||
// 后端回显的掩码值:形如 "__masked__:…abc123",尾部是原值的可辨识片段。
|
||||
const masked = isSecret && raw.startsWith("__masked__");
|
||||
const maskedTail = masked ? (raw.split("…")[1] ?? "") : "";
|
||||
|
||||
if (def.kind === "switch") {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Switch checked={value === true} onCheckedChange={onChange} aria-label={def.label} />
|
||||
{def.label}
|
||||
{def.help && <span className="text-muted-foreground">({def.help})</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (def.kind === "select") {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label>{def.label}</Label>
|
||||
<Select value={raw || def.options?.[0]?.value} onValueChange={onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(def.options ?? []).map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 控件按字段类型分派。用 if 链而不是嵌套三元,是因为这里要区分四种控件,
|
||||
// 三层三元读起来已经要停下来数括号了。
|
||||
function control() {
|
||||
if (def.kind === "textarea" || def.kind === "kv") {
|
||||
return (
|
||||
<Textarea
|
||||
id={id}
|
||||
className="font-mono"
|
||||
placeholder={def.placeholder}
|
||||
value={masked ? "" : raw}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (def.kind === "list") {
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
value={Array.isArray(value) ? (value as string[]).join(", ") : raw}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={def.placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
className={def.kind === "text" ? "font-mono" : ""}
|
||||
type={inputType(def.kind)}
|
||||
placeholder={def.placeholder}
|
||||
value={masked ? "" : raw}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const hint = masked ? (
|
||||
<p className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
<CheckIcon className="size-3" />
|
||||
已保存{maskedTail ? `(尾号 ${maskedTail})` : ""} · 填入新值即覆盖,清空则删除该项
|
||||
</p>
|
||||
) : (
|
||||
def.help && <p className="text-muted-foreground text-xs">{def.help}</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={id}>{def.label}</Label>
|
||||
{control()}
|
||||
{hint}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// FilterSummary 把过滤条件摘要成一行,让卡片不用展开就能看出这个渠道推什么。
|
||||
export function FilterSummary({ filter }: { filter: NotificationFilter }) {
|
||||
const parts: string[] = [];
|
||||
if (filter.min_severity) {
|
||||
parts.push(SEVERITY_OPTIONS.find((o) => o.value === filter.min_severity)?.label ?? filter.min_severity);
|
||||
}
|
||||
if (filter.vulnclass_include?.length) parts.push(`类型含 ${filter.vulnclass_include.length} 词`);
|
||||
if (filter.vulnclass_exclude?.length) parts.push(`排除 ${filter.vulnclass_exclude.length} 词`);
|
||||
if (filter.task_ids?.length) parts.push(`${filter.task_ids.length} 个任务`);
|
||||
if (filter.asset_ids?.length) parts.push(`${filter.asset_ids.length} 个资产`);
|
||||
if (filter.on_status_change) parts.push("含状态变更");
|
||||
if (parts.length === 0) {
|
||||
return <p className="text-muted-foreground text-sm">全部漏洞</p>;
|
||||
}
|
||||
return <p className="text-muted-foreground text-sm">{parts.join(" · ")}</p>;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { RefreshCwIcon, RotateCcwIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { api } from "@/lib/api";
|
||||
import { statusMeta, toneClasses } from "@/lib/status";
|
||||
import type { NotificationChannel, NotificationDelivery } from "@/lib/types";
|
||||
|
||||
// DeliveryList 是投递记录表:可按渠道与状态筛选,失败项可手动重发。
|
||||
export function DeliveryList({ channels }: { channels: NotificationChannel[] }) {
|
||||
const [rows, setRows] = React.useState<NotificationDelivery[]>([]);
|
||||
const [total, setTotal] = React.useState(0);
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [channelID, setChannelID] = React.useState<number | undefined>(undefined);
|
||||
const [state, setState] = React.useState<string | undefined>(undefined);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const pageSize = 50;
|
||||
|
||||
const load = React.useCallback(() => {
|
||||
setLoading(true);
|
||||
api
|
||||
.notifyDeliveries({ channelId: channelID, state, page, pageSize })
|
||||
.then((r) => {
|
||||
setRows(r.deliveries);
|
||||
setTotal(r.total);
|
||||
})
|
||||
.catch((e) => toast.error("读取投递记录失败:" + (e as Error).message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [channelID, state, page]);
|
||||
React.useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function retry(id: number) {
|
||||
try {
|
||||
await api.notifyRetryDelivery(id);
|
||||
toast.success("已重新入队");
|
||||
load();
|
||||
} catch (e) {
|
||||
toast.error("重发失败:" + (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
const maxPage = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={channelID ? String(channelID) : "all"}
|
||||
onValueChange={(v) => {
|
||||
setPage(1);
|
||||
setChannelID(v === "all" ? undefined : Number(v));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-44">
|
||||
<SelectValue placeholder="全部渠道" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部渠道</SelectItem>
|
||||
{channels.map((c) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={state ?? "all"}
|
||||
onValueChange={(v) => {
|
||||
setPage(1);
|
||||
setState(v === "all" ? undefined : v);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-32">
|
||||
<SelectValue placeholder="全部状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
{["pending", "sending", "sent", "failed", "skipped"].map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{statusMeta("delivery", s).label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" variant="outline" onClick={load} disabled={loading}>
|
||||
<RefreshCwIcon className={loading ? "animate-spin" : ""} /> 刷新
|
||||
</Button>
|
||||
<span className="text-muted-foreground ml-auto text-xs">共 {total} 条</span>
|
||||
</div>
|
||||
|
||||
<Card className="py-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-40">时间</TableHead>
|
||||
<TableHead>漏洞</TableHead>
|
||||
<TableHead className="w-40">渠道</TableHead>
|
||||
<TableHead className="w-24">状态</TableHead>
|
||||
<TableHead className="w-16">尝试</TableHead>
|
||||
<TableHead>错误</TableHead>
|
||||
<TableHead className="w-20" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-muted-foreground py-8 text-center">
|
||||
{loading ? "加载中…" : "暂无投递记录"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((d) => (
|
||||
<TableRow key={d.id}>
|
||||
<TableCell className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{formatTime(d.created_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className={toneClasses[statusMeta("severity", d.severity).tone]}>
|
||||
{statusMeta("severity", d.severity).label}
|
||||
</Badge>
|
||||
<span className="truncate text-sm">{d.title || "(无标题)"}</span>
|
||||
{d.event_kind === "finding_status_changed" && (
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
状态变更
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{d.channel_name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className={toneClasses[statusMeta("delivery", d.state).tone]}>
|
||||
{statusMeta("delivery", d.state).label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">{d.attempts}</TableCell>
|
||||
<TableCell className="text-muted-foreground max-w-md text-xs break-all">{d.last_error}</TableCell>
|
||||
<TableCell>
|
||||
{/* 只有失败/跳过的才给重发入口:已送达的重发会造成重复推送。 */}
|
||||
{(d.state === "failed" || d.state === "skipped") && (
|
||||
<Button size="sm" variant="outline" onClick={() => retry(d.id)}>
|
||||
<RotateCcwIcon /> 重发
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
{maxPage > 1 && (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{page} / {maxPage}
|
||||
</span>
|
||||
<Button size="sm" variant="outline" disabled={page >= maxPage} onClick={() => setPage((p) => p + 1)}>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export function StatTile({ label, value, hint, tone }: { label: string; value: string; hint?: string; tone?: string }) {
|
||||
return (
|
||||
<Card size="sm" className="gap-1">
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-xs">{label}</p>
|
||||
<p className={`text-lg font-semibold ${tone === "red" ? "text-rose-600" : ""}`}>{value}</p>
|
||||
{hint && <p className={`text-xs ${tone === "red" ? "text-rose-600" : "text-muted-foreground"}`}>{hint}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// formatBacklog 把积压毫秒数渲染成人看得懂的量级。
|
||||
export function formatBacklog(ms: number): string {
|
||||
if (!ms) return "—";
|
||||
if (ms < 60_000) return `${Math.round(ms / 1000)} 秒`;
|
||||
if (ms < 3_600_000) return `${Math.round(ms / 60_000)} 分钟`;
|
||||
return `${(ms / 3_600_000).toFixed(1)} 小时`;
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { BellIcon, PlusIcon, SendIcon, Trash2Icon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
import type { NotificationChannel, NotificationFilter, NotificationMeta } from "@/lib/types";
|
||||
|
||||
import {
|
||||
CHANNEL_FIELDS,
|
||||
type ChannelForm,
|
||||
emptyForm,
|
||||
KIND_LABEL,
|
||||
parseIDs,
|
||||
parseKeywords,
|
||||
parseKV,
|
||||
SEVERITY_OPTIONS,
|
||||
} from "./_components/channel-fields";
|
||||
import { ConfigField, FilterSummary } from "./_components/channel-form";
|
||||
import { DeliveryList } from "./_components/delivery-list";
|
||||
import { formatBacklog, StatTile } from "./_components/stat-tile";
|
||||
|
||||
// 本页只负责编排:加载数据、维护表单状态、调用接口。
|
||||
// 字段定义与解析在 _components/channel-fields.ts,控件与过滤摘要在
|
||||
// _components/channel-form.tsx,投递记录在 _components/delivery-list.tsx——
|
||||
// 拆开是因为它们各自能被单独读懂,而挤在一个文件里时这个页面接近 1100 行。
|
||||
export default function NotifyPage() {
|
||||
const [meta, setMeta] = React.useState<NotificationMeta | null>(null);
|
||||
const [channels, setChannels] = React.useState<NotificationChannel[]>([]);
|
||||
const [tab, setTab] = React.useState<"channels" | "deliveries">("channels");
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [editing, setEditing] = React.useState<NotificationChannel | null>(null);
|
||||
const [form, setForm] = React.useState<ChannelForm>(emptyForm("dingtalk"));
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [testing, setTesting] = React.useState(false);
|
||||
|
||||
const [globalSaving, setGlobalSaving] = React.useState(false);
|
||||
const [baseURL, setBaseURL] = React.useState("");
|
||||
const [digestMin, setDigestMin] = React.useState("");
|
||||
|
||||
const load = React.useCallback(() => {
|
||||
api
|
||||
.notifyMeta()
|
||||
.then((m) => {
|
||||
setMeta(m);
|
||||
setBaseURL(m.public_base_url);
|
||||
setDigestMin(m.digest_interval_min);
|
||||
})
|
||||
.catch((e) => toast.error("读取推送配置失败:" + (e as Error).message));
|
||||
// 渠道列表加载失败要报出来:静默失败会显示成「一个渠道都没有」,
|
||||
// 用户会以为配置丢了,比直接报错更让人慌。
|
||||
api
|
||||
.notifyChannels()
|
||||
.then(setChannels)
|
||||
.catch((e) => toast.error("读取渠道列表失败:" + (e as Error).message));
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
function setF(patch: Partial<ChannelForm>) {
|
||||
setForm((f) => ({ ...f, ...patch }));
|
||||
}
|
||||
function setCfg(key: string, value: unknown) {
|
||||
setForm((f) => ({ ...f, config: { ...f.config, [key]: value } }));
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
setEditing(null);
|
||||
setForm(emptyForm(meta?.kinds[0]?.kind ?? "dingtalk"));
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(ch: NotificationChannel) {
|
||||
setEditing(ch);
|
||||
// filter 在后端是 Go 结构体,永远序列化成对象(不会是 null),所以不需要兜底。
|
||||
const f = ch.filter;
|
||||
setForm({
|
||||
name: ch.name,
|
||||
kind: ch.kind,
|
||||
mode: ch.mode,
|
||||
enabled: ch.enabled,
|
||||
ratePerMin: String(ch.rate_per_min),
|
||||
// 后端回显的 config 里凭据是掩码值;原样放进表单,提交时原样送回,
|
||||
// 后端据此保留库中原值。
|
||||
config: { ...ch.config },
|
||||
minSeverity: f.min_severity ?? "",
|
||||
includeText: (f.vulnclass_include ?? []).join("\n"),
|
||||
excludeText: (f.vulnclass_exclude ?? []).join("\n"),
|
||||
taskIDsText: (f.task_ids ?? []).join(","),
|
||||
assetIDsText: (f.asset_ids ?? []).join(","),
|
||||
onStatusChange: f.on_status_change ?? false,
|
||||
});
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
// buildConfig 把表单状态转成渠道 config。
|
||||
//
|
||||
// 唯一的规则,两类值:
|
||||
// - 掩码值("__masked__...")原样送回 → 后端解读为「这个字段没改,保留库中原值」
|
||||
// - 其余一律按用户输入提交,空串即「清空该字段」
|
||||
//
|
||||
// 之所以不特殊照顾凭据字段(比如「凭据留空就跳过」),是因为那会让用户**无法清除**
|
||||
// 一个设错的密钥——界面上没有任何操作能表达「我要把它删掉」。现在的规则下,
|
||||
// 清空输入框就等于清空该字段,语义唯一且用户可控。
|
||||
// 掩码值不会出现在输入框里(见 ConfigField),所以「框里有字」永远等于
|
||||
// 「用户主动填的」。
|
||||
function buildConfig(): Record<string, unknown> {
|
||||
const defs = CHANNEL_FIELDS[form.kind] ?? [];
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const d of defs) {
|
||||
const raw = form.config[d.key];
|
||||
if (d.kind === "switch") {
|
||||
out[d.key] = raw === true;
|
||||
continue;
|
||||
}
|
||||
if (typeof raw === "string" && raw.startsWith("__masked__")) {
|
||||
out[d.key] = raw;
|
||||
continue;
|
||||
}
|
||||
if (d.kind === "number") {
|
||||
const n = Number(raw);
|
||||
out[d.key] = Number.isFinite(n) && n > 0 ? n : 0;
|
||||
continue;
|
||||
}
|
||||
if (d.kind === "kv") {
|
||||
out[d.key] = parseKV(String(raw ?? ""));
|
||||
continue;
|
||||
}
|
||||
if (d.kind === "list") {
|
||||
out[d.key] = String(raw ?? "")
|
||||
.split(/[\s,,]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
continue;
|
||||
}
|
||||
out[d.key] = String(raw ?? "").trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildFilter(): NotificationFilter {
|
||||
return {
|
||||
min_severity: form.minSeverity || undefined,
|
||||
vulnclass_include: parseKeywords(form.includeText),
|
||||
vulnclass_exclude: parseKeywords(form.excludeText),
|
||||
task_ids: parseIDs(form.taskIDsText),
|
||||
asset_ids: parseIDs(form.assetIDsText),
|
||||
on_status_change: form.onStatusChange,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveForm() {
|
||||
if (!form.name.trim()) {
|
||||
toast.error("请填写渠道名称");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
kind: form.kind,
|
||||
mode: form.mode,
|
||||
enabled: form.enabled,
|
||||
config: buildConfig(),
|
||||
filter: buildFilter(),
|
||||
rate_per_min: form.ratePerMin.trim() === "" ? undefined : Number(form.ratePerMin),
|
||||
};
|
||||
if (editing) {
|
||||
await api.notifyUpdateChannel(editing.id, payload);
|
||||
toast.success("已保存");
|
||||
setOpen(false);
|
||||
} else {
|
||||
await api.notifyCreateChannel(payload);
|
||||
toast.success("已添加渠道");
|
||||
setOpen(false);
|
||||
}
|
||||
load();
|
||||
} catch (e) {
|
||||
toast.error("保存失败:" + (e as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function testChannel() {
|
||||
if (!editing) return;
|
||||
setTesting(true);
|
||||
try {
|
||||
const r = await api.notifyTestChannel(editing.id);
|
||||
toast.success(`已发出测试消息(${r.latency_ms} ms),请到群里确认`);
|
||||
} catch (e) {
|
||||
// 后端把渠道返回的原始错误如实回传,这是排查配置的唯一线索,原样展示。
|
||||
toast.error("测试失败:" + (e as Error).message, { duration: 12000 });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeChannel(ch: NotificationChannel) {
|
||||
try {
|
||||
await api.notifyDeleteChannel(ch.id);
|
||||
toast.success(`已删除:${ch.name}`);
|
||||
setOpen(false);
|
||||
load();
|
||||
} catch (e) {
|
||||
toast.error("删除失败:" + (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEnabled(ch: NotificationChannel) {
|
||||
try {
|
||||
await api.notifyUpdateChannel(ch.id, { enabled: !ch.enabled });
|
||||
load();
|
||||
} catch (e) {
|
||||
toast.error("操作失败:" + (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleGlobal(on: boolean) {
|
||||
setGlobalSaving(true);
|
||||
try {
|
||||
await api.setSettings({ notify_enabled: on });
|
||||
setMeta((m) => (m ? { ...m, enabled: on } : m));
|
||||
toast.success(on ? "推送已开启" : "推送已暂停");
|
||||
} catch (e) {
|
||||
toast.error("操作失败:" + (e as Error).message);
|
||||
} finally {
|
||||
setGlobalSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGlobal() {
|
||||
setGlobalSaving(true);
|
||||
try {
|
||||
const patch: Record<string, unknown> = { notify_public_base_url: baseURL.trim() };
|
||||
const n = Number(digestMin);
|
||||
if (Number.isFinite(n) && n > 0) patch.notify_digest_interval_min = n;
|
||||
await api.setSettings(patch);
|
||||
toast.success("已保存");
|
||||
load();
|
||||
} catch (e) {
|
||||
toast.error("保存失败:" + (e as Error).message);
|
||||
} finally {
|
||||
setGlobalSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const fields = CHANNEL_FIELDS[form.kind] ?? [];
|
||||
const secretKeys = new Set(meta?.kinds.find((k) => k.kind === form.kind)?.secret_keys ?? []);
|
||||
const defaultRate = meta?.kinds.find((k) => k.kind === form.kind)?.default_rate_per_min ?? 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 md:gap-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">通知推送</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
发现漏洞时推送到钉钉 / 飞书 / 企业微信等渠道 · 每个渠道可独立设推送时机与过滤规则
|
||||
</p>
|
||||
</div>
|
||||
{meta && (
|
||||
// 用 div 而不是 label:Switch 自带 aria-label,外面再套一层 label
|
||||
// 既关联不到任何原生控件,又会让点击文字看起来应该能切换。
|
||||
<div className="flex shrink-0 items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">总开关</span>
|
||||
<Switch
|
||||
checked={meta.enabled}
|
||||
disabled={globalSaving}
|
||||
onCheckedChange={toggleGlobal}
|
||||
aria-label="推送总开关"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{meta && (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<StatTile label="渠道" value={`${meta.stats.channels_on} / ${meta.stats.channels}`} hint="启用 / 总数" />
|
||||
<StatTile label="今日送达" value={String(meta.stats.sent_today)} />
|
||||
<StatTile label="待发送" value={String(meta.stats.pending)} />
|
||||
<StatTile label="失败" value={String(meta.stats.failed)} tone={meta.stats.failed > 0 ? "red" : undefined} />
|
||||
<StatTile
|
||||
label="最久积压"
|
||||
value={formatBacklog(meta.stats.backlog_age_ms)}
|
||||
// 积压年龄比积压条数有用得多:积压 3 条可以是从 3 秒到 3 小时。
|
||||
hint={meta.stats.backlog_age_ms > 5 * 60_000 ? "推送可能卡住了" : undefined}
|
||||
tone={meta.stats.backlog_age_ms > 5 * 60_000 ? "red" : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="gap-3">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">全局设置</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="n-base">回链地址</Label>
|
||||
<Input
|
||||
id="n-base"
|
||||
placeholder="https://artex.example.com"
|
||||
value={baseURL}
|
||||
onChange={(e) => setBaseURL(e.target.value)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">消息里「查看详情」按钮指向的地址。留空则不带按钮。</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="n-digest">汇总周期(分钟)</Label>
|
||||
<Input
|
||||
id="n-digest"
|
||||
type="number"
|
||||
min={1}
|
||||
max={1440}
|
||||
placeholder="30"
|
||||
value={digestMin}
|
||||
onChange={(e) => setDigestMin(e.target.value)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">仅对「汇总」模式的渠道生效。</p>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Button onClick={saveGlobal} disabled={globalSaving}>
|
||||
保存全局设置
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as "channels" | "deliveries")} className="flex flex-col gap-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="channels">渠道</TabsTrigger>
|
||||
<TabsTrigger value="deliveries">投递记录</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="channels">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAdd}
|
||||
className="text-foreground/70 border-foreground/70 hover:bg-muted/60 hover:shadow-sm flex min-h-[130px] flex-col items-center justify-center gap-2 rounded-xl border border-dashed transition"
|
||||
>
|
||||
<PlusIcon className="size-6" />
|
||||
<span className="text-sm">添加渠道</span>
|
||||
</button>
|
||||
|
||||
{channels.map((ch) => (
|
||||
<Card
|
||||
key={ch.id}
|
||||
onClick={() => openEdit(ch)}
|
||||
className="hover:border-primary/60 cursor-pointer gap-3 transition hover:shadow-sm"
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<BellIcon className="text-muted-foreground size-4 shrink-0" />
|
||||
<CardTitle className="truncate text-base">{ch.name}</CardTitle>
|
||||
{/* 卡片整体可点(进入编辑),所以这两个控件必须各自吞掉冒泡,
|
||||
否则开关/删除会顺带触发编辑。把 stopPropagation 挂在控件自己
|
||||
身上,而不是套一层 div:套 div 会造出一个「看起来可交互但没有
|
||||
角色」的静态元素,既触发 a11y 告警,语义上也说不通。 */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Switch
|
||||
checked={ch.enabled}
|
||||
onCheckedChange={() => toggleEnabled(ch)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="启用"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
aria-label="删除"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// void 显式丢弃 Promise:removeChannel 自己 catch 并 toast,
|
||||
// 这里不需要 await(onClick 不是 async)。
|
||||
void removeChannel(ch);
|
||||
}}
|
||||
>
|
||||
<Trash2Icon className="text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{KIND_LABEL[ch.kind] ?? ch.kind}</Badge>
|
||||
<Badge variant="outline">{ch.mode === "digest" ? "汇总" : "实时"}</Badge>
|
||||
{!ch.enabled && <Badge variant="outline">已停用</Badge>}
|
||||
</div>
|
||||
<FilterSummary filter={ch.filter} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="deliveries">
|
||||
<DeliveryList channels={channels} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent side="right" className="w-full data-[side=right]:sm:max-w-lg">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{editing ? editing.name : "添加通知渠道"}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{KIND_LABEL[form.kind] ?? form.kind}
|
||||
{defaultRate > 0 ? ` · 默认限流 ${defaultRate} 条/分钟` : " · 不限流"}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>渠道类型</Label>
|
||||
<Select
|
||||
value={form.kind}
|
||||
onValueChange={(v) => {
|
||||
// 换类型等于换一套凭据字段,不能把旧配置合并进来。
|
||||
setF({ kind: v, config: {} });
|
||||
}}
|
||||
disabled={!!editing}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(meta?.kinds ?? []).map((k) => (
|
||||
<SelectItem key={k.kind} value={k.kind}>
|
||||
{KIND_LABEL[k.kind] ?? k.kind}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{editing && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
渠道类型不可修改——改了类型等于换一套凭据,请新建渠道。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="n-name">渠道名称</Label>
|
||||
<Input
|
||||
id="n-name"
|
||||
placeholder="应急响应群 / 日常播报群"
|
||||
value={form.name}
|
||||
onChange={(e) => setF({ name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{fields.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
该渠道的表单尚未定义(前端缺 CHANNEL_FIELDS 条目),请补全后再试。
|
||||
</p>
|
||||
) : (
|
||||
fields.map((d) => (
|
||||
<ConfigField
|
||||
key={d.key}
|
||||
def={d}
|
||||
value={form.config[d.key]}
|
||||
isSecret={secretKeys.has(d.key)}
|
||||
onChange={(v) => setCfg(d.key, v)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>推送时机</Label>
|
||||
<Select value={form.mode} onValueChange={(v) => setF({ mode: v as "realtime" | "digest" })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="realtime">实时 · 每条漏洞单独发一条</SelectItem>
|
||||
<SelectItem value="digest">汇总 · 按周期合并成一条</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
想做「高危实时、其余汇总」就建两个渠道:一个实时 + 门槛高危,一个汇总 + 不限级别。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="n-rate">限流(条/分钟)</Label>
|
||||
<Input
|
||||
id="n-rate"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder={defaultRate > 0 ? String(defaultRate) : "0 = 不限"}
|
||||
value={form.ratePerMin}
|
||||
onChange={(e) => setF({ ratePerMin: e.target.value })}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
留空用渠道默认值;0 表示不限流。超限不会丢消息,只会推迟发送。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<p className="mb-3 text-sm font-medium">过滤规则(留空即不过滤)</p>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>最低级别</Label>
|
||||
<Select
|
||||
value={form.minSeverity || "all"}
|
||||
onValueChange={(v) => setF({ minSeverity: v === "all" ? "" : v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SEVERITY_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value || "all"} value={o.value || "all"}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="n-inc">只推这些漏洞类型</Label>
|
||||
<Textarea
|
||||
id="n-inc"
|
||||
placeholder={"SQL注入\n命令执行"}
|
||||
value={form.includeText}
|
||||
onChange={(e) => setF({ includeText: e.target.value })}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
每行一个关键词,大小写不敏感的子串匹配。留空=全部类型。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="n-exc">排除这些漏洞类型</Label>
|
||||
<Textarea
|
||||
id="n-exc"
|
||||
placeholder={"信息泄露"}
|
||||
value={form.excludeText}
|
||||
onChange={(e) => setF({ excludeText: e.target.value })}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">排除优先于包含:同时命中时会被排除。</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="n-tasks">限定任务 ID</Label>
|
||||
<Input
|
||||
id="n-tasks"
|
||||
placeholder="1, 2, 3"
|
||||
value={form.taskIDsText}
|
||||
onChange={(e) => setF({ taskIDsText: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="n-assets">限定资产 ID</Label>
|
||||
<Input
|
||||
id="n-assets"
|
||||
placeholder="10, 11"
|
||||
value={form.assetIDsText}
|
||||
onChange={(e) => setF({ assetIDsText: e.target.value })}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">任务/资产留空=不限;填写后要求与漏洞有交集。</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Switch
|
||||
checked={form.onStatusChange}
|
||||
onCheckedChange={(v) => setF({ onStatusChange: v })}
|
||||
aria-label="接收状态变更"
|
||||
/>
|
||||
漏洞处置状态变更时也推送(仅实时模式)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Switch checked={form.enabled} onCheckedChange={(v) => setF({ enabled: v })} aria-label="启用" />
|
||||
启用该渠道
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2 pb-6">
|
||||
<Button onClick={saveForm} disabled={saving}>
|
||||
{editing ? "保存" : "添加"}
|
||||
</Button>
|
||||
{editing && (
|
||||
<Button variant="outline" onClick={testChannel} disabled={testing}>
|
||||
<SendIcon /> 发送测试消息
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -64,6 +64,10 @@ import type {
|
||||
MCPTool,
|
||||
MissingSkill,
|
||||
ModelTokenStat,
|
||||
NotificationChannel,
|
||||
NotificationDelivery,
|
||||
NotificationFilter,
|
||||
NotificationMeta,
|
||||
PromptVar,
|
||||
PromptVersion,
|
||||
SessionTokenUsage,
|
||||
@@ -805,6 +809,49 @@ export const api = {
|
||||
brave_search_api_key?: string;
|
||||
tavily_search_api_key?: string;
|
||||
}) => post<{ ok: boolean; error?: string; count?: number; backend?: string }>(`/settings/web-search/test`, patch),
|
||||
|
||||
// ---- 漏洞 IM 推送 ----
|
||||
// 渠道是多实例资源(同一类型可配多个机器人、各有过滤规则),因此独立成组,
|
||||
// 不塞进扁平的 settings 键值里。
|
||||
notifyMeta: () => get<NotificationMeta>(`/notify/meta`),
|
||||
notifyChannels: () =>
|
||||
get<{ channels: NotificationChannel[] }>(`/notify/channels`).then((r) => arr(r.channels)),
|
||||
notifyCreateChannel: (payload: {
|
||||
name: string;
|
||||
kind: string;
|
||||
enabled?: boolean;
|
||||
mode?: string;
|
||||
config: Record<string, unknown>;
|
||||
filter?: NotificationFilter;
|
||||
rate_per_min?: number;
|
||||
}) => post<{ id: number }>(`/notify/channels`, payload),
|
||||
// PATCH 语义:只提交要改的字段。config 里的掩码值原样回传即表示「保持原值」。
|
||||
notifyUpdateChannel: (
|
||||
id: number,
|
||||
payload: {
|
||||
name?: string;
|
||||
kind?: string;
|
||||
enabled?: boolean;
|
||||
mode?: string;
|
||||
config?: Record<string, unknown>;
|
||||
filter?: NotificationFilter;
|
||||
rate_per_min?: number;
|
||||
},
|
||||
) => patch<{ id: number }>(`/notify/channels/${id}`, payload),
|
||||
notifyDeleteChannel: (id: number) => del<{ ok: boolean }>(`/notify/channels/${id}`),
|
||||
// 同步发一条测试消息;失败时后端会把渠道的原始错误回传,供排查配置。
|
||||
notifyTestChannel: (id: number) => post<{ ok: boolean; latency_ms: number }>(`/notify/channels/${id}/test`),
|
||||
notifyDeliveries: (q: { channelId?: number; state?: string; page?: number; pageSize?: number } = {}) => {
|
||||
const p = new URLSearchParams();
|
||||
if (q.channelId) p.set("channel_id", String(q.channelId));
|
||||
if (q.state) p.set("state", q.state);
|
||||
p.set("page", String(q.page ?? 1));
|
||||
p.set("page_size", String(q.pageSize ?? 50));
|
||||
return get<{ deliveries: NotificationDelivery[]; total: number; page: number; page_size: number }>(
|
||||
`/notify/deliveries?${p.toString()}`,
|
||||
).then((r) => ({ ...r, deliveries: arr(r.deliveries) }));
|
||||
},
|
||||
notifyRetryDelivery: (id: number) => post<{ ok: boolean }>(`/notify/deliveries/${id}/retry`),
|
||||
report: async (task?: string) => {
|
||||
if (MOCK) return mockReport(task);
|
||||
const token = getToken();
|
||||
|
||||
@@ -99,6 +99,16 @@ const node: Record<string, StatusMeta> = {
|
||||
tombstoned: { label: "废弃", tone: "neutral" },
|
||||
};
|
||||
|
||||
// 推送投递状态。sending 用 blue 而不是 amber:它不是「有问题」,
|
||||
// 而是「已被领取、正在发」,与 pending 的等待语义要能区分开。
|
||||
const delivery: Record<string, StatusMeta> = {
|
||||
pending: { label: "待发送", tone: "amber" },
|
||||
sending: { label: "发送中", tone: "blue" },
|
||||
sent: { label: "已送达", tone: "green" },
|
||||
failed: { label: "失败", tone: "red" },
|
||||
skipped: { label: "已跳过", tone: "neutral" },
|
||||
};
|
||||
|
||||
const maps = {
|
||||
intent,
|
||||
task,
|
||||
@@ -108,6 +118,7 @@ const maps = {
|
||||
goal,
|
||||
audit,
|
||||
node,
|
||||
delivery,
|
||||
} as const;
|
||||
|
||||
export type StatusDomain = keyof typeof maps;
|
||||
|
||||
@@ -943,6 +943,83 @@ export interface Settings {
|
||||
// worker/主 agent/对话)由 noa 接管上下文压缩,取代内置 compaction;每 run 读一次,对
|
||||
// 之后启动的 run 生效。
|
||||
noa_compaction?: boolean;
|
||||
// ---- 漏洞 IM 推送(渠道本身是独立资源,见 /api/notify/*,这里只有三项全局配置)----
|
||||
notify_enabled?: boolean; // 推送总开关,默认开;用于维护期一键止血
|
||||
notify_public_base_url?: string; // 漏洞详情回链的外部访问地址;空=消息不带回链
|
||||
notify_digest_interval_min?: number; // 汇总模式周期(分钟),默认 30
|
||||
}
|
||||
|
||||
// ---- 漏洞 IM 推送 ----
|
||||
|
||||
// NotificationFilter 是渠道的过滤条件,字段全部可选,缺省即不过滤。
|
||||
// 后端对所有字段都不加校验:配置畸形时按「命中」处理(宁可多推不可漏推)。
|
||||
export interface NotificationFilter {
|
||||
min_severity?: string; // "" | low | medium | high | critical
|
||||
task_ids?: number[]; // 空=不限;非空则要求与漏洞所属任务有交集
|
||||
asset_ids?: number[]; // 空=不限;非空则要求与漏洞锚定资产有交集
|
||||
vulnclass_include?: string[]; // 空=全收;非空则要求漏洞类型命中任一关键词(大小写不敏感子串)
|
||||
vulnclass_exclude?: string[]; // 命中任一关键词即排除(排除优先于包含)
|
||||
on_status_change?: boolean; // 是否也接收漏洞处置状态变更事件
|
||||
}
|
||||
|
||||
// NotificationChannel 是一个渠道实例。config 的字段随 kind 而异,
|
||||
// 且凭据字段在读取时被替换成 "__masked__" 开头的掩码值——原样回传即表示「不改」。
|
||||
export interface NotificationChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
kind: string;
|
||||
enabled: boolean;
|
||||
mode: "realtime" | "digest";
|
||||
config: Record<string, unknown>;
|
||||
filter: NotificationFilter;
|
||||
rate_per_min: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// secret_keys 由后端按渠道类型给出,前端据此渲染密码框与「留空即不改」提示,
|
||||
// 不硬编码任何渠道知识。
|
||||
secret_keys: string[];
|
||||
}
|
||||
|
||||
// NotificationKind 是 /api/notify/meta 返回的渠道类型元数据。
|
||||
export interface NotificationKind {
|
||||
kind: string;
|
||||
default_rate_per_min: number;
|
||||
secret_keys: string[];
|
||||
}
|
||||
|
||||
export interface NotificationMeta {
|
||||
kinds: NotificationKind[];
|
||||
enabled: boolean;
|
||||
public_base_url: string;
|
||||
digest_interval_min: string;
|
||||
defaults: { digest_interval_min: number };
|
||||
stats: {
|
||||
channels: number;
|
||||
channels_on: number;
|
||||
pending: number;
|
||||
failed: number;
|
||||
sent_today: number;
|
||||
backlog_age_ms: number;
|
||||
};
|
||||
}
|
||||
|
||||
// NotificationDelivery 是一条投递记录,用于投递历史与失败重发。
|
||||
export interface NotificationDelivery {
|
||||
id: number;
|
||||
finding_id: string;
|
||||
event_kind: string; // finding_created | finding_status_changed
|
||||
channel_id: number;
|
||||
channel_name: string;
|
||||
channel_kind: string;
|
||||
state: "pending" | "sending" | "sent" | "failed" | "skipped";
|
||||
attempts: number;
|
||||
last_error: string;
|
||||
batch_id?: number;
|
||||
created_at: string;
|
||||
sent_at?: string;
|
||||
next_attempt_at: string;
|
||||
title: string;
|
||||
severity: string;
|
||||
}
|
||||
|
||||
// ---- LLM config ----
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Activity,
|
||||
Ban,
|
||||
BellRing,
|
||||
Bot,
|
||||
Brain,
|
||||
Bug,
|
||||
@@ -86,6 +87,7 @@ export const sidebarItems: NavGroup[] = [
|
||||
{ id: "mcp", title: "MCP", url: "/system/mcp", icon: Plug },
|
||||
{ id: "skills", title: "Skill", url: "/system/skills", icon: Sparkles },
|
||||
{ id: "tools", title: "工具", url: "/system/tools", icon: Wrench },
|
||||
{ id: "notify", title: "通知推送", url: "/system/notify", icon: BellRing },
|
||||
{ id: "intercept", title: "拦截规则", url: "/system/intercept", icon: ShieldAlert },
|
||||
{ id: "asset-intercept", title: "资产拦截", url: "/system/intercept/assets", icon: Ban },
|
||||
{ id: "approvals", title: "审批记录", url: "/system/intercept/approvals", icon: ClipboardList },
|
||||
|
||||
Reference in new issue
Block a user