From 18589b270ecb3254312a95e6cc2124e7eeed81fa Mon Sep 17 00:00:00 2001 From: luckone <1139448596@qq.com> Date: Wed, 30 Sep 2026 15:57:29 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix(db):=20=E4=BF=AE=E6=AD=A3=E5=85=AC?= =?UTF-8?q?=E5=8F=B8=20ICP=20=E5=BD=92=E5=B1=9E=E7=94=A8=E4=BE=8B=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E5=85=B3=E9=97=AD=E9=A1=BA=E5=BA=8F=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E7=9A=84=E8=B5=84=E4=BA=A7=E6=AE=8B=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 清理注册在 t.Cleanup 里,而连接关闭用的是 defer d.Close()。defer 在函数 返回时先执行、t.Cleanup 在其后,于是两条 DELETE 全部落在**已关闭的连接** 上,错误又被 `_, _ =` 丢弃,测试资产与公司因此永久残留在库里。 该用例用 MAX(companies.id)+1 当假 TaskID 给资产打标。残留本身不会立刻报错, 但一旦这个数字与其他用例的任务 id 相撞,那个按「恰好 N 个资产」断言的用例 就会失败,而现象(某个无关测试的资产计数不对)与原因(另一个测试的连接 关闭顺序)相距极远,排查成本很高。 改为关闭连接也走 t.Cleanup 并注册在清理之前(t.Cleanup 后进先出,保证清理 先跑),同时让清理失败显式报错而不是被吞掉。 注:同一模式(defer d.Close() + 在 t.Cleanup 里写库)在 db 包另有十余处, 本次只修被实证触发的那一处。 Co-Authored-By: Claude Code --- db/company_scope_test.go | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/db/company_scope_test.go b/db/company_scope_test.go index 713938e..31fcbc3 100644 --- a/db/company_scope_test.go +++ b/db/company_scope_test.go @@ -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. From f3fb37f70d835d6a1ec7c22fb2fee815483e53cb Mon Sep 17 00:00:00 2001 From: luckone <1139448596@qq.com> Date: Wed, 30 Sep 2026 15:57:29 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(notify):=20=E6=BC=8F=E6=B4=9E=E5=8F=91?= =?UTF-8?q?=E7=8E=B0=E6=94=AF=E6=8C=81=E6=8E=A8=E9=80=81=E5=88=B0=E9=92=89?= =?UTF-8?q?=E9=92=89/=E9=A3=9E=E4=B9=A6/=E4=BC=81=E4=B8=9A=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E7=AD=89=E5=85=AD=E7=A7=8D=E6=B8=A0=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增「系统 → 通知推送」页,把扫描发现的漏洞推送到钉钉、飞书、企业微信、 通用 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/ 都就在 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 --- CHANGELOG.md | 30 + db/finding_traffic.go | 21 +- db/findings.go | 4 + db/notification.go | 528 +++++++++ db/notification_delivery.go | 410 +++++++ db/notification_test.go | 758 +++++++++++++ db/schema.sql | 100 ++ evidence/store.go | 2 +- notify/channel.go | 180 +++ notify/channels_test.go | 506 +++++++++ notify/dingtalk.go | 140 +++ notify/email.go | 205 ++++ notify/email_test.go | 336 ++++++ notify/event.go | 69 ++ notify/feishu.go | 182 +++ notify/filter.go | 119 ++ notify/filter_test.go | 143 +++ notify/html.go | 140 +++ notify/http.go | 173 +++ notify/http_test.go | 119 ++ notify/markdown.go | 118 ++ notify/mask.go | 93 ++ notify/mask_test.go | 132 +++ notify/notify.go | 95 ++ notify/redact_test.go | 236 ++++ notify/render.go | 117 ++ notify/render_test.go | 131 +++ notify/sign_test.go | 94 ++ notify/telegram.go | 160 +++ notify/webhook.go | 218 ++++ notify/webhook_template_test.go | 106 ++ notify/wecom.go | 75 ++ server/notifier.go | 424 +++++++ server/notify_api.go | 500 +++++++++ server/notify_api_test.go | 765 +++++++++++++ server/server.go | 84 +- web/src/app/(main)/system/notify/page.tsx | 1095 +++++++++++++++++++ web/src/lib/api.ts | 47 + web/src/lib/status.ts | 11 + web/src/lib/types.ts | 77 ++ web/src/navigation/sidebar/sidebar-items.ts | 2 + 41 files changed, 8740 insertions(+), 5 deletions(-) create mode 100644 db/notification.go create mode 100644 db/notification_delivery.go create mode 100644 db/notification_test.go create mode 100644 notify/channel.go create mode 100644 notify/channels_test.go create mode 100644 notify/dingtalk.go create mode 100644 notify/email.go create mode 100644 notify/email_test.go create mode 100644 notify/event.go create mode 100644 notify/feishu.go create mode 100644 notify/filter.go create mode 100644 notify/filter_test.go create mode 100644 notify/html.go create mode 100644 notify/http.go create mode 100644 notify/http_test.go create mode 100644 notify/markdown.go create mode 100644 notify/mask.go create mode 100644 notify/mask_test.go create mode 100644 notify/notify.go create mode 100644 notify/redact_test.go create mode 100644 notify/render.go create mode 100644 notify/render_test.go create mode 100644 notify/sign_test.go create mode 100644 notify/telegram.go create mode 100644 notify/webhook.go create mode 100644 notify/webhook_template_test.go create mode 100644 notify/wecom.go create mode 100644 server/notifier.go create mode 100644 server/notify_api.go create mode 100644 server/notify_api_test.go create mode 100644 web/src/app/(main)/system/notify/page.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index c6f8742..98cb133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,36 @@ #### 新增的功能 - **新增内置拦截规则「删除类接口路径」**:此前内置的 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()`),接口只回显带尾号提示的掩码值;提交时原样回传即表示「不修改」,清空输入框则删除该字段。 + +#### 修复的问题 + +- **修复推送凭据经错误信息外泄**:投递失败时 `http.Client.Do` 返回的 `*url.Error` 会把**完整 URL** 打进错误文本,而钉钉的 `access_token`、企业微信的 `key`、飞书的 hook id、Telegram 的 `/bot/` 都**就在 URL 里**。该串因此流入四个出口:`notification_deliveries.last_error` 明文落库、投递历史接口的原样回显(绕过了渠道配置的掩码)、服务端日志、以及测试发送接口回给前端的错误提示。现统一在 `notify/http.go` 脱敏——错误信息只保留 `scheme://host` 与底层原因(足够定位 DNS / 连通性 / 证书问题),路径与查询一律丢弃。同时拒绝**跨主机重定向**:这几家的凭据在 URL 里,跟随跨主机跳转等于把凭据交给重定向目标(同主机跳转如末尾补斜杠仍正常)。 +- **修复最低级别门槛打错字会让过滤器静默失效**:`min_severity` 写成 `hgih` 这类笔误时,未知级别在序数表里为 0,判定退化成 `rank >= 0` 恒真——用户以为限制了「仅高危」,实际把全部漏洞灌进群里;后果与「配置正确」在界面上完全无法区分。现于写入路径校验取值,错误信息列出可选值(读取路径仍保持宽容:库里已有的坏值不会让渠道整个读不出来)。 +- **修复汇总批次无上限**:一个汇总周期内若扫出大量漏洞,领取会把全部待发行读进内存、渲染成一条超长消息再被渠道长度上限截掉大半,既占内存又**静默丢失**被截掉的部分;现单批上限 500 条,超出的留库成为下一批,不会丢。 +- **修复邮件渠道把临时性 SMTP 失败判成永久失败**:SMTP 的 4xx(如灰名单 `450`)是临时拒绝、正规做法是稍后重试,而此前一律判永久失败——一个启用灰名单的邮件服务器会让**每条**推送都在第一次尝试后落入失败,而这类场景恰恰是自动重试最该起作用的地方。现按应答码首位区分:4xx 可重试、5xx 永久失败,取不到码时按可重试处理。 + +#### 设计说明 + +- **写漏洞的事务只做一次盲 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` 包另有十余处,本次只修了被实证触发的那一处。 + +### 流量 #### 新增的功能 diff --git a/db/finding_traffic.go b/db/finding_traffic.go index fc74462..bfa485d 100644 --- a/db/finding_traffic.go +++ b/db/finding_traffic.go @@ -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 } diff --git a/db/findings.go b/db/findings.go index cca9033..56e5fa7 100644 --- a/db/findings.go +++ b/db/findings.go @@ -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 { diff --git a/db/notification.go b/db/notification.go new file mode 100644 index 0000000..ea6d1d2 --- /dev/null +++ b/db/notification.go @@ -0,0 +1,528 @@ +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 + } + if c.RatePerMin <= 0 { + // 0 表示不限流,是合法配置;但未指定时给一个安全默认值, + // 避免新渠道默认就把平台打限流。 + if ch, ok := notify.Get(c.Kind); ok { + c.RatePerMin = ch.DefaultRatePerMin() + } + } + 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 ( + 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 nil + } + if scanErr != nil { + return scanErr + } + found = true + if from == status { + return nil + } + if _, err := tx.ExecContext(ctx, `UPDATE findings SET status=$2 WHERE id=$1`, id, status); err != nil { + return 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 nil + }) + return from, found, notified, err +} + +// 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 +} diff --git a/db/notification_delivery.go b/db/notification_delivery.go new file mode 100644 index 0000000..92e3764 --- /dev/null +++ b/db/notification_delivery.go @@ -0,0 +1,410 @@ +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, lease time.Duration) ([]*NotificationDelivery, error) { + 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, MaxDigestBatchSize}, + }, 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 +} + +// 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 +} diff --git a/db/notification_test.go b/db/notification_test.go new file mode 100644 index 0000000..4646204 --- /dev/null +++ b/db/notification_test.go @@ -0,0 +1,758 @@ +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, 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, 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":"a@b.c","to":["x@y.z"]}`), + 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) + } +} + +// TestSaveNotificationChannelDefaultsRate 覆盖「未指定限流时套用渠道默认值」。 +// 漏掉这一步会让新渠道默认不限流,直接把平台打到限流。 +func TestSaveNotificationChannelDefaultsRate(t *testing.T) { + d := notifyTestDB(t) + ctx := context.Background() + ch := &NotificationChannel{ + Name: "默认限流", Kind: notify.KindDingTalk, + Config: json.RawMessage(`{"webhook":"https://example.com/h"}`), + } + id, err := d.SaveNotificationChannel(ctx, ch) + 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 != 20 { + t.Fatalf("钉钉默认限流应为 20,得到 %d", got.RatePerMin) + } + if got.Mode != NotifyModeRealtime { + t.Fatalf("默认模式应为 realtime,得到 %s", got.Mode) + } +} + +// 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("删渠道不应连带删除事件本身") + } +} diff --git a/db/schema.sql b/db/schema.sql index 908aaaa..a945b76 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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); diff --git a/evidence/store.go b/evidence/store.go index b785176..c9a7ec2 100644 --- a/evidence/store.go +++ b/evidence/store.go @@ -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 diff --git a/notify/channel.go b/notify/channel.go new file mode 100644 index 0000000..c47b26a --- /dev/null +++ b/notify/channel.go @@ -0,0 +1,180 @@ +package notify + +import ( + "context" + "errors" + "sort" + "strconv" + "strings" +) + +// Channel 是一个通知渠道的适配器。实现必须**无状态**:同一个实例会被多个渠道 +// 配置并发复用,凭据一律从 cfg 参数传入。 +type Channel interface { + // Kind 返回渠道类型标识,须与注册表的键一致。 + Kind() string + // Validate 在保存配置时调用,校验必填字段与格式。返回的错误会直接展示给 + // 配置者,所以文案要说明「缺哪个字段」而不是泛泛的「配置无效」。 + Validate(cfg map[string]any) error + // Send 投递一次消息。返回 nil 表示平台已确认接收;返回错误表示失败, + // 其中 *PermanentError 表示不该重试(投递引擎据此跳过退避)。 + Send(ctx context.Context, cfg map[string]any, m Message) error + // DefaultRatePerMin 返回该渠道官方建议的每分钟投递上限,作为新建渠道实例 + // 时的默认限流值。返回 0 表示无已知限制。 + DefaultRatePerMin() int + // SecretKeys 返回该渠道配置里属于凭据的键名。API 回显时这些键的值会被掩码, + // 更新时收到掩码值则保留库中的原值。只有实现自己清楚哪些字段算凭据 + // (企业微信的整个 Webhook 地址就是凭据,而钉钉的只是其中的 secret), + // 所以这个知识必须由渠道提供,不能由上层猜测。 + SecretKeys() []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 +} diff --git a/notify/channels_test.go b/notify/channels_test.go new file mode 100644 index 0000000..bddf5a1 --- /dev/null +++ b/notify/channels_test.go @@ -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: ``, + 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, "`, + Summary: "a & b > c", + DetailURL: `https://artex.local/x?a="onmouseover=alert(1)`, + }}, + } + html := htmlBody(m, 0) + if strings.Contains(html, "`, Summary: "a & b < c", }}} - if err := (telegramChannel{}).Send(context.Background(), + 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) } @@ -233,14 +233,14 @@ func TestTelegramEscapesHTMLInUntrustedContent(t *testing.T) { func TestTelegramErrorClassification(t *testing.T) { rateLimited := capturePost(t, `{"ok":false,"error_code":429,"description":"Too Many Requests"}`, nil) - err := (telegramChannel{}).Send(context.Background(), + _, 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(), + _, 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) @@ -267,7 +267,7 @@ func TestWebhookDefaultTemplateProducesValidJSON(t *testing.T) { t.Errorf("finding_id 应为数字,得到 %T", it["finding_id"]) } }) - if err := (webhookChannel{}).Send(context.Background(), map[string]any{"url": srv.URL}, singleMsg()); err != nil { + if _, err := (webhookChannel{}).Send(context.Background(), map[string]any{"url": srv.URL}, singleMsg()); err != nil { t.Fatalf("投递失败: %v", err) } } @@ -289,14 +289,14 @@ func TestWebhookCustomTemplateAndHeaders(t *testing.T) { "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 { + 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()) + _, err := (webhookChannel{}).Send(context.Background(), cfg, singleMsg()) if err == nil || !IsPermanent(err) { t.Fatalf("渲染结果非 JSON 应为永久失败(模板写错了,重试无用),得到 %v", err) } diff --git a/notify/dingtalk.go b/notify/dingtalk.go index b970070..02088a2 100644 --- a/notify/dingtalk.go +++ b/notify/dingtalk.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "fmt" + "net" "net/url" "strconv" "time" @@ -31,6 +32,9 @@ 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 == "" { @@ -43,19 +47,19 @@ func (dingTalkChannel) Validate(cfg map[string]any) error { } // Send 投递一次消息。有回链且是单条时用 ActionCard(带按钮),否则用 markdown。 -func (c dingTalkChannel) Send(ctx context.Context, cfg map[string]any, m Message) error { +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 Permanent(err) + return 0, Permanent(err) } endpoint, err := dingTalkSignedURL(hook, cfgString(cfg, "secret"), time.Now()) if err != nil { - return Permanent(err) + return 0, Permanent(err) } title := markdownTitle(m) // 钉钉 markdown 正文无明确字节上限,但仍做上限保护,避免证据字段异常膨胀。 - text := markdownBody(m, 20000) + text, kept := markdownBody(m, 20000) var payload any if !m.Batch && len(m.Items) == 1 && m.Items[0].DetailURL != "" { @@ -78,7 +82,7 @@ func (c dingTalkChannel) Send(ctx context.Context, cfg map[string]any, m Message raw, err := doJSON(ctx, "POST", endpoint, nil, payload) if err != nil { - return err + return 0, err } // 钉钉把业务错误藏在 200 响应里。 var res struct { @@ -86,14 +90,14 @@ func (c dingTalkChannel) Send(ctx context.Context, cfg map[string]any, m Message ErrMsg string `json:"errmsg"` } if err := json.Unmarshal(raw, &res); err != nil { - return fmt.Errorf("解析钉钉响应失败: %w (%s)", err, snippet(raw)) + return 0, fmt.Errorf("解析钉钉响应失败: %w (%s)", err, snippet(raw)) } if res.ErrCode != 0 { // 301000 是签名校验失败、310000 是关键词不匹配——都是配置错误, // 重试不会自愈。 - return Permanent(fmt.Errorf("钉钉返回错误 %d: %s", res.ErrCode, res.ErrMsg)) + return 0, Permanent(fmt.Errorf("钉钉返回错误 %d: %s", res.ErrCode, res.ErrMsg)) } - return nil + return kept, nil } // dingTalkSignedURL 按官方加签规则给 webhook 追加 timestamp 与 sign 参数。 @@ -122,13 +126,26 @@ func dingTalkSignedURL(hook, secret string, now time.Time) (string, error) { return u.String(), nil } -// validateHTTPURL 校验地址可用且协议受支持。 -// 限制协议是防御性的:file:// 之类会让 http.Client 产生意料之外的行为, -// 虽然 webhook 地址由管理员配置、不是攻击者可控,但没有理由放开这个面。 +// 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 err + return fmt.Errorf("地址无法解析(%s)", redactRequestTarget(raw)) } if u.Scheme != "http" && u.Scheme != "https" { return fmt.Errorf("只支持 http/https,收到 %q", u.Scheme) @@ -136,5 +153,8 @@ func validateHTTPURL(raw string) error { 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 } diff --git a/notify/email.go b/notify/email.go index 6f0627a..bba4b00 100644 --- a/notify/email.go +++ b/notify/email.go @@ -34,6 +34,10 @@ 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 服务器地址") @@ -51,9 +55,9 @@ func (emailChannel) Validate(cfg map[string]any) error { return nil } -func (c emailChannel) Send(ctx context.Context, cfg map[string]any, m Message) error { +func (c emailChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) { if err := c.Validate(cfg); err != nil { - return Permanent(err) + return 0, Permanent(err) } host := cfgString(cfg, "host") port := cfgInt(cfg, "port") @@ -65,13 +69,13 @@ func (c emailChannel) Send(ctx context.Context, cfg map[string]any, m Message) e msg, err := buildEmailMessage(from, to, m) if err != nil { - return Permanent(err) + return 0, Permanent(err) } addr := net.JoinHostPort(host, strconv.Itoa(port)) client, err := emailDial(ctx, addr, host, implicitTLS) if err != nil { - return err + return 0, err } defer client.Close() @@ -79,7 +83,7 @@ func (c emailChannel) Send(ctx context.Context, cfg map[string]any, m Message) e if !implicitTLS { if ok, _ := client.Extension("STARTTLS"); ok { if err := client.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil { - return fmt.Errorf("STARTTLS 失败: %w", err) + return 0, fmt.Errorf("STARTTLS 失败: %w", err) } } } @@ -89,32 +93,33 @@ func (c emailChannel) Send(ctx context.Context, cfg map[string]any, m Message) e // 这是**正确**的安全行为,不能绕过,但需要把原因翻译清楚—— // 否则使用者只会看到「unencrypted connection」而不知道该怎么办。 if strings.Contains(err.Error(), "unencrypted connection") { - return Permanent(fmt.Errorf("拒发凭据:连接未加密。请启用 TLS,或改用 465 端口(隐式 TLS),或把「启用 TLS」勾上 (%w)", err)) + return 0, Permanent(fmt.Errorf("拒发凭据:连接未加密。请启用 TLS,或改用 465 端口(隐式 TLS),或把「启用 TLS」勾上 (%w)", err)) } - return Permanent(fmt.Errorf("SMTP 认证失败: %w", err)) + return 0, Permanent(fmt.Errorf("SMTP 认证失败: %w", err)) } } if err := client.Mail(from); err != nil { - return smtpStageError(fmt.Sprintf("发件人 %s 被拒", from), err) + return 0, smtpStageError(fmt.Sprintf("发件人 %s 被拒", from), err) } for _, rcpt := range to { if err := client.Rcpt(rcpt); err != nil { - return smtpStageError(fmt.Sprintf("收件人 %s 被拒", rcpt), err) + return 0, smtpStageError(fmt.Sprintf("收件人 %s 被拒", rcpt), err) } } w, err := client.Data() if err != nil { - return fmt.Errorf("SMTP DATA 失败: %w", err) + return 0, fmt.Errorf("SMTP DATA 失败: %w", err) } if _, err := w.Write([]byte(msg)); err != nil { - return fmt.Errorf("写入邮件正文失败: %w", err) + return 0, fmt.Errorf("写入邮件正文失败: %w", err) } if err := w.Close(); err != nil { - return fmt.Errorf("提交邮件失败: %w", err) + return 0, fmt.Errorf("提交邮件失败: %w", err) } // Quit 失败不影响「邮件已被服务器接收」这个事实,因此忽略其错误。 _ = client.Quit() - return nil + // 邮件没有长度截断(HTML 正文全部发送),整批都算送达。 + return len(m.Items), nil } // emailDial 建立 SMTP 连接。 diff --git a/notify/email_test.go b/notify/email_test.go index 2c60aec..242e602 100644 --- a/notify/email_test.go +++ b/notify/email_test.go @@ -156,7 +156,7 @@ func TestEmailSendDeliversFullMessage(t *testing.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 { + if _, err := (emailChannel{}).Send(context.Background(), cfg, singleMsg()); err != nil { t.Fatalf("投递失败: %v", err) } // 信封阶段必须走到:发件人、两个收件人、DATA。 @@ -186,7 +186,7 @@ func TestEmailSendWithoutAuth(t *testing.T) { // 未配账号时不应发 AUTH —— 有些中继会因此拒收。 f := newFakeSMTP(t) cfg := emailCfg(t, f, nil) - if err := (emailChannel{}).Send(context.Background(), cfg, singleMsg()); err != nil { + if _, err := (emailChannel{}).Send(context.Background(), cfg, singleMsg()); err != nil { t.Fatalf("投递失败: %v", err) } if f.sawCommand("AUTH") { @@ -214,7 +214,7 @@ func TestEmailSendClassifiesSMTPReplies(t *testing.T) { f := newFakeSMTP(t) f.rcptReply = tc.rcptReply f.mailReply = tc.mailReply - err := (emailChannel{}).Send(context.Background(), emailCfg(t, f, nil), singleMsg()) + _, err := (emailChannel{}).Send(context.Background(), emailCfg(t, f, nil), singleMsg()) if err == nil { t.Fatal("应报错") } @@ -244,7 +244,7 @@ func TestEmailSendRefusesPlaintextCredentials(t *testing.T) { "username": "artex", "password": "pw", } - err := (emailChannel{}).Send(context.Background(), cfg, singleMsg()) + _, err := (emailChannel{}).Send(context.Background(), cfg, singleMsg()) if err == nil { t.Skip("本机 DNS 解析到了本地服务器,跳过(不影响其它用例)") } diff --git a/notify/feishu.go b/notify/feishu.go index 23daa36..d79bcf6 100644 --- a/notify/feishu.go +++ b/notify/feishu.go @@ -28,6 +28,9 @@ 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 == "" { @@ -39,13 +42,14 @@ func (feishuChannel) Validate(cfg map[string]any) error { return nil } -func (c feishuChannel) Send(ctx context.Context, cfg map[string]any, m Message) error { +func (c feishuChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) { if err := c.Validate(cfg); err != nil { - return Permanent(err) + return 0, Permanent(err) } + card, kept := feishuCard(m) payload := map[string]any{ "msg_type": "interactive", - "card": feishuCard(m), + "card": card, } // 加签参数与消息同层,且只在配置了 secret 时出现。 if secret := cfgString(cfg, "secret"); secret != "" { @@ -55,7 +59,7 @@ func (c feishuChannel) Send(ctx context.Context, cfg map[string]any, m Message) } raw, err := doJSON(ctx, "POST", cfgString(cfg, "webhook"), nil, payload) if err != nil { - return err + return 0, err } var res struct { Code int `json:"code"` @@ -65,15 +69,15 @@ func (c feishuChannel) Send(ctx context.Context, cfg map[string]any, m Message) StatusMessage string `json:"StatusMessage"` } if err := json.Unmarshal(raw, &res); err != nil { - return fmt.Errorf("解析飞书响应失败: %w (%s)", err, snippet(raw)) + return 0, fmt.Errorf("解析飞书响应失败: %w (%s)", err, snippet(raw)) } if res.Code != 0 { - return Permanent(fmt.Errorf("飞书返回错误 %d: %s", res.Code, res.Msg)) + return 0, Permanent(fmt.Errorf("飞书返回错误 %d: %s", res.Code, res.Msg)) } if res.StatusCode != 0 { - return Permanent(fmt.Errorf("飞书返回错误 %d: %s", res.StatusCode, res.StatusMessage)) + return 0, Permanent(fmt.Errorf("飞书返回错误 %d: %s", res.StatusCode, res.StatusMessage)) } - return nil + return kept, nil } // feishuSign 按飞书官方规则计算签名。 @@ -108,18 +112,31 @@ func feishuSeverityTemplate(severity string) string { } } -// feishuCard 构造交互式卡片。 -func feishuCard(m Message) map[string]any { +// feishuMaxCardBytes 是卡片内容的保守上限。飞书对卡片有体积限制,超了整条被拒; +// 取一个明显低于官方上限的值,把 JSON 包装开销也算进来。 +const feishuMaxCardBytes = 24000 + +// feishuCard 构造交互式卡片,返回卡片与**实际写入的条目数**。 +// kept 的用途同 markdownBody:只有真正进了卡片的条目才该被标记为已送达。 +func feishuCard(m Message) (map[string]any, int) { elements := []any{} + kept := 0 if m.Batch { - elements = append(elements, feishuMarkdownDiv(markdownBatchIntro(m))) - for i, it := range m.Items { + // 先按整条打包再拼头部:头部要写「其余 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 != "" { @@ -135,7 +152,7 @@ func feishuCard(m Message) map[string]any { if len(m.Items) > 0 { card["header"].(map[string]any)["template"] = feishuSeverityTemplate(m.Items[0].Severity) } - return card + return card, kept } func feishuMarkdownDiv(content string) map[string]any { @@ -155,28 +172,35 @@ func feishuButton(label, url string) map[string]any { } // feishuItemLines 渲染单个漏洞的 lark_md 正文。 +// +// lark_md 与 markdown 是同族的文本格式,同样会解析链接与强调,所以来自外部 +// 的字段一律过 markdownText(单行化 + 转义)——否则一条漏洞标题就能在 +// 飞书里变成可点击的外链。 func feishuItemLines(it Item) string { - out := fmt.Sprintf("**%s · %s**", SeverityLabel(it.Severity), it.Title()) + out := fmt.Sprintf("**%s · %s**", SeverityLabel(it.Severity), markdownText(it.Title(), 0)) if it.IsStatusChange() { - out += fmt.Sprintf("\n**状态变更**:%s → %s", StatusLabel(it.FromStatus), StatusLabel(it.ToStatus)) + 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", it.VulnClass) + out += fmt.Sprintf("\n**类型**:%s", markdownText(it.VulnClass, 0)) } if a := assetLine(it.Assets, maxAssetsShown); a != "" { - out += fmt.Sprintf("\n**资产**:%s", a) + out += fmt.Sprintf("\n**资产**:%s", markdownText(a, 0)) } - if s := OneLine(it.Summary, maxSummaryRunes); s != "" { - out += fmt.Sprintf("\n**摘要**:%s", s) + 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), it.Title()) + line := fmt.Sprintf("**%d. %s · %s**", index, SeverityLabel(it.Severity), markdownText(it.Title(), 0)) if a := assetLine(it.Assets, maxAssetsShown); a != "" { - line += " — " + a + line += " — " + markdownText(a, 0) } return line } diff --git a/notify/http.go b/notify/http.go index d2c8603..3416de0 100644 --- a/notify/http.go +++ b/notify/http.go @@ -7,12 +7,93 @@ import ( "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):那个代理是给渗透 @@ -24,7 +105,8 @@ import ( // bot token)**就在 URL 里**,跟随跨主机跳转等于把凭据交给重定向目标。同主机 // 的跳转(如末尾补斜杠)仍允许。 var httpClient = &http.Client{ - Timeout: 15 * time.Second, + Timeout: 15 * time.Second, + Transport: notifyTransport, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 5 { return errors.New("重定向次数过多") diff --git a/notify/markdown.go b/notify/markdown.go index e97f4d1..2d33b0e 100644 --- a/notify/markdown.go +++ b/notify/markdown.go @@ -16,7 +16,60 @@ const maxAssetsShown = 3 // 不是报告本体,完整内容在平台里。 const maxSummaryRunes = 120 -// markdownTitle 返回消息标题(IM 平台的标题栏/卡片标题)。 +// markdownReservedBytes 预留给消息头部(汇总行 + 级别分布 + 可能的截断提示) +// 与尾部(平台链接)。按整条打包时把这部分从预算里扣掉,保证头尾不会被截掉—— +// 头尾一旦被截,读者连「这是哪一批、还有多少条没显示」都看不出来。 +const markdownReservedBytes = 320 + +// markdownEscape 转义 markdown 元字符。 +// +// 为什么必须做:漏洞标题、摘要、类型、资产展示名全都来自**不可信来源**—— +// 标题与摘要出自模型输出(模型读的是被测目标的响应),资产的 url 则是扫描 +// 得到的完整 URL(含目标可控的查询串)。不转义的话,一条标题为 +// +// 登录口 SQL 注入\n[紧急:点此验证账号](http://attacker.tld) +// +// 的漏洞会在安全工程师的钉钉/飞书里渲染成**可点击的外链**;而 +// `![](http://attacker.tld/beacon)` 会在渲染时被客户端拉取,等于通报了 +// 「这条漏洞已经被看过」并泄露阅读者 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)) @@ -25,46 +78,71 @@ func markdownTitle(m Message) string { return "漏洞通知" } it := m.Items[0] - return fmt.Sprintf("[%s] %s", SeverityLabel(it.Severity), it.Title()) + return fmt.Sprintf("[%s] %s", SeverityLabel(it.Severity), OneLine(it.Title(), 0)) } -// markdownBody 渲染消息正文。maxBytes<=0 表示不截断;调用方按平台限制传入 -// (企微 4096 字节是最紧的那个)。截断发生在整篇渲染之后,所以不会切坏 -// markdown 语法之外的字符——但我们仍按字符边界切,见 TruncateBytes。 -func markdownBody(m Message, maxBytes int) string { +// 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 - if m.Batch { - b.WriteString(markdownBatchIntro(m)) - for i, it := range m.Items { - writeItem(&b, it, fmt.Sprintf("%d. ", i+1), false) - } - if m.HomeURL != "" { - fmt.Fprintf(&b, "\n[在平台中查看全部](%s)\n", m.HomeURL) - } - return TruncateBytes(b.String(), maxBytes) + b.WriteString(markdownBatchIntro(m, items, len(m.Items))) + for i, it := range items { + writeItem(&b, it, fmt.Sprintf("%d. ", i+1), false) } - if len(m.Items) == 0 { - return "" - } - writeItem(&b, m.Items[0], "", true) - return TruncateBytes(b.String(), maxBytes) + b.WriteString(footer) + return TruncateBytes(b.String(), maxBytes), kept } -// markdownBatchIntro 渲染汇总消息的开头:时间窗与条数。有了这两项,收到汇总的 -// 人不用点进平台就能判断这批需不需要立刻处理。 -func markdownBatchIntro(m Message) string { +// 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, len(m.Items)) + fmt.Fprintf(&b, "**近 %d 分钟新增 %d 个漏洞**", m.WindowMinutes, total) } else { - fmt.Fprintf(&b, "**新增 %d 个漏洞**", len(m.Items)) + 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 m.Items { + for _, it := range items { counts[it.Severity]++ } - // 固定按级别从高到低列出,未知级别不参与(避免出现「 2」这样的空标签)。 var parts []string for _, sev := range []string{"critical", "high", "medium", "low"} { if n := counts[sev]; n > 0 { @@ -82,16 +160,20 @@ func markdownBatchIntro(m Message) string { // // 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), it.Title()) + 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, a) + extras = append(extras, markdownText(a, 0)) } if it.Summary != "" { - extras = append(extras, OneLine(it.Summary, 60)) + extras = append(extras, markdownText(it.Summary, 60)) } if len(extras) > 0 { line += " — " + strings.Join(extras, " · ") @@ -101,16 +183,19 @@ func writeItem(b *strings.Builder, it Item, prefix string, single bool) { } b.WriteString(line + "\n") if it.IsStatusChange() { - fmt.Fprintf(b, "**状态变更**:%s → %s\n", StatusLabel(it.FromStatus), StatusLabel(it.ToStatus)) + 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", it.VulnClass) + fmt.Fprintf(b, "**类型**:%s\n", markdownText(it.VulnClass, 0)) } if a := assetLine(it.Assets, maxAssetsShown); a != "" { - fmt.Fprintf(b, "**资产**:%s\n", a) + fmt.Fprintf(b, "**资产**:%s\n", markdownText(a, 0)) } - if s := OneLine(it.Summary, maxSummaryRunes); s != "" { - fmt.Fprintf(b, "**摘要**:%s\n", s) + 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) diff --git a/notify/mask.go b/notify/mask.go index b649231..0812250 100644 --- a/notify/mask.go +++ b/notify/mask.go @@ -1,6 +1,10 @@ package notify -import "strings" +import ( + "encoding/json" + "fmt" + "strings" +) // MaskedPrefix 是掩码值的标记前缀。API 回显凭据时用带此前缀的值替换真实内容, // 更新接口收到带此前缀的值即理解为「保持库中原值不变」。 @@ -58,6 +62,139 @@ func MaskConfig(kind string, cfg map[string]any) map[string]any { 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 之上,用于更新渠道配置。 // // 规则: diff --git a/notify/mask_test.go b/notify/mask_test.go index 16ea85e..e24c496 100644 --- a/notify/mask_test.go +++ b/notify/mask_test.go @@ -1,6 +1,7 @@ package notify import ( + "errors" "strings" "testing" ) @@ -113,6 +114,181 @@ func TestMergeConfigKeepsUnmentionedStoredKeys(t *testing.T) { } } +// 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": "a@b.c", "to": []any{"d@e.f"}}, + 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": "a@b.c", "to": []any{"d@e.f"}}, + 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": "a@b.c", "to": []any{"x@y.z"}}, + incoming: map[string]any{"to": []any{"new@y.z"}}, + }, + } + 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,这里再确认一遍「没有渠道在掩码上 // 交白卷」——返回空切片的渠道意味着它的凭据会明文回显到浏览器。 @@ -130,3 +306,26 @@ func TestSecretKeysDeclaredForEveryKind(t *testing.T) { } } } + +// 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) + } +} diff --git a/notify/pack_test.go b/notify/pack_test.go new file mode 100644 index 0000000..1f55eb9 --- /dev/null +++ b/notify/pack_test.go @@ -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: "漏洞 ![](http://attacker.tld/beacon)", + }, + must: []string{`\!`, `\(http://attacker.tld/beacon\)`}, + wrong: []string{"![]("}, + }, + { + name: "资产名里的强调与引用", + item: Item{ + Severity: "high", + Name: "普通标题", + Assets: []string{"a.com/*注入*>引用"}, + }, + must: []string{`\*注入\*`, `\>`}, + wrong: []string{"*注入*"}, + }, + { + name: "摘要里的反引号与竖线", + item: Item{ + Severity: "high", + Name: "标题", + Summary: "`code` | 表格", + }, + must: []string{"\\`code\\`", `\|`}, + wrong: []string{"`code`"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + 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 +} diff --git a/notify/redact_test.go b/notify/redact_test.go index b0a4fa1..3f8b86d 100644 --- a/notify/redact_test.go +++ b/notify/redact_test.go @@ -75,7 +75,7 @@ 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{ + _, err := tc.ch.Send(context.Background(), tc.cfg, Message{ Items: []Item{{FindingID: 1, Severity: "high", Name: "泄露探针"}}, }) if err == nil { @@ -103,7 +103,7 @@ func TestChannelErrorsNeverLeakCredentialsInPermanentPath(t *testing.T) { } 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"}}}) + _, err := tc.ch.Send(context.Background(), tc.cfg, Message{Items: []Item{{Severity: "high"}}}) if err == nil { t.Fatal("非法配置应报错") } @@ -203,7 +203,7 @@ func TestCrossHostRedirectRefused(t *testing.T) { })) defer redirector.Close() - err := (dingTalkChannel{}).Send(context.Background(), + _, err := (dingTalkChannel{}).Send(context.Background(), map[string]any{"webhook": redirector.URL + "/robot/send?access_token=" + leakProbeToken}, Message{Items: []Item{{Severity: "high"}}}) if err == nil { @@ -228,7 +228,7 @@ func TestSameHostRedirectAllowed(t *testing.T) { })) defer srv.Close() - if err := (dingTalkChannel{}).Send(context.Background(), + 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) diff --git a/notify/render.go b/notify/render.go index 53043d9..fb46a56 100644 --- a/notify/render.go +++ b/notify/render.go @@ -78,9 +78,52 @@ func TruncateHTML(s string, max int) string { 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 { diff --git a/notify/render_test.go b/notify/render_test.go index b13a648..1f8ed72 100644 --- a/notify/render_test.go +++ b/notify/render_test.go @@ -129,3 +129,22 @@ func TestSeverityAndStatusLabels(t *testing.T) { 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) + } + } +} diff --git a/notify/ssrf_test.go b/notify/ssrf_test.go new file mode 100644 index 0000000..da15182 --- /dev/null +++ b/notify/ssrf_test.go @@ -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) +} diff --git a/notify/telegram.go b/notify/telegram.go index ea994d3..5f11150 100644 --- a/notify/telegram.go +++ b/notify/telegram.go @@ -29,6 +29,9 @@ 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") @@ -44,23 +47,24 @@ func (telegramChannel) Validate(cfg map[string]any) error { return nil } -func (c telegramChannel) Send(ctx context.Context, cfg map[string]any, m Message) error { +func (c telegramChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) { if err := c.Validate(cfg); err != nil { - return Permanent(err) + return 0, Permanent(err) } endpoint, err := telegramEndpoint(cfg) if err != nil { - return Permanent(err) + return 0, Permanent(err) } + text, kept := telegramHTML(m) payload := map[string]any{ "chat_id": cfgString(cfg, "chat_id"), - "text": telegramHTML(m), + "text": text, "parse_mode": "HTML", "disable_web_page_preview": false, } raw, err := doJSON(ctx, "POST", endpoint, nil, payload) if err != nil { - return err + return 0, err } var res struct { OK bool `json:"ok"` @@ -68,17 +72,17 @@ func (c telegramChannel) Send(ctx context.Context, cfg map[string]any, m Message Description string `json:"description"` } if err := json.Unmarshal(raw, &res); err != nil { - return fmt.Errorf("解析 Telegram 响应失败: %w (%s)", err, snippet(raw)) + return 0, fmt.Errorf("解析 Telegram 响应失败: %w (%s)", err, snippet(raw)) } if res.OK { - return nil + return kept, nil } // 429 是限流,退避后重试有效;其余(400 参数错、401 token 错、403 被拉黑、 // 404 chat 不存在)都是配置问题,重试不会自愈。 if res.ErrorCode == 429 { - return fmt.Errorf("Telegram 限流: %s", res.Description) + return 0, fmt.Errorf("Telegram 限流: %s", res.Description) } - return Permanent(fmt.Errorf("Telegram 返回错误 %d: %s", res.ErrorCode, res.Description)) + return 0, Permanent(fmt.Errorf("Telegram 返回错误 %d: %s", res.ErrorCode, res.Description)) } // telegramEndpoint 拼出 sendMessage 地址。base_url 留空时用官方 API, @@ -99,27 +103,30 @@ func telegramEndpoint(cfg map[string]any) (string, error) { return u.String(), nil } -// telegramHTML 渲染 HTML 正文。 -func telegramHTML(m Message) string { +// telegramHTML 渲染 HTML 正文,返回正文与实际写入的条目数(见 Channel.Send)。 +func telegramHTML(m Message) (string, int) { var b strings.Builder b.WriteString("" + telegramEscape(markdownTitle(m)) + "\n") if m.Batch { - for i, it := range m.Items { - var line string - if a := assetLine(it.Assets, maxAssetsShown); a != "" { - line = fmt.Sprintf("%d. %s · %s — %s", i+1, SeverityLabel(it.Severity), it.Title(), a) - } else { - line = fmt.Sprintf("%d. %s · %s", i+1, SeverityLabel(it.Severity), it.Title()) - } - b.WriteString("\n" + telegramEscape(line)) - } + // Telegram 的上限是**字符数**,所以打包也按字符计量(runeSize)。 + footer := "" if m.HomeURL != "" { - b.WriteString(fmt.Sprintf("\n\n在平台中查看全部", telegramEscapeAttr(m.HomeURL))) + footer = fmt.Sprintf("\n\n在平台中查看全部", telegramEscapeAttr(m.HomeURL)) } - return TruncateHTML(b.String(), telegramTextLimit) + 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("" + telegramEscape(telegramBatchTitle(m, items, len(m.Items))) + "") + 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() + return b.String(), 0 } it := m.Items[0] if it.IsStatusChange() { @@ -138,7 +145,31 @@ func telegramHTML(m Message) string { if it.DetailURL != "" { b.WriteString(fmt.Sprintf("\n\n查看详情", telegramEscapeAttr(it.DetailURL))) } - return TruncateHTML(b.String(), telegramTextLimit) + 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 文本内容。 diff --git a/notify/webhook.go b/notify/webhook.go index bcb50a9..329b822 100644 --- a/notify/webhook.go +++ b/notify/webhook.go @@ -28,6 +28,10 @@ func (webhookChannel) DefaultRatePerMin() int { return 0 } // 这个取舍是刻意的:宁可多填一次,也不把凭据回显到浏览器。 func (webhookChannel) SecretKeys() []string { return []string{"url", "headers"} } +// 目的地是 url。改 url 时必须重新表态 headers —— 否则原始 Authorization 头 +// 会被原样发到新地址,这正是掩码绕过的主路径。 +func (webhookChannel) DestinationKeys() []string { return []string{"url"} } + // webhookDefaultTemplate 是未填模板时的兜底请求体:一个直白的 JSON 结构, // 覆盖绝大多数「收一条 JSON 入库」的自建接收端。 const webhookDefaultTemplate = `{ @@ -95,9 +99,9 @@ func (webhookChannel) Validate(cfg map[string]any) error { return nil } -func (c webhookChannel) Send(ctx context.Context, cfg map[string]any, m Message) error { +func (c webhookChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) { if err := c.Validate(cfg); err != nil { - return Permanent(err) + return 0, Permanent(err) } method := strings.ToUpper(cfgString(cfg, "method")) if method == "" { @@ -110,12 +114,12 @@ func (c webhookChannel) Send(ctx context.Context, cfg map[string]any, m Message) if method != http.MethodGet { body, err := renderWebhookBody(cfgString(cfg, "body_template"), m) if err != nil { - return Permanent(err) + return 0, Permanent(err) } // 模板渲染出的是字符串形式的 JSON,这里转成 json.RawMessage 原样发出, // 避免二次转义把用户精心构造的结构套进一个 JSON 字符串里。 if !json.Valid([]byte(body)) { - return Permanent(errors.New("请求体模板渲染结果不是合法 JSON")) + return 0, Permanent(errors.New("请求体模板渲染结果不是合法 JSON")) } payload = json.RawMessage(body) } @@ -129,9 +133,11 @@ func (c webhookChannel) Send(ctx context.Context, cfg map[string]any, m Message) headers["Content-Type"] = ct } if _, err := doJSON(ctx, method, cfgString(cfg, "url"), headers, payload); err != nil { - return err + return 0, err } - return nil + // 通用 Webhook 不截断正文(接收端是用户自己的服务,体积由 body_template 决定), + // 因此整批都算送达。 + return len(m.Items), nil } // renderWebhookBody 用用户模板(或默认模板)渲染请求体。 diff --git a/notify/webhook_template_test.go b/notify/webhook_template_test.go index 93c897c..a3571b5 100644 --- a/notify/webhook_template_test.go +++ b/notify/webhook_template_test.go @@ -80,7 +80,7 @@ func TestTemplateRenderFailsPermanently(t *testing.T) { t.Fatal("模板语法错误应在保存时就被拦下") } // 即便绕过校验直接投递,也必须判永久失败而不是反复重试。 - err := (webhookChannel{}).Send(context.Background(), cfg, singleMsg()) + _, err := (webhookChannel{}).Send(context.Background(), cfg, singleMsg()) if err == nil || !IsPermanent(err) { t.Fatalf("坏模板应判永久失败,得到 %v", err) } @@ -96,7 +96,7 @@ func TestTemplateCanOnlyProduceJSON(t *testing.T) { } // 渲染出非 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()) + _, err := (webhookChannel{}).Send(context.Background(), bad, singleMsg()) if err == nil || !IsPermanent(err) { t.Fatalf("渲染出非 JSON 应判永久失败,得到 %v", err) } diff --git a/notify/wecom.go b/notify/wecom.go index 114cca8..74b63ea 100644 --- a/notify/wecom.go +++ b/notify/wecom.go @@ -28,6 +28,9 @@ func (weComChannel) DefaultRatePerMin() int { return 20 } // 整个地址就是全部凭据,没有别的字段需要掩码。 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 == "" { @@ -39,37 +42,37 @@ func (weComChannel) Validate(cfg map[string]any) error { return nil } -func (c weComChannel) Send(ctx context.Context, cfg map[string]any, m Message) error { +func (c weComChannel) Send(ctx context.Context, cfg map[string]any, m Message) (int, error) { if err := c.Validate(cfg); err != nil { - return Permanent(err) + return 0, Permanent(err) } // 汇总批可能很长(50 条 × 每条一行 + 前缀),4096 字节很容易超。 // 截断在这里做而不是靠平台报错:被拒意味着这一批全丢,而截断至少送达前若干条。 - content := markdownBody(m, weComMarkdownLimit) + 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 err + return 0, err } var res struct { ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` } if err := json.Unmarshal(raw, &res); err != nil { - return fmt.Errorf("解析企业微信响应失败: %w (%s)", err, snippet(raw)) + return 0, fmt.Errorf("解析企业微信响应失败: %w (%s)", err, snippet(raw)) } if res.ErrCode != 0 { // 45009 是接口调用超过限制——平台的限流窗口会滚动,退避后重试是有效的, // 所以显式归为可重试。走到这里说明客户端 rate_per_min 配得过于激进, // 重试只是兜底,真正的修法是调低该渠道的限流值。 if res.ErrCode == 45009 { - return fmt.Errorf("企业微信限流 %d: %s", res.ErrCode, res.ErrMsg) + return 0, fmt.Errorf("企业微信限流 %d: %s", res.ErrCode, res.ErrMsg) } // 93000 是 webhook key 无效——永久失败,重试不会自愈。 - return Permanent(fmt.Errorf("企业微信返回错误 %d: %s", res.ErrCode, res.ErrMsg)) + return 0, Permanent(fmt.Errorf("企业微信返回错误 %d: %s", res.ErrCode, res.ErrMsg)) } - return nil + return kept, nil } diff --git a/server/notifier.go b/server/notifier.go index 7734961..0b4fcae 100644 --- a/server/notifier.go +++ b/server/notifier.go @@ -42,6 +42,22 @@ const ( // 存在的意义是防止「一个渠道配成不限流 + 一次扫出上千条漏洞」把 // 单轮循环拖成长时间阻塞。 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 是失败重试的退避序列,下标为已尝试次数。 @@ -123,11 +139,15 @@ func (n *Notifier) step(ctx context.Context) { // 先问令牌桶这一轮还能发几条,再按这个数量去领——顺序不能反, // 否则被限流挡下的投递已经消耗过重试次数。 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, baseURL) + // 汇总也受同一份额度约束:领到的条数不能超过本轮令牌数。 + n.stepDigest(ctx, ch, allow, baseURL) continue } n.stepRealtime(ctx, ch, allow, baseURL) @@ -161,7 +181,7 @@ func (n *Notifier) stepRealtime(ctx context.Context, ch *db.NotificationChannel, } // stepDigest 在批次到期时把某渠道的待发投递聚合成一条消息发出。 -func (n *Notifier) stepDigest(ctx context.Context, ch *db.NotificationChannel, baseURL string) { +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 { @@ -171,7 +191,7 @@ func (n *Notifier) stepDigest(ctx context.Context, ch *db.NotificationChannel, b if !due { return } - deliveries, err := n.pg.ClaimDigestBatch(ctx, ch.ID, notifyLease) + deliveries, err := n.pg.ClaimDigestBatch(ctx, ch.ID, allow, notifyLease) if err != nil { log.Printf("[notify] 领取汇总批次失败 channel=%d: %v", ch.ID, err) return @@ -184,49 +204,127 @@ func (n *Notifier) stepDigest(ctx context.Context, ch *db.NotificationChannel, b _ = n.pg.FailDeliveries(ctx, deliveryIDs(deliveries), fmt.Sprintf("渠道类型 %q 未注册", ch.Kind)) return } - msg, err := n.renderBatch(ctx, deliveries, baseURL, int(window.Minutes())) + msg, included, err := n.renderBatch(ctx, deliveries, baseURL, int(window.Minutes())) if err != nil { _ = n.pg.FailDeliveries(ctx, deliveryIDs(deliveries), err.Error()) return } - n.send(ctx, channel, cfg, msg, deliveries) + // 快照坏掉、没能进消息的那些投递要显式判失败。不这么做的话它们会留在 + // 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) { - ids := deliveryIDs(deliveries) - attempts := maxAttempts(deliveries) - // 单次投递设上限,避免某个渠道卡住把这一轮剩余渠道全部拖住。 - sendCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + sendCtx, cancel := context.WithTimeout(ctx, notifySendTimeout) defer cancel() - err := channel.Send(sendCtx, cfg, msg) + 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 { - if err := n.pg.MarkDeliveriesSent(ctx, ids); err != nil { - log.Printf("[notify] 标记已送达失败 channel=%s ids=%v: %v", channel.Kind(), ids, err) - } - return + // 渠道既没报错也没说送达了多少条。按失败处理(走退避), + // 免得这条投递被反复领取却永远标记不掉。 + err = fmt.Errorf("渠道未报告送达条数(delivered=%d)", delivered) } - // 永久失败与「重试次数用尽」都直接落 failed,等人工在投递历史里重发; - // 其余按退避序列重排。 - if notify.IsPermanent(err) || attempts >= db.MaxNotifyAttempts { - reason := err.Error() - if !notify.IsPermanent(err) { - reason = fmt.Sprintf("重试 %d 次后仍失败: %s", attempts, err) + + // 失败处置**逐条**决定,而不是拿整批的最大尝试次数做判断。 + // + // 曾经是 `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 failErr := n.pg.FailDeliveries(ctx, ids, reason); failErr != nil { - log.Printf("[notify] 标记失败状态出错 channel=%s ids=%v: %v", channel.Kind(), ids, failErr) + } + + 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) } - log.Printf("[notify] 投递失败 channel=%d kind=%s ids=%v: %s", deliveries[0].ChannelID, channel.Kind(), ids, reason) - return } - delay := notifyBackoff[min(attempts, len(notifyBackoff)-1)] - if rErr := n.pg.RescheduleDeliveries(ctx, ids, delay, err.Error()); rErr != nil { - log.Printf("[notify] 重排投递失败 channel=%s ids=%v: %v", channel.Kind(), ids, rErr) + 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 取渠道实现并解析其配置。 @@ -263,29 +361,39 @@ func (n *Notifier) renderSingle(ctx context.Context, dl *db.NotificationDelivery // renderBatch 渲染汇总消息。逐条解析快照——单条坏了只跳过那一条, // 不让它把整批汇总拖没。 -func (n *Notifier) renderBatch(ctx context.Context, deliveries []*db.NotificationDelivery, baseURL string, windowMinutes int) (notify.Message, error) { +// +// 返回值 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{}, err + return notify.Message{}, nil, err } items = append(items, item) + included = append(included, dl) } if len(items) == 0 { - return notify.Message{}, fmt.Errorf("汇总批次 %d 条投递全部无法解析", len(deliveries)) + return notify.Message{}, nil, fmt.Errorf("汇总批次 %d 条投递全部无法解析", len(deliveries)) } return notify.Message{ Items: items, Batch: true, WindowMinutes: windowMinutes, HomeURL: baseURL, - }, nil + }, included, nil } // itemFor 把事件快照渲染成待推送条目,顺带解析资产名与详情回链。 @@ -397,18 +505,6 @@ func deliveryIDs(deliveries []*db.NotificationDelivery) []int64 { return out } -// maxAttempts 取批次里最大的已尝试次数。批次会一起成败,用最大值决定是否 -// 还有重试预算,避免新加入的行被老行的次数拖下水(反之亦然)。 -func maxAttempts(deliveries []*db.NotificationDelivery) int { - m := 0 - for _, dl := range deliveries { - if dl.Attempts > m { - m = dl.Attempts - } - } - return m -} - func trimTrailingSlash(s string) string { for len(s) > 0 && s[len(s)-1] == '/' { s = s[:len(s)-1] diff --git a/server/notify_api.go b/server/notify_api.go index 006de2c..7d1a49d 100644 --- a/server/notify_api.go +++ b/server/notify_api.go @@ -217,12 +217,19 @@ func (s *Server) notifyCreateChannel(w http.ResponseWriter, r *http.Request) { 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: // 门槛打错字会让过滤器静默失效变成全推,必须在入口拦掉。 @@ -285,7 +292,13 @@ func (s *Server) notifyUpdateChannel(w http.ResponseWriter, r *http.Request) { if stored == nil { stored = map[string]any{} } - merged := notify.MergeConfig(stored, req.Config) + // 用 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 @@ -413,7 +426,9 @@ func (s *Server) notifyTestChannel(w http.ResponseWriter, r *http.Request) { } msg := notifyTestMessage(s.notifierBaseURL(pg)) start := time.Now() - if err := channel.Send(r.Context(), cfg, msg); err != nil { + // 测试消息只有一条,送达条数这里不需要(渠道长度上限对单条消息而言 + // 由截断兜底,不涉及分段)。 + if _, err := channel.Send(r.Context(), cfg, msg); err != nil { // 把渠道返回的原始错误如实回给用户——这是他们调试配置的唯一线索。 writeErr(w, 502, err.Error()) return diff --git a/server/notify_api_test.go b/server/notify_api_test.go index a3c5ab0..d389f01 100644 --- a/server/notify_api_test.go +++ b/server/notify_api_test.go @@ -45,6 +45,10 @@ type notifyFixture struct { 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 @@ -194,18 +198,22 @@ func (f *fakeWebhook) last(t *testing.T) map[string]any { return f.body(t, f.count()-1) } -// markdownText 从钉钉 markdown/actionCard 请求体里取出正文。 +// markdownText 从请求体里取出正文,兼容各家的字段名差异: +// 钉钉 markdown 用 `text`、ActionCard 用 `text`、企业微信 markdown 用 `content`。 func markdownText(t *testing.T, body map[string]any) string { t.Helper() - if md, ok := body["markdown"].(map[string]any); ok { - s, _ := md["text"].(string) - return s + 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 + } + } } - if card, ok := body["actionCard"].(map[string]any); ok { - s, _ := card["text"].(string) - return s - } - t.Fatalf("请求体里没有 markdown/actionCard 正文: %v", body) + t.Fatalf("请求体里没有可识别的正文: %v", body) return "" } @@ -407,14 +415,14 @@ func TestNotifyDigestBatchesMultipleFindingsIntoOneMessage(t *testing.T) { ch := f.channel(t, chID) // 未到期:不发。 - f.n.stepDigest(ctx, ch, "") + f.n.stepDigest(ctx, ch, 50, "") if hook.count() != 0 { t.Fatal("汇总批次未到期就发了") } // 催老批次后:三条合成一条消息。 f.agePendingBatch(t, chID) - f.n.stepDigest(ctx, ch, "") + f.n.stepDigest(ctx, ch, 50, "") if got := hook.count(); got != 1 { t.Fatalf("三条应汇总成一条消息,实际发了 %d 条", got) } @@ -713,6 +721,112 @@ func TestNotifyNoDeepLinkWithoutBaseURL(t *testing.T) { } } +// 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)分居两个包: @@ -763,3 +877,19 @@ func TestNotifyRateLimitDoesNotConsumeRetryBudget(t *testing.T) { 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) + } +} From ee8929115e58f0a024d603689b3cf839851436c6 Mon Sep 17 00:00:00 2001 From: luckone <1139448596@qq.com> Date: Wed, 30 Sep 2026 16:11:38 +0800 Subject: [PATCH 4/5] =?UTF-8?q?refactor(web):=20=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E9=A1=B5=E6=8B=86=E5=88=86=E4=B8=BA=E7=BB=84=E4=BB=B6=EF=BC=8C?= =?UTF-8?q?1095=20=E8=A1=8C=E9=99=8D=E5=88=B0=20605=20=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 页面此前接近 1100 行,超过 CLAUDE.md 的 800 行硬上限。按项目的 _components/ 约定拆开,每个文件都能单独读懂: _components/channel-fields.ts 渠道字段表与配置值解析(纯数据,无视图) _components/channel-form.tsx 按字段定义渲染控件的表单 _components/delivery-list.tsx 投递记录表与筛选 _components/stat-tile.tsx 概览统计卡片 page.tsx 只负责编排:加载、表单状态、调接口 拆分是纯模块搬移,未改行为;tsc 与 next build 均通过,/system/notify 仍正常 预渲染。 Co-Authored-By: Claude Code --- .../notify/_components/channel-fields.ts | 181 ++++++ .../notify/_components/channel-form.tsx | 153 +++++ .../notify/_components/delivery-list.tsx | 184 ++++++ .../system/notify/_components/stat-tile.tsx | 21 + web/src/app/(main)/system/notify/page.tsx | 528 +----------------- 5 files changed, 558 insertions(+), 509 deletions(-) create mode 100644 web/src/app/(main)/system/notify/_components/channel-fields.ts create mode 100644 web/src/app/(main)/system/notify/_components/channel-form.tsx create mode 100644 web/src/app/(main)/system/notify/_components/delivery-list.tsx create mode 100644 web/src/app/(main)/system/notify/_components/stat-tile.tsx diff --git a/web/src/app/(main)/system/notify/_components/channel-fields.ts b/web/src/app/(main)/system/notify/_components/channel-fields.ts new file mode 100644 index 0000000..441ee3f --- /dev/null +++ b/web/src/app/(main)/system/notify/_components/channel-fields.ts @@ -0,0 +1,181 @@ +// 渠道字段表与配置值的解析工具。 +// +// 与页面拆开是因为这一份是**数据**而不是视图:它描述每种渠道有哪些字段、 +// 各自该用什么控件,以及表单文本到配置值(JSON)的双向转换。 +// 单独放一个文件后,新增渠道只需要动这里,页面本身不必改。 +// 渠道类型的展示名与简介。放在前端是因为它只影响文案,后端不需要知道。 +export const KIND_LABEL: Record = { + 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 = { + 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: "artex@example.com" }, + { 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; + 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 { + const out: Record = {}; + 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); +} diff --git a/web/src/app/(main)/system/notify/_components/channel-form.tsx b/web/src/app/(main)/system/notify/_components/channel-form.tsx new file mode 100644 index 0000000..bc16292 --- /dev/null +++ b/web/src/app/(main)/system/notify/_components/channel-form.tsx @@ -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 ( +
+ + {def.label} + {def.help && ({def.help})} +
+ ); + } + + if (def.kind === "select") { + return ( +
+ + +
+ ); + } + + // 控件按字段类型分派。用 if 链而不是嵌套三元,是因为这里要区分四种控件, + // 三层三元读起来已经要停下来数括号了。 + function control() { + if (def.kind === "textarea" || def.kind === "kv") { + return ( +