Files
sub2api/backend/internal/securityaudit/prompt_snapshot.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

660 lines
21 KiB
Go

package securityaudit
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"regexp"
"sort"
"strings"
"unicode/utf8"
)
var (
ErrNoPromptText = errors.New("prompt audit request contains no user text")
bearerPattern = regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9._~+\-/]+=*`)
apiKeyPattern = regexp.MustCompile(`(?i)\b(sk|rk|pk|api[_-]?key|token|secret|password)[-_:=\s]+[A-Za-z0-9._~+\-/]{8,}`)
canaryPattern = regexp.MustCompile(`(?i)([A-Z]+_CANARY_)[A-Za-z0-9_-]+`)
emailPattern = regexp.MustCompile(`(?i)\b[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}\b`)
phonePattern = regexp.MustCompile(`(?:\+?\d[\d\s().-]{8,}\d)`)
)
const promptAuditPrioritySeparator = "\x00SUB2API_PROMPT_AUDIT_PRIORITY_END\x00"
type promptSegment struct {
text string
user bool
role string
}
func ExtractPromptSnapshot(req Request) (PromptSnapshot, error) {
return extractPromptSnapshot(req, false)
}
// ExtractBlockingPromptSnapshot builds the narrow, low-latency blocking input
// when configured. Asynchronous auditing always uses ExtractPromptSnapshot so
// the complete client-controlled transcript is retained for review.
func ExtractBlockingPromptSnapshot(req Request, latestTurnOnly bool) (PromptSnapshot, error) {
return extractPromptSnapshot(req, latestTurnOnly)
}
func extractPromptSnapshot(req Request, latestTurnOnly bool) (PromptSnapshot, error) {
var document any
if err := json.Unmarshal(req.Body, &document); err != nil {
return PromptSnapshot{}, errors.New("prompt audit request JSON is invalid")
}
extracted := extractProtocolSegments(req.Protocol, document)
segments := normalizeSegmentsLatestUserFirst(extracted)
if latestTurnOnly {
segments = blockingSegmentsLatestUserAndPreviousOutput(extracted)
}
if len(segments) == 0 {
return PromptSnapshot{}, ErrNoPromptText
}
scanText, metadataText := buildPrioritizedScanText(segments)
digest := sha256.Sum256([]byte(metadataText))
stage := strings.TrimSpace(req.Stage)
if stage == "" {
stage = "http"
}
return PromptSnapshot{
RequestID: req.RequestID, UserID: req.UserID, UsernameSnapshot: req.Username,
UserEmailSnapshot: req.UserEmail, APIKeyID: req.APIKeyID, APIKeyNameSnapshot: req.APIKeyName,
GroupID: cloneInt64Ptr(req.GroupID), GroupName: req.GroupName, Provider: req.Provider,
Endpoint: req.Endpoint, Protocol: req.Protocol, Model: req.Model,
PromptHash: hex.EncodeToString(digest[:]), RedactedPreview: BuildPromptPreview(metadataText, DefaultPromptPreviewMaxRunes),
FullPrompt: BuildFullPrompt(metadataText, DefaultFullPromptMaxRunes),
PromptLength: utf8.RuneCountInString(metadataText), MessageCount: len(segments), Stage: stage,
ScanText: scanText,
}, nil
}
// DefaultPromptPreviewMaxRunes caps how much sanitized prompt text may be
// considered before BuildPromptPreview withholds the majority for storage/UI.
const DefaultPromptPreviewMaxRunes = 96
// DefaultFullPromptMaxRunes caps how much unredacted prompt text is persisted
// on an audit event for admin review. It is deliberately generous so realistic
// prompts are kept intact while bounding per-row storage.
const DefaultFullPromptMaxRunes = 65536
func extractProtocolSegments(protocol string, document any) []promptSegment {
root, _ := document.(map[string]any)
protocol = strings.ToLower(strings.TrimSpace(protocol))
switch protocol {
case "openai_chat_completions", "openai_chat", "chat_completions":
return extractChatLikeSegments(root)
case "anthropic_messages", "claude_messages", "messages":
return append(extractAnthropicSystem(root["system"]), extractMessages(root["messages"], clientInstructionRoles...)...)
case "gemini", "gemini_generate_content":
return extractGeminiRoot(root)
case "openai_responses", "responses", "responses_websocket":
if frameType := stringValue(root["type"]); frameType != "" || protocol == "responses_websocket" {
if frameType != "response.create" {
return nil
}
if input, exists := root["input"]; exists && input != nil {
return append(extractInstructions(root["instructions"]), extractResponses(input)...)
}
if response, ok := root["response"].(map[string]any); ok {
return append(extractInstructions(response["instructions"]), extractResponses(response["input"])...)
}
return extractInstructions(root["instructions"])
}
return append(extractInstructions(root["instructions"]), extractResponses(root["input"])...)
case "openai_images", "grok_media", "media", "images":
return userPromptSegments(extractMediaPrompts(root))
default:
if segments := extractChatLikeSegments(root); len(segments) > 0 {
return segments
}
if responses := append(extractInstructions(root["instructions"]), extractResponses(root["input"])...); len(responses) > 0 {
return responses
}
if gemini := extractGeminiRoot(root); len(gemini) > 0 {
return gemini
}
return userPromptSegments(extractMediaPrompts(root))
}
}
// clientInstructionRoles are roles a client may freely populate. Attackers can
// place jailbreak/PII text in assistant/tool turns, so blocking audit must scan
// them too—not only user/system/developer instructions.
var clientInstructionRoles = []string{"user", "system", "developer", "assistant", "tool"}
func extractChatLikeSegments(root map[string]any) []promptSegment {
if root == nil {
return nil
}
return extractMessages(root["messages"], clientInstructionRoles...)
}
func extractMessages(value any, wantedRoles ...string) []promptSegment {
items, ok := value.([]any)
if !ok {
return nil
}
wanted := make(map[string]struct{}, len(wantedRoles))
for _, role := range wantedRoles {
wanted[strings.ToLower(strings.TrimSpace(role))] = struct{}{}
}
result := make([]promptSegment, 0, len(items))
for _, item := range items {
message, ok := item.(map[string]any)
if !ok {
continue
}
role := strings.ToLower(stringValue(message["role"]))
if _, match := wanted[role]; !match {
continue
}
texts := contentTexts(message["content"])
for _, text := range texts {
result = append(result, promptSegment{text: text, user: role == "user", role: role})
}
}
return result
}
func extractInstructions(value any) []promptSegment {
switch typed := value.(type) {
case string:
if text := strings.TrimSpace(typed); text != "" {
return []promptSegment{{text: text, role: "system"}}
}
case []any:
return systemPromptSegments(contentTexts(typed))
case map[string]any:
return systemPromptSegments(contentTexts(typed))
}
return nil
}
func extractAnthropicSystem(value any) []promptSegment {
switch typed := value.(type) {
case string:
if text := strings.TrimSpace(typed); text != "" {
return []promptSegment{{text: text, role: "system"}}
}
case []any:
return systemPromptSegments(contentTexts(typed))
case map[string]any:
return systemPromptSegments(contentTexts(typed))
}
return nil
}
func extractResponses(value any) []promptSegment {
switch typed := value.(type) {
case string:
return []promptSegment{{text: typed, user: true, role: "user"}}
case []any:
result := make([]promptSegment, 0, len(typed))
for _, item := range typed {
switch entry := item.(type) {
case string:
result = append(result, promptSegment{text: entry, user: true, role: "user"})
case map[string]any:
role := strings.ToLower(stringValue(entry["role"]))
if role != "" && !isClientInstructionRole(role) {
continue
}
if content, exists := entry["content"]; exists {
for _, text := range contentTexts(content) {
result = append(result, promptSegment{text: text, user: role == "" || role == "user", role: role})
}
} else if text := stringValue(entry["text"]); text != "" {
result = append(result, promptSegment{text: text, user: role == "" || role == "user", role: role})
}
}
}
return result
case map[string]any:
role := strings.ToLower(stringValue(typed["role"]))
if role != "" && !isClientInstructionRole(role) {
return nil
}
return promptSegmentsForRole(contentTexts(typed["content"]), role)
default:
return nil
}
}
func isClientInstructionRole(role string) bool {
switch strings.ToLower(strings.TrimSpace(role)) {
case "user", "system", "developer", "assistant", "tool", "model":
return true
default:
return false
}
}
func extractGemini(value any) []promptSegment {
var contents []any
switch typed := value.(type) {
case []any:
contents = typed
case map[string]any:
contents = []any{typed}
default:
return nil
}
result := make([]promptSegment, 0, len(contents))
for _, item := range contents {
content, ok := item.(map[string]any)
if !ok {
continue
}
role := strings.ToLower(stringValue(content["role"]))
if role != "" && !isClientInstructionRole(role) {
continue
}
parts, _ := content["parts"].([]any)
for _, part := range parts {
if object, ok := part.(map[string]any); ok {
if text := stringValue(object["text"]); text != "" {
result = append(result, promptSegment{text: text, user: role == "" || role == "user", role: role})
}
}
}
}
return result
}
func extractGeminiRoot(root map[string]any) []promptSegment {
if root == nil {
return nil
}
result := extractGeminiSystemInstruction(root["systemInstruction"])
result = append(result, extractGeminiSystemInstruction(root["system_instruction"])...)
result = append(result, extractGemini(root["contents"])...)
result = append(result, extractGemini(root["content"])...)
result = append(result, extractGeminiInstances(root["instances"])...)
if requests, ok := root["requests"].([]any); ok {
for _, item := range requests {
request, ok := item.(map[string]any)
if !ok {
continue
}
result = append(result, extractGeminiSystemInstruction(request["systemInstruction"])...)
result = append(result, extractGeminiSystemInstruction(request["system_instruction"])...)
result = append(result, extractGemini(request["contents"])...)
result = append(result, extractGemini(request["content"])...)
result = append(result, extractGeminiInstances(request["instances"])...)
}
}
return result
}
func extractGeminiSystemInstruction(value any) []promptSegment {
switch typed := value.(type) {
case string:
if text := strings.TrimSpace(typed); text != "" {
return []promptSegment{{text: text, role: "system"}}
}
case map[string]any:
if parts, ok := typed["parts"].([]any); ok {
result := make([]promptSegment, 0, len(parts))
for _, part := range parts {
if object, ok := part.(map[string]any); ok {
if text := stringValue(object["text"]); text != "" {
result = append(result, promptSegment{text: text, role: "system"})
}
}
}
return result
}
return systemPromptSegments(contentTexts(typed))
case []any:
segments := extractGemini(typed)
for index := range segments {
segments[index].user = false
segments[index].role = "system"
}
return segments
}
return nil
}
func extractGeminiInstances(value any) []promptSegment {
instances, ok := value.([]any)
if !ok {
return nil
}
result := make([]promptSegment, 0, len(instances))
for _, item := range instances {
if instance, ok := item.(map[string]any); ok {
if prompt := stringValue(instance["prompt"]); prompt != "" {
result = append(result, promptSegment{text: prompt, user: true, role: "user"})
}
}
}
return result
}
func extractMediaPrompts(root map[string]any) []string {
if root == nil {
return nil
}
result := make([]string, 0, 4)
seen := map[string]struct{}{}
var walk func(any, string)
walk = func(value any, key string) {
switch typed := value.(type) {
case map[string]any:
keys := make([]string, 0, len(typed))
for childKey := range typed {
keys = append(keys, childKey)
}
sort.Strings(keys)
for _, childKey := range keys {
walk(typed[childKey], childKey)
}
case []any:
for _, item := range typed {
walk(item, key)
}
case string:
if !isMediaPromptKey(key) || looksLikeMediaPayload(typed) {
return
}
text := strings.TrimSpace(typed)
if text == "" {
return
}
if _, duplicate := seen[text]; duplicate {
return
}
seen[text] = struct{}{}
result = append(result, text)
}
}
walk(root, "")
return result
}
func isMediaPromptKey(key string) bool {
normalized := strings.NewReplacer("_", "", "-", "").Replace(strings.ToLower(strings.TrimSpace(key)))
switch normalized {
case "prompt", "inputprompt", "textprompt", "description", "query", "lyrics", "negativeprompt",
"positiveprompt", "gptdescriptionprompt", "prompten", "finalprompt", "finalzhprompt",
"origprompt", "actualprompt", "imageprompt", "input":
return true
default:
return false
}
}
func looksLikeMediaPayload(value string) bool {
trimmed := strings.TrimSpace(value)
lower := strings.ToLower(trimmed)
if strings.HasPrefix(lower, "data:image/") || strings.HasPrefix(lower, "data:video/") ||
strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
return true
}
if len(trimmed) >= 256 {
for _, r := range trimmed {
alphaNumeric := (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
if !alphaNumeric && r != '+' && r != '/' && r != '=' {
return false
}
}
return true
}
return false
}
func contentTexts(value any) []string {
switch typed := value.(type) {
case string:
return []string{typed}
case []any:
result := make([]string, 0, len(typed))
for _, part := range typed {
object, ok := part.(map[string]any)
if !ok {
continue
}
typeName := strings.ToLower(stringValue(object["type"]))
if typeName != "" && typeName != "text" && typeName != "input_text" && typeName != "output_text" {
continue
}
if text := stringValue(object["text"]); text != "" {
result = append(result, text)
}
}
return result
case map[string]any:
if text := stringValue(typed["text"]); text != "" {
return []string{text}
}
}
return nil
}
func normalizeSegmentsLatestUserFirst(values []promptSegment) []string {
normalized := normalizedPromptSegments(values)
if len(normalized) == 0 {
return nil
}
priorityIndex := len(normalized) - 1
for index := len(normalized) - 1; index >= 0; index-- {
if isUserSegment(normalized[index]) {
priorityIndex = index
break
}
}
result := make([]string, 0, len(normalized))
result = append(result, normalized[priorityIndex].text)
for index, segment := range normalized {
if index != priorityIndex {
result = append(result, segment.text)
}
}
return result
}
// blockingSegmentsLatestUserAndPreviousOutput limits synchronous guard input to
// the current user turn and the nearest preceding assistant/model turn. It is
// deliberately opt-in because full transcript scanning remains stronger at
// finding client-controlled content placed in older or non-user messages.
func blockingSegmentsLatestUserAndPreviousOutput(values []promptSegment) []string {
normalized := normalizedPromptSegments(values)
latestUserStart := latestUserSegmentStart(normalized)
if latestUserStart < 0 {
// A request without user content cannot be narrowed safely. Preserve the
// established full-snapshot behavior for unusual protocol payloads.
return normalizeSegmentsLatestUserFirst(values)
}
latestUserEnd := latestUserStart
for latestUserEnd < len(normalized) && isUserSegment(normalized[latestUserEnd]) {
latestUserEnd++
}
currentUserText := make([]string, 0, latestUserEnd-latestUserStart)
for _, segment := range normalized[latestUserStart:latestUserEnd] {
currentUserText = append(currentUserText, segment.text)
}
// A single client turn may have several text content parts. Keep it in one
// priority segment so every part of the latest input is scanned before the
// prior output begins.
selected := []promptSegment{{text: strings.Join(currentUserText, "\n\n"), user: true, role: "user"}}
for index := latestUserStart - 1; index >= 0; index-- {
if !isAssistantOutputSegment(normalized[index]) {
continue
}
start := index
for start > 0 && isAssistantOutputSegment(normalized[start-1]) {
start--
}
selected = append(selected, normalized[start:index+1]...)
break
}
return promptSegmentTexts(selected)
}
func normalizedPromptSegments(values []promptSegment) []promptSegment {
normalized := make([]promptSegment, 0, len(values))
for _, value := range values {
value.text = strings.TrimSpace(value.text)
if value.text != "" {
normalized = append(normalized, value)
}
}
return normalized
}
func latestUserSegmentStart(values []promptSegment) int {
latest := -1
for index := len(values) - 1; index >= 0; index-- {
if isUserSegment(values[index]) {
latest = index
break
}
}
for latest > 0 && isUserSegment(values[latest-1]) {
latest--
}
return latest
}
func isUserSegment(segment promptSegment) bool {
return segment.user || segment.role == "user"
}
func isAssistantOutputSegment(segment promptSegment) bool {
return segment.role == "assistant" || segment.role == "model"
}
func promptSegmentTexts(values []promptSegment) []string {
result := make([]string, 0, len(values))
for _, value := range values {
result = append(result, value.text)
}
return result
}
func buildPrioritizedScanText(segments []string) (scanText string, metadataText string) {
metadataText = strings.Join(segments, "\n\n")
if len(segments) <= 1 {
return metadataText, metadataText
}
return segments[0] + promptAuditPrioritySeparator + strings.Join(segments[1:], "\n\n"), metadataText
}
func promptSegmentsForRole(texts []string, role string) []promptSegment {
result := make([]promptSegment, 0, len(texts))
for _, text := range texts {
result = append(result, promptSegment{text: text, user: role == "" || role == "user", role: role})
}
return result
}
func userPromptSegments(texts []string) []promptSegment {
return promptSegmentsForRole(texts, "user")
}
func systemPromptSegments(texts []string) []promptSegment {
return promptSegmentsForRole(texts, "system")
}
func RedactPreview(value string, maxRunes int) string {
value = bearerPattern.ReplaceAllString(value, "Bearer ***")
value = apiKeyPattern.ReplaceAllStringFunc(value, func(match string) string {
if index := strings.IndexAny(match, ":= \t"); index >= 0 {
return match[:index+1] + "***"
}
return "***"
})
value = canaryPattern.ReplaceAllString(value, "${1}***")
value = emailPattern.ReplaceAllString(value, "***@***")
value = phonePattern.ReplaceAllString(value, "***PHONE***")
return TrimRunes(value, maxRunes)
}
// BuildPromptPreview stores only a short, non-recoverable head of sanitized
// input. Ordinary confidential prompts must not land nearly intact in PostgreSQL
// or the admin UI merely because no secret regex matched.
func BuildPromptPreview(value string, maxRunes int) string {
if maxRunes <= 0 {
maxRunes = DefaultPromptPreviewMaxRunes
}
redacted := strings.TrimSpace(RedactPreview(value, maxRunes))
if redacted == "" {
return ""
}
runes := []rune(redacted)
hadTruncation := strings.HasSuffix(redacted, "…")
if hadTruncation && len(runes) > 0 {
runes = runes[:len(runes)-1]
}
if len(runes) == 0 {
return "***…"
}
// Short unlabelled secrets would otherwise leak a recoverable prefix (e.g.
// 20 runes → 5 visible). Fully withhold anything below the keep threshold.
const minLengthForPartialPreview = 32
if len(runes) < minLengthForPartialPreview {
if hadTruncation {
return "***…"
}
return "***"
}
// Keep at most a quarter of the already-truncated text, and never more than
// 24 runes, so the majority of prompt content is withheld by default.
keep := len(runes) / 4
if keep > 24 {
keep = 24
}
preview := string(runes[:keep]) + "***"
if hadTruncation || keep < len(runes) {
preview += "…"
}
return preview
}
// BuildFullPrompt returns the complete prompt text for audit-event storage and
// admin review, without redaction. NUL bytes are stripped because PostgreSQL
// TEXT rejects them, and the result is capped at maxRunes.
func BuildFullPrompt(value string, maxRunes int) string {
if maxRunes <= 0 {
maxRunes = DefaultFullPromptMaxRunes
}
value = strings.ReplaceAll(value, "\x00", "")
return TrimRunes(strings.TrimSpace(value), maxRunes)
}
// FullPromptFromScanText reconstructs the display prompt from the worker scan
// payload. buildPrioritizedScanText inserts exactly one priority separator
// between the prioritized segment and the remainder, so replacing it with the
// metadata joiner yields the original multi-segment text.
func FullPromptFromScanText(scanText string) string {
return BuildFullPrompt(strings.ReplaceAll(scanText, promptAuditPrioritySeparator, "\n\n"), DefaultFullPromptMaxRunes)
}
func TrimRunes(value string, limit int) string {
if limit <= 0 {
return ""
}
runes := []rune(value)
if len(runes) <= limit {
return value
}
return string(runes[:limit]) + "…"
}
func stringValue(value any) string {
text, _ := value.(string)
return strings.TrimSpace(text)
}
func cloneInt64Ptr(value *int64) *int64 {
if value == nil {
return nil
}
cloned := *value
return &cloned
}