feat(harness): 输入护栏升级 —— 归一化反绕过(Tier1) + LLM 越狱分类器(Tier2)
原输入护栏纯正则,空格/编码/同形字一改写即漏,且 bannedTerms 空置。升级为两层: Tier1(网关同步、无 LLM):先归一化再匹配,干掉绕过—— - 小写 + 去零宽字符 + 去变音符 + 同形字折叠(西里尔/希腊→拉丁) + 拆字间隔还原(i g n o r e / i.g.n.o.r.e → ignore) + base64 解码回扫 - 多视图(原文/归一化/紧凑/解码)匹配高精度注入正则,无需穷举变体 - bannedTerms 经 GUARDRAIL_BANNED_TERMS env 落地 - 软信号(jailbreak/developer mode/无限制…)→ 灰区,放行但打 safety_check 标志 Tier2(dispatcher harness LLM 分类器,escalation): - 仅对灰区任务执行前调 LLM 裁决 jailbreak+severity,≥0.7 → rejected - 明确干净/恶意的不付 LLM 成本;模型抖动/解析失败 fail-open 不误锁正常用户 契约新增 MetaSafetyCheck 透传灰区标志;orchestrator 加执行前护栏门控 + SetGuardian。 网关 6 单测 + dispatcher 4 单测,三模块全绿。live:拆字/base64/西里尔同形字均 422 拦, 恶意灰区被 LLM 拒(severity 1)、良性灰区(海盗 roleplay)放行完成。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,45 +1,198 @@
|
||||
// Package guardrail 实现 Harness 输入护栏的纯检测逻辑(与 HTTP 解耦,便于单测)。
|
||||
//
|
||||
// 两层设计:
|
||||
// - Tier1(本包,网关同步、无 LLM):先把输入「归一化」——小写、去零宽字符、折叠同形字、
|
||||
// 拆字间隔(i g n o r e)还原、base64 解码——再跑高精度注入正则,干掉编码/空格/同形字绕过;
|
||||
// env 黑名单命中即拦。命中即 Blocked。
|
||||
// - Tier2(dispatcher harness 的 LLM 分类器):Tier1 判为「疑似但不确定」(Suspect) 的灰区输入
|
||||
// 放行但打标,由 Dispatcher 执行前调 LLM 裁决(见 contract.MetaSafetyCheck)。
|
||||
package guardrail
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// MaxJSONBytes 是 JSON 请求体上限(文件上传走 multipart,不经此检查)。
|
||||
const MaxJSONBytes = 256 * 1024
|
||||
|
||||
// injectionPatterns 是提示词注入 / 越权诱导的可疑模式(大小写不敏感)。
|
||||
// injectionPatterns 是高精度的提示词注入 / 越权诱导模式(命中即硬拦截)。
|
||||
// 在 原文 + 归一化 + 紧凑(去分隔) + base64 解码 多个视图上匹配,故无需把绕过变体逐一写进正则。
|
||||
var injectionPatterns = []struct {
|
||||
label string
|
||||
re *regexp.Regexp
|
||||
}{
|
||||
{"忽略既定指令", regexp.MustCompile(`(?i)ignore\s+(all\s+|the\s+)*previous\s+(instructions?|prompts?)`)},
|
||||
{"忽略既定指令", regexp.MustCompile(`(?i)disregard\s+(the\s+)?(above|previous|prior)`)},
|
||||
{"忽略既定指令", regexp.MustCompile(`(?i)ignore\s*(all\s*|the\s*)*previous\s*(instructions?|prompts?)`)},
|
||||
{"忽略既定指令", regexp.MustCompile(`(?i)disregard\s*(the\s*)?(above|previous|prior)`)},
|
||||
{"忽略既定指令", regexp.MustCompile(`忽略(以上|之前|前面|上述|先前)[^。\n]{0,8}(指令|指示|提示|要求|规则|设定)`)},
|
||||
{"角色越权", regexp.MustCompile(`(?i)you\s+are\s+now\s+(a|an|the|no longer)`)},
|
||||
{"诱导泄露提示词", regexp.MustCompile(`(?i)(reveal|show|print|repeat|expose)\s+(me\s+)?(your\s+|the\s+)*(system\s+)?prompt`)},
|
||||
{"角色越权", regexp.MustCompile(`(?i)you\s*are\s*now\s*(a|an|the|no\s*longer)`)},
|
||||
{"诱导泄露提示词", regexp.MustCompile(`(?i)(reveal|show|print|repeat|expose)\s*(me\s*)?(your\s*|the\s*)*(system\s*)?prompt`)},
|
||||
{"诱导泄露提示词", regexp.MustCompile(`(泄露|显示|输出|告诉我|重复)[^。\n]{0,6}(系统)?(提示词|指令)`)},
|
||||
}
|
||||
|
||||
// bannedTerms 是敏感词黑名单(默认空,按需在此扩展;命中即拦截)。
|
||||
var bannedTerms = []string{}
|
||||
// suspectLexicon 是「软信号」越狱词表:单独命中不足以拦截(误伤风险),但触发灰区升级(交 LLM 裁决)。
|
||||
var suspectLexicon = []string{
|
||||
"jailbreak", "developer mode", "dan mode", "do anything now", "no restrictions",
|
||||
"without restrictions", "unfiltered", "ignore safety", "bypass", "sudo mode",
|
||||
"pretend you are", "roleplay as", "act as if", "越狱", "开发者模式", "无限制",
|
||||
}
|
||||
|
||||
// Inspect 检查输入是否触发护栏。blocked=true 时返回人类可读的拦截原因。
|
||||
func Inspect(body []byte) (reason string, blocked bool) {
|
||||
if len(body) > MaxJSONBytes {
|
||||
return "请求体过大(超过 256KB)", true
|
||||
// bannedTerms 是敏感词黑名单,从 env GUARDRAIL_BANNED_TERMS 读(逗号分隔,已小写);命中即拦截。
|
||||
var bannedTerms = loadBannedTerms()
|
||||
|
||||
func loadBannedTerms() []string {
|
||||
var out []string
|
||||
for _, w := range strings.Split(os.Getenv("GUARDRAIL_BANNED_TERMS"), ",") {
|
||||
if w = strings.TrimSpace(strings.ToLower(w)); w != "" {
|
||||
out = append(out, w)
|
||||
}
|
||||
}
|
||||
s := string(body)
|
||||
return out
|
||||
}
|
||||
|
||||
// Result 是一次输入检查结果。
|
||||
type Result struct {
|
||||
Blocked bool // 硬拦截(注入/黑名单/超体积)
|
||||
Reason string // 人类可读拦截原因
|
||||
Suspect bool // 灰区:疑似但不确定,放行但标记升级 LLM 裁决(Tier2)
|
||||
Signals []string // 命中的软信号(可观测)
|
||||
}
|
||||
|
||||
// Inspect 检查输入是否触发护栏。
|
||||
func Inspect(body []byte) Result {
|
||||
if len(body) > MaxJSONBytes {
|
||||
return Result{Blocked: true, Reason: "请求体过大(超过 256KB)"}
|
||||
}
|
||||
raw := string(body)
|
||||
// 多视图:原文 + 归一化(小写/去零宽/同形字折叠/塌缩空白)+ 紧凑(去所有分隔)+ base64 解码。
|
||||
norm := normalize(raw)
|
||||
compact := compactize(norm)
|
||||
views := []string{raw, norm, compact}
|
||||
if dec := decodeBase64Blobs(raw); dec != "" {
|
||||
views = append(views, strings.ToLower(dec), compactize(normalize(dec)))
|
||||
}
|
||||
|
||||
for _, p := range injectionPatterns {
|
||||
if p.re.MatchString(s) {
|
||||
return "疑似提示词注入(" + p.label + ")", true
|
||||
for _, v := range views {
|
||||
if p.re.MatchString(v) {
|
||||
return Result{Blocked: true, Reason: "疑似提示词注入(" + p.label + ")"}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, w := range bannedTerms {
|
||||
if w != "" && strings.Contains(s, w) {
|
||||
return "命中敏感词", true
|
||||
for _, v := range views {
|
||||
if strings.Contains(v, w) {
|
||||
return Result{Blocked: true, Reason: "命中敏感词"}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
|
||||
// 软信号 → 灰区升级(不拦,交 Tier2 LLM 裁决)。
|
||||
var signals []string
|
||||
for _, w := range suspectLexicon {
|
||||
if strings.Contains(norm, w) || strings.Contains(compact, compactize(w)) {
|
||||
signals = append(signals, w)
|
||||
}
|
||||
}
|
||||
if len(views) > 3 { // 含可解码的 base64 blob:本身可疑,升级
|
||||
signals = append(signals, "base64-payload")
|
||||
}
|
||||
return Result{Suspect: len(signals) > 0, Signals: signals}
|
||||
}
|
||||
|
||||
// zeroWidth 是常见零宽 / 不可见字符(注入者用来打断关键词、骗过正则):
|
||||
// U+200B-200D 零宽空格/连接符、U+2060 word joiner、U+FEFF BOM、U+00AD 软连字符。
|
||||
func zeroWidth(r rune) bool {
|
||||
switch r {
|
||||
case '\u200b', '\u200c', '\u200d', '\u2060', '\ufeff', '\u00ad':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// homoglyphs 把常见西里尔/希腊同形字折回拉丁字母(如 'а'→'a',肉眼一样、码位不同)。
|
||||
var homoglyphs = map[rune]rune{
|
||||
'а': 'a', 'е': 'e', 'о': 'o', 'р': 'p', 'с': 'c', 'х': 'x', 'у': 'y',
|
||||
'і': 'i', 'ѕ': 's', 'ԁ': 'd', 'ո': 'n', 'ɡ': 'g', 'ⅼ': 'l',
|
||||
'ο': 'o', 'ρ': 'p', 'ε': 'e', 'α': 'a', 'ι': 'i', 'ν': 'v',
|
||||
}
|
||||
|
||||
// normalize 归一化:小写 + 去零宽 + 去组合附加符(变音) + 同形字折叠 + 塌缩连续空白为单空格。
|
||||
func normalize(s string) string {
|
||||
s = strings.ToLower(s)
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
prevSpace := false
|
||||
for _, r := range s {
|
||||
if zeroWidth(r) || unicode.Is(unicode.Mn, r) { // Mn=组合附加符
|
||||
continue
|
||||
}
|
||||
if g, ok := homoglyphs[r]; ok {
|
||||
r = g
|
||||
}
|
||||
if unicode.IsSpace(r) {
|
||||
if !prevSpace {
|
||||
b.WriteRune(' ')
|
||||
prevSpace = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
prevSpace = false
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
// compactize 去掉所有非「字母数字 / CJK」字符:把 "i.g.n.o.r.e" / "i g n o r e" 还原成 "ignore",
|
||||
// 干掉拆字间隔类绕过。注意会粘连多词,故 compact 视图配合的是同样去分隔的紧凑模式。
|
||||
func compactize(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r >= 0x4e00 && r <= 0x9fff: // CJK
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// base64BlobRe 匹配较长的 base64 串(短串误报多,限 ≥24 字符)。
|
||||
var base64BlobRe = regexp.MustCompile(`[A-Za-z0-9+/]{24,}={0,2}`)
|
||||
|
||||
// decodeBase64Blobs 解码输入里的长 base64 片段,拼成可扫描的明文(解码失败/非文本的丢弃)。
|
||||
func decodeBase64Blobs(s string) string {
|
||||
var out strings.Builder
|
||||
for _, m := range base64BlobRe.FindAllString(s, -1) {
|
||||
dec, err := base64.StdEncoding.DecodeString(m)
|
||||
if err != nil {
|
||||
if dec, err = base64.RawStdEncoding.DecodeString(m); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if isMostlyText(dec) {
|
||||
out.Write(dec)
|
||||
out.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// isMostlyText 判断解码结果是否大体为可打印文本(过滤二进制/随机字节,降误报)。
|
||||
func isMostlyText(b []byte) bool {
|
||||
if len(b) == 0 {
|
||||
return false
|
||||
}
|
||||
printable := 0
|
||||
for _, c := range b {
|
||||
if c == '\n' || c == '\t' || (c >= 0x20 && c < 0x7f) {
|
||||
printable++
|
||||
}
|
||||
}
|
||||
return printable*100/len(b) >= 85
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user