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