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]>
94 lines
3.4 KiB
Go
94 lines
3.4 KiB
Go
package notify
|
|
|
|
import "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
|
|
}
|
|
|
|
// 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
|
|
}
|