三处遗留(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(按任务口径)。
196 lines
8.3 KiB
Go
196 lines
8.3 KiB
Go
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)
|
||
}
|
||
}
|