Files
Cyrene_v2/internal/services/gateway/observability_redact_test.go
T
AskaEth fa5d3d23bf feat(gateway): 面板人工加记忆钉 long + skill 解析去回显 + 可观测读侧脱敏
三处遗留(M37/M38/M39)同落 internal/services/gateway:

① adminAddMemory 显式传 RetentionTier=long(主人 2026-10-26 裁定「人工动作
  默认 long」)。此前不传档位,面板典型输入(短寒暄/一次性祈使/时效查询/句首
  时点/进行中)会被 resolveRetention 判 temp 并写 7 天有效期 —— 主人在面板上
  亲手加的记忆会静默消失。只钉这一条入口,Store.Add 默认仍由规则判定
  (自动抽取路径不放宽)。

② adminInstallSkillPlugin 解析失败不再打 err.Error():新建
  admin_plugins_skill_error.go,把失败折叠成「类别(yaml_syntax /
  yaml_type_mismatch / skill_invalid / skill_source)+ 行列号」,日志与 400
  响应用同一份摘要。解码器会把出错值前 7 个字符嵌进错误串(M38 #17),
  原样日志等于把上传内容写进 viewer 可读的日志面。

③ 读侧脱敏走既有 helper,不另写一套:
  - adminLogOf 的 msg 过 trace.RedactText(与写侧同一函数);
  - adminSpanOf 先过 trace.SanitizeRecord(REST 尾部与 observability_trace
    推送帧在同一处收口)。⚠️ 有意改变 admin 响应内容:props 变白名单投影 +
    omitted 摘要;字段名与帧形状不变。行数/级别/时间戳/来源、span 的
    status/耗时/时间/名字一律保住。

测试(每条都验过「删掉实现 ⇒ FAIL」):
  - 面板加记忆 ⇒ 落库 (retention_tier='long', expires_at='');对照组证明同一句
    内容走 Store.Add 仍判 temp;
  - 非法 YAML 含哨兵 ⇒ 响应与日志均无哨兵、都有类别与 line 2(内联/文件/语法/
    来源四类);
  - 日志页 msg 含 sk- 形状凭据 ⇒ [redacted],level/source/time 与非敏感正文保留;
  - trace 响应 props 正文 ⇒ omitted Digest、args_summary 打码,排障字段原样。

验收:go build ./... / go vet ./... 全绿;go test -count=1 -race
./internal/services/gateway/... 全绿。未 push(按任务口径)。
2026-10-26 13:53:59 +08:00

196 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
// 本文件钉住**可观测面板读侧的脱敏**(M42 #3 = M38 #1 的读侧放大面 / M39 遗留)。
//
// 写侧(stderr 的 JSONL)在 M39 已经有了 `trace.SanitizeRecord` / `trace.RedactText`;
// 但面板这两条读路径当时仍原样搬运:
//
// - 日志页 `GET /api/v1/admin/observability/logs`:`buf.Snapshot` → 原样出图;
// - trace 页 `GET /api/v1/admin/observability/trace`:环形缓冲持有**原始** props,
// 原样出图。
//
// 两条都在这里补上「读侧也走同一个 helper」——**不另写一套脱敏**:一套脱敏两处
// 实现必然漂移,而漂移的方向总是「其中一面开始原样吐凭据」。
//
// 🔴 同时保住**排障字段**:日志的行数/级别/时间戳/来源,trace 的 status/耗时/时间/
// 名字都必须原样透出。脱敏只动「内容面」(msg 正文、props 值),不动结构 ——
// 否则面板就变成不可诊断的空壳(这条是塔的明确要求)。
//
// 每条用例都能被一条变异打红:把读侧的 `trace.RedactText` / `trace.SanitizeRecord`
// 摘掉,哨兵与凭据就会原样出现在响应体里。
import (
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// obsLeakKey 是 `sk-` 形状的假凭据:trace 包的形状规则(`\bsk-[A-Za-z0-9_\-]{8,}`)
// 认它,因此它出现在响应体里就一定意味着读侧没过脱敏。
const obsLeakKey = "sk-live0000000000000000ABCD"
// obsLeakPrefix 是断言用的前缀(比整串更严格:打码后不该留下任何一段)。
const obsLeakPrefix = "sk-live0000"
// obsLeakBody 是哨兵正文:它没有凭据形状,只能靠「白名单/正文键」判据拦下 ——
// 因此它抓的是「正文原样出图」,与 obsLeakKey 抓的「凭据原样出图」互不替代。
const obsLeakBody = "SENTINEL_BODY_PAYLOAD_42"
// obsLogServer 构建一个注入了真实 logger 环形缓冲的网关。
func obsLogServer(t *testing.T) (base, tok string) {
t.Helper()
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
ring := logger.NewRingBuffer(16)
log := logger.New(logger.Options{Level: "debug", Format: "text", Out: io.Discard, Ring: ring})
log.Info("boot ok")
// 最新在前:错误放最后,因此它是 Data[0]。
logger.WithSource(log, "gateway/model").Error("upstream rejected " + obsLeakKey + " with 401")
srv.SetLogBuffer(ring)
return ts.URL, signAny(t, jwt, "acct", auth.RoleOwner)
}
// TestAdminObservabilityLogs_RedactsCredentialInMsg 钉住日志页读侧打码:
// 凭据形状被换成 `[redacted]`,而同一条 msg 的非敏感部分、以及 level/source/time
// 这些排障字段原样保留(一行的可诊断性不能被脱敏吃掉)。
func TestAdminObservabilityLogs_RedactsCredentialInMsg(t *testing.T) {
t.Parallel()
base, tok := obsLogServer(t)
resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/observability/logs", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if strings.Contains(string(body), obsLeakPrefix) {
t.Fatalf("日志页回显了凭据(%q):%s", obsLeakPrefix, body)
}
var env logEnvelope
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v (body=%s)", err, body)
}
if len(env.Data) != 2 {
t.Fatalf("日志条数 = %d, want 2(脱敏不许改变条目数)", len(env.Data))
}
got := env.Data[0]
if !strings.Contains(got.Msg, "[redacted]") {
t.Fatalf("凭据位置应换成 [redacted],msg = %q", got.Msg)
}
// 排障字段与非敏感正文必须保住。
if got.Level != "error" || got.Source != "gateway/model" {
t.Fatalf("level/source 被改动:%+v", got)
}
if _, err := time.Parse(time.RFC3339, got.Time); err != nil {
t.Fatalf("time 不是 RFC3339:%q (%v)", got.Time, err)
}
if !strings.Contains(got.Msg, "upstream rejected") || !strings.Contains(got.Msg, "with 401") {
t.Fatalf("msg 的非敏感部分被脱敏吃掉:%q", got.Msg)
}
if env.Data[1].Msg != "boot ok" || env.Data[1].Level != "info" {
t.Fatalf("无关条目被改动:%+v", env.Data[1])
}
}
// obsTraceServer 构建一个网关并把一条**含正文与凭据**的 span 送进 trace 环形
// 缓冲(模拟生产:ObserveSpan 收的是 tracer 的原始记录)。
func obsTraceServer(t *testing.T) (base, tok string) {
t.Helper()
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
started := time.Date(2026, 10, 26, 3, 4, 5, 0, time.UTC)
srv.ObserveSpan(trace.SpanRecord{
ID: "span-42",
Name: "tool.execution",
Kind: trace.SpanKindTool,
Status: trace.SpanStatusOK,
StartedAt: started,
EndedAt: started.Add(25 * time.Millisecond),
Duration: 25 * time.Millisecond,
Props: map[string]any{
// 白名单键:保留,但值里的凭据必须打码。
"tool": "web_search",
"args_summary": "query=天气 " + obsLeakKey,
// 表外正文键:折成 omitted 里的 Digest(留指纹、去正文)。
"messages": obsLeakBody,
"result": obsLeakBody,
},
Events: []trace.Event{{
Time: started.Add(time.Millisecond),
Type: "tool.done",
Props: map[string]any{"text": obsLeakBody, "ok": true},
}},
})
return ts.URL, signAny(t, jwt, "acct", auth.RoleOwner)
}
// TestAdminObservabilityTrace_RedactsPropsAndEvents 钉住 trace 页读侧投影:
// props 与事件里的正文/凭据都出不来,而 span 的 status/时间/耗时/名字逐字保留。
//
// ⚠️ 这条用例同时是「有意改变 admin 响应内容」的证据(见 adminSpanOf 注释):
// props 从原始值变成白名单投影 + omitted 摘要,客户端不得依赖未声明的 props。
func TestAdminObservabilityTrace_RedactsPropsAndEvents(t *testing.T) {
t.Parallel()
base, tok := obsTraceServer(t)
resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/observability/trace", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if strings.Contains(string(body), obsLeakBody) {
t.Fatalf("trace 页回显了正文哨兵(%q):%s", obsLeakBody, body)
}
if strings.Contains(string(body), obsLeakPrefix) {
t.Fatalf("trace 页回显了凭据(%q):%s", obsLeakPrefix, body)
}
var env struct {
Data []AdminTraceSpan `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v (body=%s)", err, body)
}
if len(env.Data) != 1 {
t.Fatalf("span 条数 = %d, want 1", len(env.Data))
}
sp := env.Data[0]
// 排障字段:status / 时间 / 耗时 / 名字 / kind 一个都不许动。
if sp.ID != "span-42" || sp.Name != "tool.execution" || sp.Kind != "tool_call" ||
sp.Status != "ok" || sp.DurationNs != int64(25*time.Millisecond) {
t.Fatalf("span 的排障字段被改动:%+v", sp)
}
if sp.StartedAt != "2026-10-26T03:04:05Z" || sp.EndedAt != "2026-10-26T03:04:05Z" {
t.Fatalf("时间字段被改动:startedAt=%q endedAt=%q", sp.StartedAt, sp.EndedAt)
}
// 白名单键保留(面板靠它分组),但值里的凭据已打码。
if tool, _ := sp.Props["tool"].(string); tool != "web_search" {
t.Fatalf("白名单 props.tool = %v, want web_search(脱敏不许把排障键也丢掉)", sp.Props["tool"])
}
summary, _ := sp.Props["args_summary"].(string)
if !strings.Contains(summary, "[redacted]") || strings.Contains(summary, obsLeakPrefix) {
t.Fatalf("白名单键的值没打码:args_summary = %q", summary)
}
// 表外正文键折成 omitted 里的 Digest:留键名(可判断「这次带了什么」),不留正文。
omitted, ok := sp.Props["omitted"].(map[string]any)
if !ok {
t.Fatalf("表外正文键应折成 props.omitted,实际 props = %+v", sp.Props)
}
for _, k := range []string{"messages", "result"} {
if _, present := omitted[k]; !present {
t.Fatalf("omitted 缺键 %q:%+v", k, omitted)
}
}
// 事件正文键折成 Digest,非正文键(ok)保留。
if len(sp.Events) != 1 {
t.Fatalf("events = %+v, want 1 条", sp.Events)
}
if _, leaked := sp.Events[0].Props["text"].(string); leaked {
t.Fatalf("事件正文键仍是字符串(未折成 Digest):%+v", sp.Events[0].Props)
}
if ok, _ := sp.Events[0].Props["ok"].(bool); !ok {
t.Fatalf("事件非正文键 ok 应原样保留:%+v", sp.Events[0].Props)
}
}