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
660 lines
21 KiB
Go
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
|
|
}
|