Files
ARTEX-chixuweihu/evidence/store.go
T
luckoneandClaude Code f3fb37f70d feat(notify): 漏洞发现支持推送到钉钉/飞书/企业微信等六种渠道
新增「系统 → 通知推送」页,把扫描发现的漏洞推送到钉钉、飞书、企业微信、
通用 Webhook、Telegram、邮件。同一类型可配任意多个机器人实例,每个实例独立
设启停、限流与过滤规则(最低级别 / 任务资产范围 / 漏洞类型关键词 / 是否接收
处置状态变更)。推送时机分实时与汇总两种模式,「高危实时、其余汇总」建两个
渠道即可表达,策略不写死在代码里。带投递历史与失败重发。

三个关键设计:

1. 写漏洞的事务只做一次盲 INSERT。notification_events 由 RecordFindingTx
   在同一事务内写入,保证「漏洞落库」与「推送任务存在」原子一致;这条 INSERT
   刻意不读渠道表、不跑用户的过滤规则,否则一条配错的过滤条件就能污染甚至
   中止事务,让高危漏洞存不进库。为把失败隔离在这一条语句上(PostgreSQL 中
   事务内任一语句报错会让整个事务作废、连 COMMIT 都失败),它被 SAVEPOINT
   包住,失败时只记日志。

2. 投递用租约领取而非长事务。FOR UPDATE SKIP LOCKED 领取后把行置为 sending、
   把 next_attempt_at 推到未来作为租约,提交后再做网络投递,因此投递期间不
   持有数据库锁;崩溃留下的 sending 行会被下一轮重新领取,自愈且不会无限重试。

3. 限流不消耗重试预算。先按渠道令牌桶算出本轮还能发几条,再按这个数量领取;
   顺序反过来会让被限流挡下的投递白计一次尝试,三次预算被等待耗光后落入失败。

审计并修复的四个问题:

- 推送凭据经错误信息外泄:http.Client.Do 返回的 *url.Error 会把完整 URL 打进
  错误文本,而钉钉 access_token、企业微信 key、飞书 hook id、Telegram
  /bot<token>/ 都就在 URL 里。该串流入 last_error(明文落库)、投递历史接口
  (绕过渠道掩码)、服务端日志与前端错误提示。现统一脱敏为 scheme://host
  + 底层原因,并拒绝跨主机重定向。
- 最低级别门槛打错字会让过滤器静默失效:未知级别序数为 0,判定退化成
  rank >= 0 恒真,用户以为限制「仅高危」实际全推。现于写入路径校验取值。
- 汇总批次无上限:一次领取把全部待发行读进内存、消息被渠道长度上限截掉大半
  且静默丢失。现单批上限 500 条,超出部分留库成为下一批。
- 邮件渠道把 SMTP 4xx(灰名单等临时失败)判成永久失败,导致这类最该重试的
  场景从不重试。现按应答码首位区分 4xx/5xx。

测试:notify 包 82% 覆盖(含用自建最小 SMTP 服务器驱动的完整邮件协议会话、
两种签名算法对 OpenSSL 基准值的比对、UTF-8 边界截断、以及模板能力边界的
反射断言);db 与 server 侧共 29 个用例跑真实 PostgreSQL,其中端到端用例
真发 HTTP 到假接收端并断言消息内容与投递状态。

Co-Authored-By: Claude Code <[email protected]>
2026-09-30 16:25:06 +08:00

420 lines
12 KiB
Go

// Package evidence preserves finding evidence independently of disposable traffic.
package evidence
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/traffic"
)
type Store struct {
DB *db.DB
Traffic *traffic.Traffic
Dir string
}
func New(pg *db.DB, tr *traffic.Traffic, dir string) *Store {
return &Store{DB: pg, Traffic: tr, Dir: dir}
}
func hashPath(dir, hash string) (string, error) {
if len(hash) != 64 {
return "", errors.New("invalid evidence hash")
}
if _, err := hex.DecodeString(hash); err != nil || strings.ToLower(hash) != hash {
return "", errors.New("invalid evidence hash")
}
return filepath.Join(dir, "blobs", hash[:2], hash+".bin"), nil
}
func verifyFile(path, hash string, length int64) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
h := sha256.New()
n, err := io.Copy(h, f)
if err != nil {
return err
}
if n != length || hex.EncodeToString(h.Sum(nil)) != hash {
return fmt.Errorf("证据正文校验失败: %s", hash)
}
return nil
}
// A new body becomes visible only after a durable write. Failed SQL commits may
// leave unreferenced files; GC reaps those after a full day's grace period.
func (s *Store) writeBody(r io.Reader, expectedLength int64, expectedHash string) (hash string, err error) {
stage := filepath.Join(s.Dir, ".staging")
if err = os.MkdirAll(stage, 0o700); err != nil {
return
}
f, err := os.CreateTemp(stage, "body-")
if err != nil {
return "", err
}
defer func() { f.Close(); os.Remove(f.Name()) }()
h := sha256.New()
n, err := io.Copy(io.MultiWriter(f, h), r)
if err != nil {
return "", err
}
hash = hex.EncodeToString(h.Sum(nil))
if n != expectedLength {
return "", fmt.Errorf("正文不完整: 预期 %d 字节,读取 %d 字节", expectedLength, n)
}
if expectedHash != "" && expectedHash != hash {
return "", errors.New("原始流量正文哈希不匹配")
}
if err = f.Sync(); err != nil {
return "", err
}
if err = f.Close(); err != nil {
return "", err
}
path, err := hashPath(s.Dir, hash)
if err != nil {
return "", err
}
if _, err = os.Stat(path); err == nil {
if err = verifyFile(path, hash, n); err != nil {
return "", err
}
// Refresh the grace period for a restored but not-yet-committed body.
now := time.Now()
return hash, os.Chtimes(path, now, now)
} else if !os.IsNotExist(err) {
return "", err
}
if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return "", err
}
if err = os.Rename(f.Name(), path); err != nil {
return "", err
}
d, err := os.Open(filepath.Dir(path))
if err != nil {
return "", err
}
defer d.Close()
return hash, d.Sync()
}
// StageFindingsExport freezes bindings/report versions and makes private body
// copies before the HTTP response is started. The caller owns and removes dest.
func (s *Store) StageFindingsExport(ctx context.Context, findings []*db.DBFinding, dest string, copyBodies bool) error {
return s.DB.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
var snapshots []db.TrafficEvidenceSnapshot
for _, f := range findings {
list, err := db.FindingTrafficTx(tx, f.ID)
if err != nil {
return err
}
f.TrafficBindings = list.Bindings
f.TrafficCount = len(list.Bindings)
f.EvidenceVersion = list.Version
f.ReportEvidenceVersion = list.ReportVersion
if err = tx.QueryRow(`SELECT report FROM findings WHERE id=$1`, f.ID).Scan(&f.Report); err != nil {
return err
}
for _, b := range list.Bindings {
snapshots = append(snapshots, b.Snapshot)
}
}
if copyBodies {
return s.copySnapshots(snapshots, dest)
}
return nil
})
}
func (s *Store) prepare(ctx context.Context, refs []db.TrafficRef) ([]db.PreparedTrafficEvidence, error) {
refs, err := db.NormalizeTrafficRefs(refs)
if err != nil {
return nil, err
}
out := make([]db.PreparedTrafficEvidence, 0, len(refs))
if len(refs) == 0 {
return out, nil
}
ids := make([]string, len(refs))
byID := map[string]db.TrafficRef{}
for i, ref := range refs {
ids[i] = ref.TrafficID
byID[ref.TrafficID] = ref
}
err = s.Traffic.ReadEvidence(ctx, ids, func(e traffic.EvidenceExchange) error {
rh, err := s.writeBody(e.Request, e.ReqLen, e.ReqHash)
if err != nil {
return err
}
ph, err := s.writeBody(e.Response, e.RespLen, e.RespHash)
if err != nil {
return err
}
v := db.TrafficEvidenceSnapshot{SourceTrafficID: e.ID, CapturedAt: e.TS, URL: e.URL, Method: e.Method, Status: e.Status, ContentType: e.ContentType,
ReqHead: e.ReqHead, RespHead: e.RespHead, ReqHash: rh, RespHash: ph, ReqLen: e.ReqLen, RespLen: e.RespLen}
// Raw wire bytes: normalize once here so the ID, the stored row and every
// downstream consumer (archive, API responses) all see the same text.
v = v.Normalize()
v.ID = db.TrafficSnapshotID(v)
out = append(out, db.PreparedTrafficEvidence{Ref: byID[e.ID], Snapshot: v})
return nil
})
return out, err
}
func (s *Store) Record(ctx context.Context, in db.RecordFindingInput, refs []db.TrafficRef) (out *db.RecordedFinding, err error) {
err = s.DB.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
if err := db.LockTaskEvidenceTx(tx, in.TaskID); err != nil {
return err
}
prepared, err := s.prepare(ctx, refs)
if err != nil {
return err
}
out, err = db.RecordFindingTx(ctx, tx, in, prepared)
return err
})
return
}
func (s *Store) Bind(ctx context.Context, findingID int64, refs []db.TrafficRef) (out *db.FindingTraffic, err error) {
err = s.DB.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
if err := db.LockFindingEvidenceTx(tx, findingID, nil); err != nil {
return err
}
prepared, err := s.prepare(ctx, refs)
if err != nil {
return err
}
if err = db.AddFindingTrafficTx(tx, findingID, prepared); err != nil {
return err
}
out, err = db.FindingTrafficTx(tx, findingID)
return err
})
return
}
func (s *Store) WithBinding(ctx context.Context, findingID, bindingID int64, fn func(db.FindingTrafficBinding) error) error {
return s.DB.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
list, err := db.FindingTrafficTx(tx, findingID)
if err != nil {
return err
}
for _, b := range list.Bindings {
if b.ID == bindingID {
return fn(b)
}
}
return db.ErrEvidenceNotFound
})
}
// Binding resolves one binding's metadata under the evidence lock and releases
// the lock before returning. Callers that then stream a body to a client must
// use this instead of WithBinding: verifyFile+io.Copy is O(body size), so a
// large download (or a slow client) holding WithEvidenceTx would block every
// evidence write process-wide. Reading the blob afterwards is safe — blobs are
// content-addressed and GC only reaps unreferenced files after a 24h grace
// period, and an already-open fd survives an unlink regardless.
func (s *Store) Binding(ctx context.Context, findingID, bindingID int64) (db.FindingTrafficBinding, error) {
var out db.FindingTrafficBinding
err := s.WithBinding(ctx, findingID, bindingID, func(b db.FindingTrafficBinding) error {
out = b
return nil
})
return out, err
}
// OpenBody may be called without holding the evidence lock; see Binding.
func (s *Store) OpenBody(snapshot db.TrafficEvidenceSnapshot, side string) (*os.File, int64, error) {
hash, length := snapshot.ReqHash, snapshot.ReqLen
if side == "response" {
hash, length = snapshot.RespHash, snapshot.RespLen
} else if side != "request" {
return nil, 0, errors.New("side 必须为 request 或 response")
}
path, err := hashPath(s.Dir, hash)
if err != nil {
return nil, 0, err
}
if err = verifyFile(path, hash, length); err != nil {
return nil, 0, err
}
f, err := os.Open(path)
return f, length, err
}
// CopySnapshots is used by both report downloads and portable task archives.
// The destination owns real copies, never links into either disposable store.
func (s *Store) CopySnapshots(ctx context.Context, snapshots []db.TrafficEvidenceSnapshot, dest string) error {
return s.DB.WithEvidenceTx(ctx, func(*sql.Tx) error { return s.copySnapshots(snapshots, dest) })
}
func (s *Store) copySnapshots(snapshots []db.TrafficEvidenceSnapshot, dest string) error {
for _, v := range snapshots {
for _, side := range []string{"request", "response"} {
if err := func() error {
f, length, err := s.OpenBody(v, side)
if err != nil {
return err
}
defer f.Close()
hash := v.ReqHash
if side == "response" {
hash = v.RespHash
}
target, err := hashPath(dest, hash)
if err != nil {
return err
}
if err = os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
return err
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600)
if os.IsExist(err) {
return verifyFile(target, hash, length)
}
if err != nil {
return err
}
_, copyErr := io.Copy(out, f)
syncErr := out.Sync()
closeErr := out.Close()
return errors.Join(copyErr, syncErr, closeErr)
}(); err != nil {
return err
}
}
}
return nil
}
func (s *Store) InstallSnapshots(ctx context.Context, snapshots []db.TrafficEvidenceSnapshot, source string) error {
return s.WithInstalledSnapshots(ctx, snapshots, source, func() error { return nil })
}
// WithInstalledSnapshots pins installed bodies until the metadata restore finishes.
// The callback must not acquire another evidence advisory lock.
func (s *Store) WithInstalledSnapshots(ctx context.Context, snapshots []db.TrafficEvidenceSnapshot, source string, restore func() error) error {
return s.DB.WithEvidenceTx(ctx, func(*sql.Tx) error {
for _, v := range snapshots {
if v.ID != db.TrafficSnapshotID(v) {
return errors.New("归档证据快照元数据哈希不匹配")
}
for _, body := range []struct {
hash string
length int64
}{{v.ReqHash, v.ReqLen}, {v.RespHash, v.RespLen}} {
if err := func() error {
path, err := hashPath(source, body.hash)
if err != nil {
return err
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
_, err = s.writeBody(f, body.length, body.hash)
return err
}(); err != nil {
return err
}
}
}
return restore()
})
}
func (s *Store) Collect(ctx context.Context, now time.Time) error {
return s.DB.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
if _, err := tx.Exec(`UPDATE traffic_evidence_snapshots s SET unreferenced_at=$1
WHERE unreferenced_at IS NULL AND NOT EXISTS(SELECT 1 FROM finding_traffic_bindings b WHERE b.snapshot_id=s.id)`, now); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM traffic_evidence_snapshots s WHERE unreferenced_at<$1
AND NOT EXISTS(SELECT 1 FROM finding_traffic_bindings b WHERE b.snapshot_id=s.id)`, now.Add(-24*time.Hour)); err != nil {
return err
}
rows, err := tx.Query(`SELECT req_hash FROM traffic_evidence_snapshots UNION SELECT resp_hash FROM traffic_evidence_snapshots`)
if err != nil {
return err
}
refs := map[string]bool{}
for rows.Next() {
var hash string
if err := rows.Scan(&hash); err != nil {
rows.Close()
return err
}
refs[hash] = true
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
for _, root := range []string{filepath.Join(s.Dir, "blobs"), filepath.Join(s.Dir, ".staging")} {
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
if entry.IsDir() {
return nil
}
if refs[strings.TrimSuffix(entry.Name(), ".bin")] {
return nil
}
info, err := entry.Info()
if err != nil {
return err
}
if now.Sub(info.ModTime()) < 24*time.Hour {
return nil
}
return os.Remove(path)
})
if err != nil {
return err
}
}
return nil
})
}
func (s *Store) RunGC(ctx context.Context) {
timer := time.NewTicker(time.Hour)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-timer.C:
if err := s.Collect(ctx, now); err != nil && !errors.Is(err, context.Canceled) {
log.Printf("[evidence] cleanup failed: %v", err)
}
}
}
}