package harness import ( "regexp" "strings" "unicode/utf8" ) // secretPatterns 是输出里疑似密钥/令牌的特征(命中即脱敏,防模型把密钥回吐给用户)。 var secretPatterns = []*regexp.Regexp{ regexp.MustCompile(`sk-[A-Za-z0-9_-]{16,}`), // OpenAI/DeepSeek 风格 key regexp.MustCompile(`AKIA[0-9A-Z]{16}`), // AWS Access Key ID regexp.MustCompile(`eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}`), // JWT regexp.MustCompile(`(?i)bearer\s+[A-Za-z0-9._-]{16,}`), // Bearer 令牌 } // piiPatterns 是个人隐私信息(PII)特征:手机号 / 邮箱 / 身份证。用 \b 边界避免吞进更长数字串。 var piiPatterns = []*regexp.Regexp{ regexp.MustCompile(`\b1[3-9]\d{9}\b`), // 中国大陆手机号(11 位) regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}`), // 邮箱 regexp.MustCompile(`\b\d{17}[\dXx]\b`), // 身份证(18 位,末位可 X) } // openerPatterns 匹配「位于字符串末尾、可能仍在增长」的敏感串前缀(锚定 $)。 // 流式逐片脱敏会漏掉被分片切断的密钥(如 "sk-912cf85b"|"16d0..."),逐片都不单独命中。 // StreamRedactor 据此把"半截疑似密钥"的起点之后整段暂留,与下一分片拼接后再判,消除跨片漏检。 var openerPatterns = []*regexp.Regexp{ regexp.MustCompile(`sk-[A-Za-z0-9_-]*$`), regexp.MustCompile(`AKIA[0-9A-Z]*$`), regexp.MustCompile(`eyJ[A-Za-z0-9_-]*(\.[A-Za-z0-9_-]*){0,2}$`), regexp.MustCompile(`(?i)bearer\s*[A-Za-z0-9._-]*$`), regexp.MustCompile(`1[3-9]\d*$`), // 手机号(增长中) regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]*$`), // 邮箱 @ 之后增长中 regexp.MustCompile(`\d{6,}$`), // 长数字串(身份证等)增长中 } // allPatterns 是全部完整匹配特征(密钥 + PII),供流式切点判定"勿切断完整匹配"。 var allPatterns = append(append([]*regexp.Regexp{}, secretPatterns...), piiPatterns...) const redactMark = "[已脱敏]" // RedactSecrets 把文本里疑似密钥/令牌/PII 替换为脱敏标记,返回脱敏后文本与命中次数。 // 整段脱敏(非流式场景、StreamRedactor 内部、最终兜底都用它)。 func RedactSecrets(s string) (string, int) { n := 0 repl := func(string) string { n++; return redactMark } for _, re := range secretPatterns { s = re.ReplaceAllStringFunc(s, repl) } for _, re := range piiPatterns { s = re.ReplaceAllStringFunc(s, repl) } return s, n } const ( // redactMinTail 始终暂留的尾窗:兜住 opener 未识别、却被跨片切断的短模式。 redactMinTail = 16 // redactMaxHold 暂留上限:超过即强制发射(脱敏后),防对抗性长串导致无限暂留 / O(n²)。 redactMaxHold = 256 ) // StreamRedactor 是有状态的流式脱敏器:跨分片缓冲,解决密钥被切断而漏检。 // 用法:每分片 Push() 得可安全发射的文本,流结束 Flush() 吐出暂留尾部。非并发安全(单流单实例)。 type StreamRedactor struct { carry strings.Builder hits int } // NewStreamRedactor 建一个流式脱敏器(每个 token 流一个实例)。 func NewStreamRedactor() *StreamRedactor { return &StreamRedactor{} } // Push 吃一个分片,返回当前可安全发射的脱敏文本(可能为空——尾部疑似半截敏感串被暂留待下片)。 // 关键:切点在「原文」缓冲上定,只对已结算的头部脱敏、尾部按原文暂留 —— 否则贪婪正则会在 // 缓冲末尾凑够最短长度就把半截密钥提前脱敏发走,剩余字符随后明文泄漏。 func (r *StreamRedactor) Push(chunk string) string { if chunk == "" { return "" } r.carry.WriteString(chunk) buf := r.carry.String() cut := safeCut(buf) head, n := RedactSecrets(buf[:cut]) r.hits += n r.carry.Reset() r.carry.WriteString(buf[cut:]) // 原文暂留(未脱敏,供下片继续判定) return head } // Flush 流结束时吐出暂留尾部(做最终脱敏)。返回脱敏后的剩余文本。 func (r *StreamRedactor) Flush() string { s, n := RedactSecrets(r.carry.String()) r.hits += n r.carry.Reset() return s } // Hits 返回累计脱敏命中次数。 func (r *StreamRedactor) Hits() int { return r.hits } // safeCut 在「原文」缓冲上计算可安全发射的截断点:暂留 [cut:],发射并脱敏 [:cut]。 // 三层保障:① opener 暂留末尾仍在增长的疑似密钥;② 始终留一段尾窗兜底未被 opener 覆盖的短模式; // ③ 切点绝不切断任何「完整匹配」(否则半截匹配的头部会明文流出)。最后以暂留上限封顶防 DoS。 func safeCut(s string) int { cut := len(s) for _, re := range openerPatterns { if loc := re.FindStringIndex(s); loc != nil && loc[0] < cut { cut = loc[0] } } if minCut := len(s) - redactMinTail; minCut < cut { cut = minCut // 始终暂留尾窗 } // 勿切断完整匹配:把任何跨越 cut 的完整匹配整体并入暂留尾(循环至稳定,匹配数有限)。 for { moved := false for _, re := range allPatterns { for _, loc := range re.FindAllStringIndex(s, -1) { if loc[0] < cut && loc[1] > cut { cut = loc[0] moved = true } } } if !moved { break } } if maxCut := len(s) - redactMaxHold; maxCut > cut { cut = maxCut // 暂留封顶(最终硬限,防对抗性长串无限暂留 / O(n²)) } if cut < 0 { cut = 0 } // 退到最近的 rune 边界:cut 来自字节索引,直接切多字节字符(中文)会发出半个 rune 乱码。 for cut > 0 && cut < len(s) && !utf8.RuneStart(s[cut]) { cut-- } return cut }