ForcePilot/backend/package/yuxi/channel/security/external_content.py
Kris 849d21120f feat(channel/security): 新增完整安全工具模块
新增一系列安全相关功能:
1.  新增二维码生成工具,支持自定义参数导出图片/Base64/字节流
2.  新增HTML内容安全处理工具,包括标签剥离、转义、URL校验
3.  新增日志敏感信息脱敏工具,支持配置、字典、日志记录脱敏
4.  新增密钥管理运行时,支持从环境变量/文件加载密钥
5.  新增身份链接管理,支持多渠道身份绑定与解析
6.  新增SSRF防护工具,支持域名/IP校验与固定主机
7.  新增安全权限修复工具,修复文件目录权限与配置项
8.  新增外部内容安全处理,支持LLM特殊令牌剥离与注入检测
9.  新增认证限流工具,支持多维度限流与本地回环豁免
10. 新增配对管理工具,支持安全配对码生成与校验
11. 新增白名单管理工具,支持DM/群组/来源白名单校验
2026-05-21 10:32:57 +08:00

251 lines
7.6 KiB
Python
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.

from __future__ import annotations
import logging
import re
import secrets
import string
from dataclasses import dataclass, field
from typing import NamedTuple
logger = logging.getLogger(__name__)
_BOUNDARY_ID_LENGTH = 64
_BOUNDARY_ALPHABET = string.ascii_letters + string.digits
_CHATML_TOKENS = re.compile(
r"<\|im_start\|>|<\|im_end\|>",
)
_LLAMA_TOKENS = re.compile(
r"\[/?INST\]|<<SYS>>|<</SYS>>",
)
_MISTRAL_TOKENS = re.compile(
r"\[/?INST\]",
)
_PHI_TOKENS = re.compile(
r"<\|system\|>|<\|user\|>|<\|assistant\|>|<\|end\|>|<\|endoftext\|>",
)
_LLM_SPECIAL_TOKENS = re.compile(
r"<\|im_start\|>|<\|im_end\|>|"
r"\[/?INST\]|<<SYS>>|<</SYS>>|"
r"<\|system\|>|<\|user\|>|<\|assistant\|>|<\|end\|>|<\|endoftext\|>",
)
def _normalize_for_detection(text: str) -> str:
"""Normalize text by removing zero-width characters and common obfuscation."""
# Remove zero-width and invisible characters used for obfuscation
zw_chars = "\u200b\u200c\u200d\ufeff\u2060\u180e"
for ch in zw_chars:
text = text.replace(ch, "")
# Normalize common homoglyphs
text = text.replace("", "0").replace("", "1").replace("", "A").replace("", "a")
return text
_INJECTION_PATTERNS: list[tuple[str, re.Pattern[str], str]] = [
(
"ignore_previous",
re.compile(
r"(ignore|disregard|forget|override)\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|commands?|directives?|prompts?)",
re.IGNORECASE,
),
"critical",
),
(
"pretend_role",
re.compile(
r"(pretend|act|pose|role[\s-]?play)\s+(to\s+be|as)\s+(if\s+you\s+are\s+)?(an?\s+)?(DAN|developer|admin|system|root|unrestricted|jailbreak)",
re.IGNORECASE,
),
"critical",
),
(
"new_instructions",
re.compile(
r"(your\s+)?(new|updated|revised)\s+(instructions?|system\s+prompts?|directives?|guidelines?)\s*(:|=|is|are|as\s+follows)",
re.IGNORECASE,
),
"critical",
),
(
"now_you_are",
re.compile(
r"(now|from\s+now\s+on)\s+(you\s+are|you\'re)\s+(a|an|the)\s+(DAN|developer|admin|system|unrestricted)",
re.IGNORECASE,
),
"critical",
),
(
"system_override",
re.compile(
r"(override|overwrite|replace)\s+(the\s+)?(system\s+(prompt|message|instruction)|above\s+instructions?)",
re.IGNORECASE,
),
"critical",
),
(
"delimiter_injection",
re.compile(
r"<\|(?:im_start|im_end|system|user|assistant|end)\|>"
r"(?:(?!<\|(?:im_start|im_end|system|user|assistant|end)\|>)[\s\S]){0,5000}"
r"<\|(?:im_start|im_end|system|user|assistant|end)\|>",
),
"critical",
),
(
"fake_completion",
re.compile(
r"(task\s+(is\s+)?(complete|finished|done)|goal\s+satisfied)\.?\s*(stop|end|terminate|halt)\b",
re.IGNORECASE,
),
"warn",
),
(
"output_format_hijack",
re.compile(
r"(respond|reply|answer|output)\s+(only|exclusively|solely)\s+(with|in|as)\s+(JSON|XML|YAML|code|markdown)",
re.IGNORECASE,
),
"warn",
),
(
"disclosure_request",
re.compile(
r"(reveal|disclose|show|print|output|dump|leak)\s+(your|the)\s+(system\s+(prompt|message|instruction)|original\s+(prompt|instruction)|hidden\s+(prompt|instruction)|secret\s+(prompt|instruction))",
re.IGNORECASE,
),
"warn",
),
]
_INJECTION_PATTERN_NAMES: dict[str, str] = {
"ignore_previous": "Ignore/override previous instructions",
"pretend_role": "Pretend to be a privileged role (DAN/developer/admin)",
"new_instructions": "Inject new/revised instructions",
"now_you_are": "You are now X role redefinition",
"system_override": "Attempt to override system prompt",
"delimiter_injection": "LLM delimiter token injection",
"fake_completion": "Fake task completion signal",
"output_format_hijack": "Output format hijacking",
"disclosure_request": "System prompt disclosure request",
}
def generate_boundary_id(length: int = _BOUNDARY_ID_LENGTH) -> str:
return "".join(secrets.choice(_BOUNDARY_ALPHABET) for _ in range(length))
_MAX_CONTENT_LENGTH = 200_000
def strip_llm_tokens(content: str) -> str:
if len(content) > _MAX_CONTENT_LENGTH:
logger.warning("Content length %d exceeds limit %d, truncating", len(content), _MAX_CONTENT_LENGTH)
content = content[:_MAX_CONTENT_LENGTH]
cleaned = _LLM_SPECIAL_TOKENS.sub("", content)
if cleaned != content:
logger.debug("Stripped LLM special tokens from content")
return cleaned
def wrap_external_content(
content: str,
boundary_id: str | None = None,
source_label: str = "external",
) -> str:
if boundary_id is None:
boundary_id = generate_boundary_id()
wrapped = f'<EXTERNAL_CONTENT id="{boundary_id}" source="{source_label}">\n{content}\n</EXTERNAL_CONTENT>'
return wrapped
@dataclass
class InjectionDetectionResult:
detected: bool
patterns: list[tuple[str, str, str]] = field(default_factory=list)
severity: str = "none"
def to_dict(self) -> dict:
return {
"detected": self.detected,
"severity": self.severity,
"patterns": [{"id": pid, "description": desc, "severity": sev} for pid, desc, sev in self.patterns],
}
@property
def critical_count(self) -> int:
return sum(1 for _, _, s in self.patterns if s == "critical")
@property
def warn_count(self) -> int:
return sum(1 for _, _, s in self.patterns if s == "warn")
def detect_injection_patterns(content: str) -> InjectionDetectionResult:
matched: list[tuple[str, str, str]] = []
if len(content) > _MAX_CONTENT_LENGTH:
content = content[:_MAX_CONTENT_LENGTH]
logger.warning("Content truncated to %d for injection detection", _MAX_CONTENT_LENGTH)
normalized = _normalize_for_detection(content)
for pattern_id, regex, severity in _INJECTION_PATTERNS:
if regex.search(content) or regex.search(normalized):
description = _INJECTION_PATTERN_NAMES.get(pattern_id, pattern_id)
matched.append((pattern_id, description, severity))
if not matched:
return InjectionDetectionResult(detected=False, severity="none")
max_severity = "critical" if any(s == "critical" for _, _, s in matched) else "warn"
result = InjectionDetectionResult(
detected=True,
patterns=matched,
severity=max_severity,
)
if max_severity == "critical":
logger.warning(
"Critical injection patterns detected: %s",
[pid for pid, _, _ in matched],
)
else:
logger.info(
"Injection patterns detected (warn): %s",
[pid for pid, _, _ in matched],
)
return result
class SanitizedContent(NamedTuple):
content: str
detection: InjectionDetectionResult
def sanitize_external_content(
content: str,
source_label: str = "external",
*,
boundary_id: str | None = None,
skip_strip: bool = False,
skip_wrap: bool = False,
skip_detect: bool = False,
) -> SanitizedContent:
cleaned = content if skip_strip else strip_llm_tokens(content)
detection = (
InjectionDetectionResult(detected=False, severity="none") if skip_detect else detect_injection_patterns(cleaned)
)
wrapped = (
cleaned if skip_wrap else wrap_external_content(cleaned, boundary_id=boundary_id, source_label=source_label)
)
return SanitizedContent(content=wrapped, detection=detection)