#102 高:证据正文下载在全局 advisory lock 事务内 io.Copy 给 HTTP 客户端, verifyFile 又要整读一遍算 SHA-256 —— 一次大文件下载或慢客户端就会挂住 全进程的证据写入。改为事务内只解析绑定元数据(Store.Binding),出锁后再校验 与串流;blob 内容寻址且 GC 有 24h 宽限,已打开的 fd 也不受 unlink 影响。 #102 高:traffic_evidence_snapshots 的 url/req_head/resp_head 直接来自目标 原始报文,未清洗就写 PG TEXT。目标返回非 UTF-8 头(GBK filename)或 NUL 会让 RecordFindingTx 整个事务回滚,已确认的漏洞完全不落库。新增 TrafficEvidenceSnapshot.Normalize(),在算 ID 之前套用仓库既有的 utf8Clean, 保证哈希与入库字节一致。 #102 中:自动绑定默认关闭,reporter 便不会传 evidence_version, SetFindingReportVersionByNodeID 按 legacy 语义写 -1,报告永久显示「待更新」 且 UI 无处清除;老库还因 reporter_agent_seed_v1 守卫拿不到新触发文案。 触发消息改为无条件先读 get_finding_traffic(只读工具,不依赖捕获开关)并回传 版本,并加一次性迁移刷新老库中未被用户改动的默认文案。 #102 中:开关关闭时 report_finding 只要收到 traffic_refs/evidence_hint_id 就在写入前报错,模型幻觉出多余字段即丢掉一个真实漏洞。改为忽略这两个参数 照常登记,由既有的 evidence_status=not_bound 说明未绑定。 #101 中:删掉 RuleID!=0 判断后,每次模型放行都写一行带完整审计快照的 intercept_pending(单行最坏 24×8KiB 上下文 + 32KiB 输入)。开启兜底裁判的 渗透任务会灌入数百 MB,并经 task_archives 的 SELECT * 进入归档。放行行改为 只留决策元数据与执行结果,丢掉重放快照;ask/deny 路径不变。 #103 中:FindingRetestForConversation 出错时在收尾 defer 注册前就 return, 复测行永远停在 pending —— 漏洞列表常驻「复测中」,后续发起复测被去重拦下, 只有重启才能清。新增 FailPendingRetestForConversation 按会话 ID 收尾。
218 lines
6.2 KiB
Go
218 lines
6.2 KiB
Go
package intercept
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"sync"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"github.com/Autumn-27/artex/db"
|
||
)
|
||
|
||
const (
|
||
contextLimit = 24
|
||
entryLimit = 8 * 1024
|
||
promptLimit = 32 * 1024
|
||
outputLimit = 64 * 1024
|
||
)
|
||
|
||
type traceKey struct{}
|
||
type callKey struct{}
|
||
type completion func(status, output string, truncated bool)
|
||
|
||
type tracedCall struct {
|
||
key string
|
||
audit db.InterceptAudit
|
||
claimed bool
|
||
ambiguous bool
|
||
complete completion
|
||
}
|
||
|
||
// Trace belongs to ONE Prompt invocation. SDK v0.3.6 hooks omit the tool ID;
|
||
// correlate only when exactly one outstanding event has matching input. Never
|
||
// guess between simultaneous identical requests, even when results arrive FIFO.
|
||
type Trace struct {
|
||
mu sync.Mutex
|
||
runID string
|
||
user string
|
||
userCut bool
|
||
entries []db.InterceptContextEntry
|
||
cut bool
|
||
calls map[string]*tracedCall
|
||
}
|
||
|
||
func WithTrace(ctx context.Context, user string, prior []db.InterceptContextEntry) (context.Context, *Trace) {
|
||
t := &Trace{runID: rand.Text(), calls: make(map[string]*tracedCall)}
|
||
t.user, t.userCut = bounded(user, promptLimit)
|
||
for _, e := range prior {
|
||
t.append(e)
|
||
}
|
||
return context.WithValue(ctx, traceKey{}, t), t
|
||
}
|
||
|
||
func (t *Trace) append(e db.InterceptContextEntry) {
|
||
var cut bool
|
||
e.Text, cut = bounded(e.Text, entryLimit)
|
||
e.Truncated = e.Truncated || cut
|
||
t.cut = t.cut || e.Truncated
|
||
t.entries = append(t.entries, e)
|
||
if len(t.entries) > contextLimit {
|
||
t.entries = append([]db.InterceptContextEntry(nil), t.entries[len(t.entries)-contextLimit:]...)
|
||
t.cut = true
|
||
}
|
||
}
|
||
|
||
func (t *Trace) Append(e db.InterceptContextEntry) {
|
||
t.mu.Lock()
|
||
defer t.mu.Unlock()
|
||
t.append(e)
|
||
}
|
||
|
||
func (t *Trace) Start(id, tool string, input []byte) {
|
||
t.mu.Lock()
|
||
defer t.mu.Unlock()
|
||
t.calls[id] = &tracedCall{key: tool + ":" + digestInput(input), audit: db.InterceptAudit{
|
||
RunID: t.runID, ToolUseID: id, Correlation: "exact", InputDigest: digestInput(input),
|
||
UserMessage: t.user, UserTruncated: t.userCut, CapturedAt: time.Now().UTC(),
|
||
Context: append([]db.InterceptContextEntry{}, t.entries...), ContextTruncated: t.cut,
|
||
}}
|
||
t.append(db.InterceptContextEntry{Kind: "tool_use", Tool: tool, ToolUseID: id, Text: string(input)})
|
||
}
|
||
|
||
// WithCall claims the event before model review starts, so subsequent tools
|
||
// cannot change this approval's context while the judge is running.
|
||
func WithCall(ctx context.Context, tool string, input []byte) context.Context {
|
||
t, _ := ctx.Value(traceKey{}).(*Trace)
|
||
a := db.InterceptAudit{Correlation: "unavailable", InputDigest: digestInput(input), CapturedAt: time.Now().UTC()}
|
||
if t != nil {
|
||
t.mu.Lock()
|
||
var candidates []*tracedCall
|
||
for _, c := range t.calls {
|
||
if !c.claimed && c.key == tool+":"+a.InputDigest {
|
||
candidates = append(candidates, c)
|
||
}
|
||
}
|
||
if len(candidates) == 1 && !candidates[0].ambiguous {
|
||
candidates[0].claimed = true
|
||
a = candidates[0].audit
|
||
} else {
|
||
a.RunID, a.UserMessage, a.UserTruncated = t.runID, t.user, t.userCut
|
||
if len(candidates) > 0 {
|
||
a.Correlation = "ambiguous"
|
||
for _, c := range candidates {
|
||
c.ambiguous = true
|
||
}
|
||
}
|
||
}
|
||
t.mu.Unlock()
|
||
}
|
||
return context.WithValue(ctx, callKey{}, a)
|
||
}
|
||
|
||
func (t *Trace) bind(id string, f completion) {
|
||
t.mu.Lock()
|
||
defer t.mu.Unlock()
|
||
if c := t.calls[id]; c != nil {
|
||
c.complete = f
|
||
}
|
||
}
|
||
|
||
func (t *Trace) Complete(id, output string, isError bool) {
|
||
t.mu.Lock()
|
||
c := t.calls[id]
|
||
delete(t.calls, id)
|
||
t.mu.Unlock()
|
||
if c == nil || c.complete == nil {
|
||
return
|
||
}
|
||
status := "succeeded"
|
||
if isError {
|
||
status = "failed"
|
||
}
|
||
out, cut := bounded(output, outputLimit)
|
||
c.complete(status, out, cut)
|
||
}
|
||
|
||
// Finish marks missing results unknown, never successful. The tool may have
|
||
// been interrupted or its final event lost; this is distinct from a tool error.
|
||
func (t *Trace) Finish() {
|
||
t.mu.Lock()
|
||
calls := t.calls
|
||
t.calls = make(map[string]*tracedCall)
|
||
t.mu.Unlock()
|
||
for _, c := range calls {
|
||
if c.complete != nil {
|
||
c.complete("unknown", "执行结束但未收到工具结果", false)
|
||
}
|
||
}
|
||
}
|
||
|
||
func auditFor(ctx context.Context, dec Decision, input []byte, status string) *db.InterceptAudit {
|
||
a, ok := ctx.Value(callKey{}).(db.InterceptAudit)
|
||
if !ok {
|
||
a = db.InterceptAudit{Correlation: "unavailable", InputDigest: digestInput(input), CapturedAt: time.Now().UTC()}
|
||
}
|
||
a.InitialAction, a.InitialReason = dec.Action, dec.Message
|
||
a.ModelFallback = dec.ModelFallback
|
||
a.RuleName, a.ConfigDigest, a.ProfileID = dec.RuleName, dec.ConfigDigest, dec.ProfileID
|
||
a.ExecutionStatus = "not_started"
|
||
if status == "allowed" {
|
||
a.EffectiveAction, a.ExecutionStatus = "allow", "awaiting_result"
|
||
if a.Correlation != "exact" {
|
||
a.ExecutionStatus = "unknown"
|
||
}
|
||
// A straight rule/model allow is a routine event, not an approval a human
|
||
// reviewed: with the fallback judge on, a pentest task emits thousands of
|
||
// them. Keeping the full snapshot would write 24×8KiB of context plus a
|
||
// 32KiB prompt per row into intercept_pending — hundreds of MB, which the
|
||
// task archive (SELECT *) then carries along. Decision metadata and the
|
||
// execution result still land; only the bulky replay snapshot is dropped.
|
||
// The ask path builds its audit in HandleAsk and is unaffected.
|
||
a.UserMessage, a.UserTruncated = "", false
|
||
a.Context, a.ContextTruncated = nil, false
|
||
}
|
||
if status == "denied" {
|
||
a.EffectiveAction, a.ExecutionStatus = "deny", "not_executed"
|
||
}
|
||
return &a
|
||
}
|
||
|
||
func (i *Interceptor) bindResult(ctx context.Context, id int64, audit *db.InterceptAudit) {
|
||
t, _ := ctx.Value(traceKey{}).(*Trace)
|
||
if t == nil || audit.Correlation != "exact" || audit.ToolUseID == "" {
|
||
return
|
||
}
|
||
t.bind(audit.ToolUseID, func(status, output string, cut bool) {
|
||
_ = i.db.CompleteIntercept(id, audit.RunID, audit.ToolUseID, status, output, cut)
|
||
})
|
||
}
|
||
|
||
func digestInput(input []byte) string {
|
||
var value any
|
||
d := json.NewDecoder(bytes.NewReader(input))
|
||
d.UseNumber()
|
||
if d.Decode(&value) == nil {
|
||
if canonical, err := json.Marshal(value); err == nil {
|
||
input = canonical
|
||
}
|
||
}
|
||
h := sha256.Sum256(input)
|
||
return hex.EncodeToString(h[:])
|
||
}
|
||
|
||
func bounded(s string, limit int) (string, bool) {
|
||
if len(s) <= limit {
|
||
return s, false
|
||
}
|
||
end := limit
|
||
for end > 0 && !utf8.RuneStart(s[end]) {
|
||
end--
|
||
}
|
||
return s[:end], true
|
||
}
|