mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
新增「系统 → 通知推送」页,把扫描发现的漏洞推送到钉钉、飞书、企业微信、 通用 Webhook、Telegram、邮件。同一类型可配任意多个机器人实例,每个实例独立 设启停、限流与过滤规则(最低级别 / 任务资产范围 / 漏洞类型关键词 / 是否接收 处置状态变更)。推送时机分实时与汇总两种模式,「高危实时、其余汇总」建两个 渠道即可表达,策略不写死在代码里。带投递历史与失败重发。 三个关键设计: 1. 写漏洞的事务只做一次盲 INSERT。notification_events 由 RecordFindingTx 在同一事务内写入,保证「漏洞落库」与「推送任务存在」原子一致;这条 INSERT 刻意不读渠道表、不跑用户的过滤规则,否则一条配错的过滤条件就能污染甚至 中止事务,让高危漏洞存不进库。为把失败隔离在这一条语句上(PostgreSQL 中 事务内任一语句报错会让整个事务作废、连 COMMIT 都失败),它被 SAVEPOINT 包住,失败时只记日志。 2. 投递用租约领取而非长事务。FOR UPDATE SKIP LOCKED 领取后把行置为 sending、 把 next_attempt_at 推到未来作为租约,提交后再做网络投递,因此投递期间不 持有数据库锁;崩溃留下的 sending 行会被下一轮重新领取,自愈且不会无限重试。 3. 限流不消耗重试预算。先按渠道令牌桶算出本轮还能发几条,再按这个数量领取; 顺序反过来会让被限流挡下的投递白计一次尝试,三次预算被等待耗光后落入失败。 审计并修复的四个问题: - 推送凭据经错误信息外泄:http.Client.Do 返回的 *url.Error 会把完整 URL 打进 错误文本,而钉钉 access_token、企业微信 key、飞书 hook id、Telegram /bot<token>/ 都就在 URL 里。该串流入 last_error(明文落库)、投递历史接口 (绕过渠道掩码)、服务端日志与前端错误提示。现统一脱敏为 scheme://host + 底层原因,并拒绝跨主机重定向。 - 最低级别门槛打错字会让过滤器静默失效:未知级别序数为 0,判定退化成 rank >= 0 恒真,用户以为限制「仅高危」实际全推。现于写入路径校验取值。 - 汇总批次无上限:一次领取把全部待发行读进内存、消息被渠道长度上限截掉大半 且静默丢失。现单批上限 500 条,超出部分留库成为下一批。 - 邮件渠道把 SMTP 4xx(灰名单等临时失败)判成永久失败,导致这类最该重试的 场景从不重试。现按应答码首位区分 4xx/5xx。 测试:notify 包 82% 覆盖(含用自建最小 SMTP 服务器驱动的完整邮件协议会话、 两种签名算法对 OpenSSL 基准值的比对、UTF-8 边界截断、以及模板能力边界的 反射断言);db 与 server 侧共 29 个用例跑真实 PostgreSQL,其中端到端用例 真发 HTTP 到假接收端并断言消息内容与投递状态。 Co-Authored-By: Claude Code <[email protected]>
133 lines
4.6 KiB
Go
133 lines
4.6 KiB
Go
package notify
|
|
|
|
import (
|
|
"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"])
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|