| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659 |
- 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
- }
|