浏览代码

feat:mcp读取数据增加脱敏中间层,避免数据泄漏(未测试)

Jerry Liang 2 月之前
父节点
当前提交
dba6daaf34

+ 2 - 4
service/cmd/mcp/tools/data/mongo.go

@@ -31,13 +31,11 @@ var mongoAllowedCollections = []string{
 	"StatisticsAlarm",
 	// 站点监控
 	"SiteMonitorConfig",
-	// 用户配置
-	"UserInfo",
 	// 自动升级
 	"AutoUpdateConfigOption",
 	"AutoUpdateConfig",
-	// AI会话
-	"AiChatSessions",
+	// 注意:UserInfo(内部用户身份数据)与 AiChatSessions(历史对话自由文本,
+	// 字段级脱敏无效)已从白名单移除,禁止通过 data_mongo_* 直查以防隐私泄露。
 }
 
 func registerMongoTools(s *server.MCPServer) {

+ 3 - 9
service/cmd/mcp/tools/data/plane_api_handlers.go

@@ -2,7 +2,6 @@ package data
 
 import (
 	"context"
-	"encoding/json"
 
 	"github.com/mark3labs/mcp-go/mcp"
 
@@ -10,18 +9,13 @@ import (
 	"fwh-toolbox-server/repositories"
 )
 
-// planeRawResult 将 Plane API 返回的原始 JSON bytes 转为 MCP 结果
+// planeRawResult 将 Plane API 返回的原始 JSON bytes 转为 MCP 结果。
+// 原始字节先经流式 PII 脱敏(优化 B),脱敏失败(含非法 JSON)则返回错误、拒绝输出。
 func planeRawResult(data []byte, err error, action string) (*mcp.CallToolResult, error) {
 	if err != nil {
 		return helper.ServiceError(action, err), nil
 	}
-	// 解析为 any 再格式化输出
-	var parsed any
-	if jsonErr := json.Unmarshal(data, &parsed); jsonErr != nil {
-		// 无法解析则直接返回原始文本
-		return helper.TextResult(string(data)), nil
-	}
-	return helper.JSONResult(parsed), nil
+	return helper.BytesResultOrFileMasked(data, "plane_"+action), nil
 }
 
 // === Project Handlers ===

+ 2 - 7
service/cmd/mcp/tools/data/plane_v2_api_handlers.go

@@ -2,7 +2,6 @@ package data
 
 import (
 	"context"
-	"encoding/json"
 
 	"github.com/mark3labs/mcp-go/mcp"
 
@@ -10,18 +9,14 @@ import (
 )
 
 // planeV2RawResult 将 Plane V2 API 返回的原始 JSON bytes 转为 MCP 结果。
+// 原始字节先经流式 PII 脱敏(优化 B),脱敏失败(含非法 JSON)则返回错误、拒绝输出。
 // 结果较大时自动落盘到本地文件(/tmp/fwh-toolbox-agent/results/),
 // 返回 localPath + 预览,避免大数据占用 LLM 上下文。
 func planeV2RawResult(data []byte, err error, action string) (*mcp.CallToolResult, error) {
 	if err != nil {
 		return helper.ServiceError(action, err), nil
 	}
-	// 解析为 any 再格式化输出;解析失败则按原始文本走落盘判断
-	var parsed any
-	if jsonErr := json.Unmarshal(data, &parsed); jsonErr != nil {
-		return helper.BytesResultOrFile(data, "plane_v2_"+action), nil
-	}
-	return helper.JSONResultOrFile(parsed, "plane_v2_"+action), nil
+	return helper.BytesResultOrFileMasked(data, "plane_v2_"+action), nil
 }
 
 // === Workspace Handlers ===

+ 5 - 5
service/cmd/mcp/tools/helper/helper.go

@@ -1,19 +1,19 @@
 package helper
 
 import (
-	"encoding/json"
 	"fmt"
 
 	"github.com/mark3labs/mcp-go/mcp"
 )
 
-// JSONResult 将任意数据序列化为 JSON 文本返回
+// JSONResult 将任意数据序列化为 JSON 文本返回,序列化前完成 PII 脱敏(A+B)。
+// 脱敏失败时返回错误,拒绝输出(fail-closed)。
 func JSONResult(data any) *mcp.CallToolResult {
-	bytes, err := json.MarshalIndent(data, "", "  ")
+	b, err := marshalSanitized(data)
 	if err != nil {
-		return mcp.NewToolResultError(fmt.Sprintf("序列化失败: %v", err))
+		return mcp.NewToolResultError(err.Error())
 	}
-	return mcp.NewToolResultText(string(bytes))
+	return mcp.NewToolResultText(string(b))
 }
 
 // TextResult 直接返回文本

+ 141 - 0
service/cmd/mcp/tools/helper/pii_rules.go

@@ -0,0 +1,141 @@
+package helper
+
+import (
+	"strings"
+)
+
+// MaskChar 是脱敏时使用的掩码字符,默认为 '*'。
+// 抽为包级变量以便在需要时全局自定义(例如改为 '•' 或 '#')。
+var MaskChar = "*"
+
+// MaskKind 表示某个字段应采用的脱敏方式。
+type MaskKind int
+
+const (
+	// MaskFull 完全脱敏:整个值替换为固定长度的掩码串(不泄露长度)。
+	// 适用于手机号、完整地址串、经纬度、IMEI/IMSI/MAC/IP 等。
+	MaskFull MaskKind = iota
+	// MaskName 姓名脱敏:保留首字符,其余以掩码填充。
+	MaskName
+	// MaskEmail 邮箱脱敏:保留本地部分首字符与完整域名。
+	MaskEmail
+	// MaskAccount 账号脱敏:保留前 2 后 2,中间掩码。
+	MaskAccount
+)
+
+// piiFieldRules 是敏感字段名到脱敏方式的映射。
+// 匹配时统一将 JSON key 转为小写后查表(大小写不敏感精确匹配)。
+//
+// 明确不脱敏(保留原值)的字段(不出现在此表中即视为保留):
+//   - iccid / giccid:SIM 卡业务主键,作为查询标识符保留
+//   - city / province / country:行政区字段,地址保留到城市级
+//   - sn / giccid 等设备/业务标识
+var piiFieldRules = map[string]MaskKind{
+	// 手机号 —— 完全脱敏
+	"contactnumber":           MaskFull,
+	"financiercontractnumber": MaskFull,
+	"msisdn":                  MaskFull,
+	"phone":                   MaskFull,
+	"mobile":                  MaskFull,
+	"tel":                     MaskFull,
+
+	// 姓名 —— 保留首字符
+	"username":      MaskName,
+	"firstname":     MaskName,
+	"lastname":      MaskName,
+	"accountname":   MaskName,
+	"financiername": MaskName,
+	"display_name":  MaskName,
+	"displayname":   MaskName,
+
+	// 邮箱
+	"email": MaskEmail,
+
+	// 完整地址串 —— 完全脱敏(行政区字段 city/province/country 不在表中,保留)
+	"address":           MaskFull,
+	"deviceaddress":     MaskFull,
+	"addresssecondlast": MaskFull,
+	"gpsaddress":        MaskFull,
+
+	// 经纬度 —— 完全脱敏
+	"longitude": MaskFull,
+	"latitude":  MaskFull,
+	"lng":       MaskFull,
+	"lat":       MaskFull,
+
+	// 账号 —— 前2后2
+	"account":          MaskAccount,
+	"installeraccount": MaskAccount,
+
+	// 设备/网络标识 —— 完全脱敏(iccid/giccid 保留,不在表中)
+	"imei":      MaskFull,
+	"imsi":      MaskFull,
+	"mac":       MaskFull,
+	"deviceip":  MaskFull,
+	"gatewayip": MaskFull,
+}
+
+// fullMask 固定长度掩码串,用于 MaskFull,避免通过长度泄露原值信息。
+const fullMaskLen = 6
+
+// maskKindFor 返回字段名对应的脱敏方式;ok 为 false 表示该字段无需脱敏。
+func maskKindFor(key string) (MaskKind, bool) {
+	k, ok := piiFieldRules[strings.ToLower(key)]
+	return k, ok
+}
+
+// applyMaskString 对字符串值按指定方式脱敏。
+func applyMaskString(kind MaskKind, s string) string {
+	switch kind {
+	case MaskName:
+		return maskName(s)
+	case MaskEmail:
+		return maskEmail(s)
+	case MaskAccount:
+		return maskAccount(s)
+	case MaskFull:
+		fallthrough
+	default:
+		return maskFull()
+	}
+}
+
+// maskFull 返回固定长度的掩码串。
+func maskFull() string {
+	return strings.Repeat(MaskChar, fullMaskLen)
+}
+
+// maskName 保留首字符(按 rune),其余固定 3 个掩码字符。
+// 空字符串返回空。
+func maskName(s string) string {
+	if s == "" {
+		return s
+	}
+	r := []rune(s)
+	return string(r[0]) + strings.Repeat(MaskChar, 3)
+}
+
+// maskEmail 保留本地部分首字符 + 掩码 + 完整域名。
+// 非法邮箱(无 @)退化为完全脱敏。
+func maskEmail(s string) string {
+	if s == "" {
+		return s
+	}
+	at := strings.LastIndex(s, "@")
+	if at <= 0 || at == len(s)-1 {
+		return maskFull()
+	}
+	local := s[:at]
+	domain := s[at:] // 含 @
+	lr := []rune(local)
+	return string(lr[0]) + strings.Repeat(MaskChar, 3) + domain
+}
+
+// maskAccount 保留前 2 后 2,中间固定掩码;长度不足 5 时完全脱敏。
+func maskAccount(s string) string {
+	r := []rune(s)
+	if len(r) < 5 {
+		return maskFull()
+	}
+	return string(r[:2]) + strings.Repeat(MaskChar, 3) + string(r[len(r)-2:])
+}

+ 241 - 0
service/cmd/mcp/tools/helper/pii_stream.go

@@ -0,0 +1,241 @@
+package helper
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"strconv"
+)
+
+// MaskJSONBytes 对已序列化的 JSON 字节流做流式脱敏,返回脱敏后的 JSON 字节。
+//
+// 实现要点(优化 B):
+//   - 使用 json.Decoder 的 token 流式解析,不构建完整的 interface{} 对象树,
+//     内存占用为常数级(栈深度 = JSON 嵌套深度)。
+//   - 单次遍历:读到 object 的 key 命中敏感规则时,对其对应的 value 做脱敏;
+//     其余 token 原样透传。
+//   - 若敏感 key 的 value 是对象或数组,则整个子树被完全脱敏(fail-safe)。
+//   - 解析失败时返回 error(fail-closed,由上层拒绝输出,不泄露原始数据)。
+func MaskJSONBytes(in []byte) ([]byte, error) {
+	dec := json.NewDecoder(bytes.NewReader(in))
+	dec.UseNumber()
+
+	var out bytes.Buffer
+	w := &jsonWriter{buf: &out}
+
+	if err := maskValue(dec, w, nil); err != nil {
+		return nil, fmt.Errorf("PII 脱敏失败: %w", err)
+	}
+
+	// 确认没有多余内容(例如尾部垃圾),保证输入是单个合法 JSON 值。
+	if dec.More() {
+		return nil, fmt.Errorf("PII 脱敏失败: JSON 存在多余的尾部内容")
+	}
+
+	return out.Bytes(), nil
+}
+
+// maskValue 读取下一个 JSON 值并写入 w。
+// forceMask 非 nil 时,表示当前值处于某个敏感 key 之下,整个值(含子树)需完全脱敏。
+func maskValue(dec *json.Decoder, w *jsonWriter, forceMask *MaskKind) error {
+	tok, err := dec.Token()
+	if err != nil {
+		return err
+	}
+
+	switch t := tok.(type) {
+	case json.Delim:
+		switch t {
+		case '{':
+			return maskObject(dec, w, forceMask)
+		case '[':
+			return maskArray(dec, w, forceMask)
+		default:
+			return fmt.Errorf("非预期的分隔符: %v", t)
+		}
+	default:
+		// 标量值(string / json.Number / bool / nil)
+		return writeScalar(w, tok, forceMask)
+	}
+}
+
+// maskObject 处理一个 JSON 对象。forceMask 非 nil 时整个对象子树被脱敏。
+func maskObject(dec *json.Decoder, w *jsonWriter, forceMask *MaskKind) error {
+	w.openObject()
+	for dec.More() {
+		// 读 key
+		keyTok, err := dec.Token()
+		if err != nil {
+			return err
+		}
+		key, ok := keyTok.(string)
+		if !ok {
+			return fmt.Errorf("对象 key 非字符串: %T", keyTok)
+		}
+		w.writeKey(key)
+
+		// 决定该字段 value 的脱敏方式:
+		//   - 若已处于强制脱敏子树中,继续沿用父级 forceMask。
+		//   - 否则查规则表。
+		var childForce *MaskKind
+		if forceMask != nil {
+			childForce = forceMask
+		} else if kind, hit := maskKindFor(key); hit {
+			k := kind
+			childForce = &k
+		}
+
+		if err := maskValue(dec, w, childForce); err != nil {
+			return err
+		}
+	}
+	// 消费结束的 '}'
+	if _, err := dec.Token(); err != nil {
+		return err
+	}
+	w.closeObject()
+	return nil
+}
+
+// maskArray 处理一个 JSON 数组。forceMask 非 nil 时数组内每个元素都被脱敏。
+func maskArray(dec *json.Decoder, w *jsonWriter, forceMask *MaskKind) error {
+	w.openArray()
+	for dec.More() {
+		if err := maskValue(dec, w, forceMask); err != nil {
+			return err
+		}
+	}
+	// 消费结束的 ']'
+	if _, err := dec.Token(); err != nil {
+		return err
+	}
+	w.closeArray()
+	return nil
+}
+
+// writeScalar 写入一个标量值,必要时脱敏。
+func writeScalar(w *jsonWriter, tok json.Token, forceMask *MaskKind) error {
+	if forceMask != nil {
+		// 处于敏感字段之下:
+		//   - 字符串按具体 MaskKind 处理
+		//   - 其余标量(数字/bool,例如经纬度以 number 存储)统一替换为掩码字符串
+		if s, ok := tok.(string); ok {
+			return w.writeValueRaw(quote(applyMaskString(*forceMask, s)))
+		}
+		if tok == nil {
+			// null 保持 null(无信息泄露)
+			return w.writeValueRaw("null")
+		}
+		return w.writeValueRaw(quote(maskFull()))
+	}
+
+	// 非敏感字段:原样写出
+	switch v := tok.(type) {
+	case string:
+		return w.writeValueRaw(quote(v))
+	case json.Number:
+		return w.writeValueRaw(v.String())
+	case bool:
+		return w.writeValueRaw(strconv.FormatBool(v))
+	case nil:
+		return w.writeValueRaw("null")
+	default:
+		return fmt.Errorf("非预期的标量类型: %T", tok)
+	}
+}
+
+// quote 将字符串按 JSON 规则转义并加引号。
+func quote(s string) string {
+	b, _ := json.Marshal(s)
+	return string(b)
+}
+
+// indentJSON 将紧凑 JSON 重新缩进为两空格格式,便于阅读并与优化 A 的输出保持一致。
+// 缩进失败(理论上不会发生,因输入已是合法 JSON)时返回原始字节。
+func indentJSON(b []byte) []byte {
+	var out bytes.Buffer
+	if err := json.Indent(&out, b, "", "  "); err != nil {
+		return b
+	}
+	return out.Bytes()
+}
+
+// jsonWriter 负责按结构写出 JSON,管理逗号与 key/value 状态。
+type jsonWriter struct {
+	buf   *bytes.Buffer
+	stack []*frame
+}
+
+type frame struct {
+	isObject   bool
+	needComma  bool // 是否需要在下一个元素/键前加逗号
+	expectKey  bool // 对象中:下一个 writeKey 之后期待 value
+	pendingKey bool // 已写入 key,等待 value(value 前不加逗号)
+}
+
+func (w *jsonWriter) top() *frame {
+	if len(w.stack) == 0 {
+		return nil
+	}
+	return w.stack[len(w.stack)-1]
+}
+
+// beforeValue 在写入一个值(标量或容器起始)之前处理逗号。
+func (w *jsonWriter) beforeValue() {
+	f := w.top()
+	if f == nil {
+		return
+	}
+	if f.isObject {
+		// 对象中值紧跟 key,key 已处理逗号,这里不加
+		if f.pendingKey {
+			f.pendingKey = false
+			return
+		}
+	}
+	if f.needComma {
+		w.buf.WriteByte(',')
+	}
+	f.needComma = true
+}
+
+func (w *jsonWriter) openObject() {
+	w.beforeValue()
+	w.buf.WriteByte('{')
+	w.stack = append(w.stack, &frame{isObject: true})
+}
+
+func (w *jsonWriter) closeObject() {
+	w.buf.WriteByte('}')
+	w.stack = w.stack[:len(w.stack)-1]
+}
+
+func (w *jsonWriter) openArray() {
+	w.beforeValue()
+	w.buf.WriteByte('[')
+	w.stack = append(w.stack, &frame{isObject: false})
+}
+
+func (w *jsonWriter) closeArray() {
+	w.buf.WriteByte(']')
+	w.stack = w.stack[:len(w.stack)-1]
+}
+
+func (w *jsonWriter) writeKey(key string) {
+	f := w.top()
+	if f != nil && f.needComma {
+		w.buf.WriteByte(',')
+	}
+	w.buf.WriteString(quote(key))
+	w.buf.WriteByte(':')
+	if f != nil {
+		f.needComma = true
+		f.pendingKey = true
+	}
+}
+
+func (w *jsonWriter) writeValueRaw(raw string) error {
+	w.beforeValue()
+	_, err := w.buf.WriteString(raw)
+	return err
+}

+ 165 - 0
service/cmd/mcp/tools/helper/pii_struct.go

@@ -0,0 +1,165 @@
+package helper
+
+import (
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/dto"
+)
+
+// sanitizeStrong 尝试对已知的强类型做源头脱敏(优化 A)。
+//
+// 返回:
+//   - 脱敏后的数据副本 + true:命中强类型,已完成脱敏。
+//   - nil + false:未命中强类型,调用方应改走流式脱敏(优化 B)。
+//
+// 设计约束:所有脱敏均作用于输入数据的副本,绝不修改上游对象(这些对象
+// 可能来自 Mongo 缓存或被其他调用方共享)。规则与掩码函数复用 pii_rules.go,
+// 确保 A 与 B 的行为一致。
+func sanitizeStrong(data any) (any, bool) {
+	switch v := data.(type) {
+	case *dto.SiteInfoDTO:
+		if v == nil {
+			return v, true
+		}
+		cp := *v
+		sanitizeSiteInfoDTO(&cp)
+		return &cp, true
+
+	case dto.SiteInfoDTO:
+		sanitizeSiteInfoDTO(&v)
+		return v, true
+
+	case *dto.ResPage[dto.SiteInfoDTO]:
+		if v == nil {
+			return v, true
+		}
+		cp := *v
+		cp.Data = make([]dto.SiteInfoDTO, len(v.Data))
+		copy(cp.Data, v.Data)
+		for i := range cp.Data {
+			sanitizeSiteInfoDTO(&cp.Data[i])
+		}
+		return &cp, true
+
+	case *domain.SimCardInfo:
+		if v == nil {
+			return v, true
+		}
+		cp := *v
+		sanitizeDomainSimCardInfo(&cp)
+		return &cp, true
+
+	case *dto.SimCardInfo:
+		if v == nil {
+			return v, true
+		}
+		cp := *v
+		sanitizeDTOSimCardInfo(&cp)
+		return &cp, true
+
+	case *dto.ResPage[dto.SimCardInfo]:
+		if v == nil {
+			return v, true
+		}
+		cp := *v
+		cp.Data = make([]dto.SimCardInfo, len(v.Data))
+		copy(cp.Data, v.Data)
+		for i := range cp.Data {
+			sanitizeDTOSimCardInfo(&cp.Data[i])
+		}
+		return &cp, true
+
+	case *dto.ResPage[domain.SimCardInfo]:
+		if v == nil {
+			return v, true
+		}
+		cp := *v
+		cp.Data = make([]domain.SimCardInfo, len(v.Data))
+		copy(cp.Data, v.Data)
+		for i := range cp.Data {
+			sanitizeDomainSimCardInfo(&cp.Data[i])
+		}
+		return &cp, true
+
+	case *domain.SimCardMonthlyData:
+		if v == nil {
+			return v, true
+		}
+		cp := *v
+		sanitizeSimCardMonthlyData(&cp)
+		return &cp, true
+
+	default:
+		return nil, false
+	}
+}
+
+// maskStrPtr 对 *string 字段就地脱敏(作用于副本的指针,需重新分配以免污染原值)。
+func maskStrPtr(p **string, kind MaskKind) {
+	if p == nil || *p == nil {
+		return
+	}
+	masked := applyMaskString(kind, **p)
+	*p = &masked
+}
+
+func maskStr(s *string, kind MaskKind) {
+	if s == nil {
+		return
+	}
+	*s = applyMaskString(kind, *s)
+}
+
+// sanitizeSiteInfoDTO 对站点信息做字段级脱敏。
+// 保留:city / province / country(行政区)、iccId(业务标识)、sn、各类版本与状态字段。
+func sanitizeSiteInfoDTO(s *dto.SiteInfoDTO) {
+	// 姓名
+	maskStrPtr(&s.UserName, MaskName)
+	maskStrPtr(&s.FirstName, MaskName)
+	maskStrPtr(&s.LastName, MaskName)
+	maskStrPtr(&s.AccountName, MaskName)
+	maskStrPtr(&s.FinancierName, MaskName)
+
+	// 手机号 —— 完全脱敏
+	maskStrPtr(&s.ContactNumber, MaskFull)
+	maskStrPtr(&s.FinancierContractNumber, MaskFull)
+
+	// 完整地址串 —— 完全脱敏
+	maskStrPtr(&s.Address, MaskFull)
+	maskStrPtr(&s.DeviceAddress, MaskFull)
+	maskStrPtr(&s.AddressSecondLast, MaskFull)
+
+	// 经纬度 —— 完全脱敏(string 类型)
+	s.Longitude = maskFull()
+	s.Latitude = maskFull()
+
+	// 账号
+	maskStrPtr(&s.Account, MaskAccount)
+	maskStrPtr(&s.InstallerAccount, MaskAccount)
+
+	// 设备/网络标识 —— 完全脱敏(保留 ICCID)
+	maskStrPtr(&s.Mac, MaskFull)
+	maskStrPtr(&s.DeviceIp, MaskFull)
+	maskStrPtr(&s.GatewayIp, MaskFull)
+	s.IMEI = maskFull()
+	// s.ICCID 保留
+}
+
+// sanitizeDomainSimCardInfo 对 domain.SimCardInfo 脱敏(保留 GICCID)。
+func sanitizeDomainSimCardInfo(s *domain.SimCardInfo) {
+	s.IMEI = maskFull()
+	s.IMSI = maskFull()
+	s.MSISDN = maskFull()
+	// s.GICCID 保留
+}
+
+// sanitizeDTOSimCardInfo 对 dto.SimCardInfo 脱敏(保留 GICCID、SN 列表)。
+func sanitizeDTOSimCardInfo(s *dto.SimCardInfo) {
+	s.IMEI = maskFull()
+	s.IMSI = maskFull()
+	s.MSISDN = maskFull()
+	// s.GICCID / s.SN 保留
+}
+
+// sanitizeSimCardMonthlyData 月用量数据本身不含 IMEI/IMSI/MSISDN,仅含 GICCID(simId) 与用量,
+// 无需脱敏;保留以显式声明该类型已被审阅。
+func sanitizeSimCardMonthlyData(_ *domain.SimCardMonthlyData) {}

+ 334 - 0
service/cmd/mcp/tools/helper/pii_test.go

@@ -0,0 +1,334 @@
+package helper
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+
+	"fwh-toolbox-server/domain"
+	"fwh-toolbox-server/dto"
+)
+
+// ---------- 掩码函数单元测试 ----------
+
+func TestMaskName(t *testing.T) {
+	cases := map[string]string{
+		"":      "",
+		"张三":    "张***",
+		"John":  "J***",
+		"李":     "李***",
+	}
+	for in, want := range cases {
+		if got := maskName(in); got != want {
+			t.Errorf("maskName(%q)=%q, want %q", in, got, want)
+		}
+	}
+}
+
+func TestMaskEmail(t *testing.T) {
+	if got := maskEmail("john@corp.com"); got != "j***@corp.com" {
+		t.Errorf("maskEmail got %q", got)
+	}
+	// 非法邮箱退化为完全脱敏
+	if got := maskEmail("notanemail"); got != maskFull() {
+		t.Errorf("maskEmail(invalid) got %q", got)
+	}
+	if got := maskEmail(""); got != "" {
+		t.Errorf("maskEmail(empty) got %q", got)
+	}
+}
+
+func TestMaskAccount(t *testing.T) {
+	if got := maskAccount("abcdefgh"); got != "ab***gh" {
+		t.Errorf("maskAccount got %q", got)
+	}
+	// 过短完全脱敏
+	if got := maskAccount("abc"); got != maskFull() {
+		t.Errorf("maskAccount(short) got %q", got)
+	}
+}
+
+func TestMaskFullFixedLength(t *testing.T) {
+	if got := maskFull(); got != strings.Repeat(MaskChar, fullMaskLen) {
+		t.Errorf("maskFull got %q", got)
+	}
+}
+
+// ---------- 优化 B:流式 token 脱敏测试 ----------
+
+func TestMaskJSONBytes_FlatFields(t *testing.T) {
+	in := []byte(`{
+		"sn": "SN123",
+		"userName": "Alice",
+		"contactNumber": "13812345678",
+		"email": "a@b.com",
+		"longitude": 121.5,
+		"latitude": 31.2,
+		"address": "123 Main St",
+		"city": "Shanghai",
+		"province": "SH",
+		"country": "CN",
+		"iccid": "8986012345678901234",
+		"imei": "356938035643809",
+		"account": "installer01"
+	}`)
+	out, err := MaskJSONBytes(in)
+	if err != nil {
+		t.Fatalf("MaskJSONBytes err: %v", err)
+	}
+	var m map[string]any
+	if err := json.Unmarshal(out, &m); err != nil {
+		t.Fatalf("output not valid JSON: %v\n%s", err, out)
+	}
+
+	// 保留字段
+	if m["sn"] != "SN123" {
+		t.Errorf("sn should be preserved, got %v", m["sn"])
+	}
+	if m["iccid"] != "8986012345678901234" {
+		t.Errorf("iccid should be preserved, got %v", m["iccid"])
+	}
+	if m["city"] != "Shanghai" || m["province"] != "SH" || m["country"] != "CN" {
+		t.Errorf("admin region should be preserved: city=%v province=%v country=%v", m["city"], m["province"], m["country"])
+	}
+
+	// 完全脱敏字段
+	full := maskFull()
+	for _, k := range []string{"contactNumber", "longitude", "latitude", "address", "imei"} {
+		if m[k] != full {
+			t.Errorf("%s should be fully masked, got %v", k, m[k])
+		}
+	}
+	// 姓名
+	if m["userName"] != "A***" {
+		t.Errorf("userName masked wrong: %v", m["userName"])
+	}
+	// 邮箱
+	if m["email"] != "a***@b.com" {
+		t.Errorf("email masked wrong: %v", m["email"])
+	}
+	// 账号
+	if m["account"] != "in***01" {
+		t.Errorf("account masked wrong: %v", m["account"])
+	}
+}
+
+func TestMaskJSONBytes_Nested(t *testing.T) {
+	in := []byte(`{
+		"data": [
+			{"userName": "Bob", "sn": "S1", "nested": {"phone": "111", "keep": "ok"}},
+			{"userName": "Carol", "sn": "S2"}
+		],
+		"total": 2
+	}`)
+	out, err := MaskJSONBytes(in)
+	if err != nil {
+		t.Fatalf("err: %v", err)
+	}
+	var m map[string]any
+	if err := json.Unmarshal(out, &m); err != nil {
+		t.Fatalf("invalid JSON: %v\n%s", err, out)
+	}
+	arr := m["data"].([]any)
+	first := arr[0].(map[string]any)
+	if first["userName"] != "B***" {
+		t.Errorf("nested list userName wrong: %v", first["userName"])
+	}
+	if first["sn"] != "S1" {
+		t.Errorf("sn should be preserved in list: %v", first["sn"])
+	}
+	nested := first["nested"].(map[string]any)
+	if nested["phone"] != maskFull() {
+		t.Errorf("deeply nested phone should be masked: %v", nested["phone"])
+	}
+	if nested["keep"] != "ok" {
+		t.Errorf("non-sensitive nested field should be preserved: %v", nested["keep"])
+	}
+	if m["total"].(float64) != 2 {
+		t.Errorf("total wrong: %v", m["total"])
+	}
+}
+
+func TestMaskJSONBytes_SensitiveSubtreeRedacted(t *testing.T) {
+	// 敏感 key 对应对象/数组时,整个子树被脱敏
+	in := []byte(`{"address": {"street": "secret", "no": 42}, "list_phone": ["a","b"]}`)
+	// 注意 list_phone 不在规则表,仅测 address 子树
+	out, err := MaskJSONBytes(in)
+	if err != nil {
+		t.Fatalf("err: %v", err)
+	}
+	var m map[string]any
+	if err := json.Unmarshal(out, &m); err != nil {
+		t.Fatalf("invalid JSON: %v\n%s", err, out)
+	}
+	addr := m["address"].(map[string]any)
+	if addr["street"] != maskFull() {
+		t.Errorf("address.street should be masked: %v", addr["street"])
+	}
+	// number 子字段被替换为掩码字符串
+	if addr["no"] != maskFull() {
+		t.Errorf("address.no should be masked: %v", addr["no"])
+	}
+}
+
+func TestMaskJSONBytes_InvalidJSONFailsClosed(t *testing.T) {
+	_, err := MaskJSONBytes([]byte(`{not valid json`))
+	if err == nil {
+		t.Fatal("expected error for invalid JSON (fail-closed)")
+	}
+}
+
+func TestMaskJSONBytes_NullPreserved(t *testing.T) {
+	in := []byte(`{"userName": null, "phone": null}`)
+	out, err := MaskJSONBytes(in)
+	if err != nil {
+		t.Fatalf("err: %v", err)
+	}
+	if !strings.Contains(string(out), `"userName":null`) {
+		t.Errorf("null should be preserved: %s", out)
+	}
+}
+
+// ---------- 优化 A:强类型脱敏测试 ----------
+
+func TestSanitizeSiteInfoDTO_NoUpstreamPollution(t *testing.T) {
+	name := "Alice"
+	phone := "13812345678"
+	addr := "123 Main St"
+	iccid := "8986012345678901234"
+	orig := &dto.SiteInfoDTO{
+		Sn:            "SN1",
+		UserName:      &name,
+		ContactNumber: &phone,
+		Address:       &addr,
+		Longitude:     "121.5",
+		Latitude:      "31.2",
+		City:          "Shanghai",
+		ICCID:         iccid,
+		IMEI:          "356938035643809",
+	}
+
+	sanitized, ok := sanitizeStrong(orig)
+	if !ok {
+		t.Fatal("expected strong-type hit for *dto.SiteInfoDTO")
+	}
+	s := sanitized.(*dto.SiteInfoDTO)
+
+	// 脱敏结果正确
+	if *s.UserName != "A***" {
+		t.Errorf("userName: %v", *s.UserName)
+	}
+	if *s.ContactNumber != maskFull() {
+		t.Errorf("contactNumber: %v", *s.ContactNumber)
+	}
+	if *s.Address != maskFull() {
+		t.Errorf("address: %v", *s.Address)
+	}
+	if s.Longitude != maskFull() || s.Latitude != maskFull() {
+		t.Errorf("geo not masked: %v %v", s.Longitude, s.Latitude)
+	}
+	if s.IMEI != maskFull() {
+		t.Errorf("imei: %v", s.IMEI)
+	}
+	// 保留字段
+	if s.ICCID != iccid {
+		t.Errorf("iccid should be preserved: %v", s.ICCID)
+	}
+	if s.City != "Shanghai" {
+		t.Errorf("city should be preserved: %v", s.City)
+	}
+
+	// 上游未被污染
+	if *orig.UserName != "Alice" || *orig.ContactNumber != "13812345678" || *orig.Address != "123 Main St" {
+		t.Errorf("upstream polluted: name=%v phone=%v addr=%v", *orig.UserName, *orig.ContactNumber, *orig.Address)
+	}
+	if orig.Longitude != "121.5" || orig.IMEI != "356938035643809" {
+		t.Errorf("upstream scalar polluted: lng=%v imei=%v", orig.Longitude, orig.IMEI)
+	}
+}
+
+func TestSanitizeResPageSiteInfo(t *testing.T) {
+	name := "Bob"
+	orig := &dto.ResPage[dto.SiteInfoDTO]{
+		Data:  []dto.SiteInfoDTO{{Sn: "S1", UserName: &name, IMEI: "111"}},
+		Total: 1,
+	}
+	sanitized, ok := sanitizeStrong(orig)
+	if !ok {
+		t.Fatal("expected hit")
+	}
+	s := sanitized.(*dto.ResPage[dto.SiteInfoDTO])
+	if *s.Data[0].UserName != "B***" {
+		t.Errorf("page item userName: %v", *s.Data[0].UserName)
+	}
+	// 上游 slice 未被污染
+	if *orig.Data[0].UserName != "Bob" {
+		t.Errorf("upstream page polluted: %v", *orig.Data[0].UserName)
+	}
+}
+
+func TestSanitizeDomainSimCardInfo(t *testing.T) {
+	orig := &domain.SimCardInfo{GICCID: "GIC19", IMEI: "imei1", IMSI: "imsi1", MSISDN: "msisdn1"}
+	sanitized, ok := sanitizeStrong(orig)
+	if !ok {
+		t.Fatal("expected hit")
+	}
+	s := sanitized.(*domain.SimCardInfo)
+	if s.IMEI != maskFull() || s.IMSI != maskFull() || s.MSISDN != maskFull() {
+		t.Errorf("sim identifiers not masked: %+v", s)
+	}
+	if s.GICCID != "GIC19" {
+		t.Errorf("giccid should be preserved: %v", s.GICCID)
+	}
+	// 上游未污染
+	if orig.IMEI != "imei1" {
+		t.Errorf("upstream sim polluted: %v", orig.IMEI)
+	}
+}
+
+// ---------- A/B 一致性测试 ----------
+
+func TestAB_Consistency_SiteInfo(t *testing.T) {
+	name := "Alice"
+	phone := "13812345678"
+	addr := "123 Main St"
+	site := &dto.SiteInfoDTO{
+		Sn:            "SN1",
+		UserName:      &name,
+		ContactNumber: &phone,
+		Address:       &addr,
+		Longitude:     "121.5",
+		City:          "Shanghai",
+		ICCID:         "8986012345678901234",
+		IMEI:          "356938035643809",
+	}
+
+	// A 路径
+	sanitized, _ := sanitizeStrong(site)
+	aBytes, err := json.Marshal(sanitized)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// B 路径:直接对原始结构体序列化后走流式脱敏
+	rawBytes, _ := json.Marshal(site)
+	bBytes, err := MaskJSONBytes(rawBytes)
+	if err != nil {
+		t.Fatalf("B err: %v", err)
+	}
+
+	// 归一化比较(字段顺序可能不同,比较解析后的 map)
+	var ma, mb map[string]any
+	if err := json.Unmarshal(aBytes, &ma); err != nil {
+		t.Fatal(err)
+	}
+	if err := json.Unmarshal(bBytes, &mb); err != nil {
+		t.Fatal(err)
+	}
+
+	for _, k := range []string{"userName", "contactNumber", "address", "longitude", "imei", "iccId", "city", "sn"} {
+		if ma[k] != mb[k] {
+			t.Errorf("A/B mismatch on %q: A=%v B=%v", k, ma[k], mb[k])
+		}
+	}
+}

+ 44 - 3
service/cmd/mcp/tools/helper/spill.go

@@ -29,19 +29,47 @@ const previewMaxBytes = 2000
 // hintSanitizer 用于清洗文件名 hint,只保留安全字符
 var hintSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_\-]+`)
 
-// JSONResultOrFile 将数据序列化为 JSON:
+// JSONResultOrFile 将数据序列化为 JSON,并在序列化前完成 PII 脱敏(A+B):
+//   - 命中已知强类型:走源头结构体脱敏(优化 A),零额外内存开销。
+//   - 其余类型:序列化后走流式 token 脱敏(优化 B)。
+//   - 脱敏失败:返回错误,拒绝输出(fail-closed,绝不泄露原始数据)。
+//
+// 脱敏完成后:
 //   - 小结果(<= InlineMaxBytes)直接内联返回文本
 //   - 大结果写入本地文件,返回包含 localPath + 预览 + 元信息的摘要
 //
 // hint 用于生成可读的文件名前缀(如 "plane_work_items")。
 func JSONResultOrFile(data any, hint string) *mcp.CallToolResult {
-	b, err := json.MarshalIndent(data, "", "  ")
+	b, err := marshalSanitized(data)
 	if err != nil {
-		return mcp.NewToolResultError(fmt.Sprintf("序列化失败: %v", err))
+		return mcp.NewToolResultError(err.Error())
 	}
 	return BytesResultOrFile(b, hint)
 }
 
+// marshalSanitized 执行 A+B 脱敏并返回脱敏后的 JSON 字节。
+//   - 优化 A:命中强类型时先对副本做字段级脱敏,再直接序列化(无需二次处理)。
+//   - 优化 B:未命中强类型时序列化后做流式 token 脱敏。
+func marshalSanitized(data any) ([]byte, error) {
+	if sanitized, ok := sanitizeStrong(data); ok {
+		b, err := json.MarshalIndent(sanitized, "", "  ")
+		if err != nil {
+			return nil, fmt.Errorf("序列化失败: %v", err)
+		}
+		return b, nil
+	}
+
+	b, err := json.MarshalIndent(data, "", "  ")
+	if err != nil {
+		return nil, fmt.Errorf("序列化失败: %v", err)
+	}
+	masked, err := MaskJSONBytes(b)
+	if err != nil {
+		return nil, err
+	}
+	return indentJSON(masked), nil
+}
+
 // JSONResultOrFileAuto 与 JSONResultOrFile 相同,但使用通用文件名前缀,
 // 便于对现有 helper.JSONResult 调用点做无侵入替换。
 func JSONResultOrFileAuto(data any) *mcp.CallToolResult {
@@ -55,6 +83,19 @@ func TextResultOrFile(text string, hint string) *mcp.CallToolResult {
 	return BytesResultOrFile([]byte(text), hint)
 }
 
+// BytesResultOrFileMasked 针对上游返回的原始 JSON 字节(如 Plane API 响应)
+// 先做流式 PII 脱敏(优化 B),再走落盘判断。
+//   - 脱敏失败(含无法解析的非法 JSON):返回错误,拒绝输出(fail-closed)。
+//
+// 相比"unmarshal 成 any 再 JSONResultOrFile",此路径省去一次反序列化+序列化往返。
+func BytesResultOrFileMasked(b []byte, hint string) *mcp.CallToolResult {
+	masked, err := MaskJSONBytes(b)
+	if err != nil {
+		return mcp.NewToolResultError(err.Error())
+	}
+	return BytesResultOrFile(indentJSON(masked), hint)
+}
+
 // BytesResultOrFile 针对已经序列化好的 JSON/文本 bytes 做落盘判断。
 func BytesResultOrFile(b []byte, hint string) *mcp.CallToolResult {
 	if len(b) <= InlineMaxBytes {