package securityaudit import ( "bytes" "context" "crypto/sha256" "encoding/json" "errors" "fmt" "io" "net" "net/http" "sort" "strings" "sync" ) type ScannerDefinition struct { ID string `json:"id"` Label string `json:"label"` LabelZH string `json:"label_zh"` Description string `json:"description"` } var AllScannerIDs = []string{ "violent", "non_violent_illegal_acts", "sexual_content_or_sexual_acts", "pii", "suicide_and_self_harm", "unethical_acts", "politically_sensitive_topics", "copyright_violation", "jailbreak", } var ScannerCatalog = map[string]ScannerDefinition{ "violent": {ID: "violent", Label: "Violent", LabelZH: "暴力", Description: "Violence or threats of violence"}, "non_violent_illegal_acts": {ID: "non_violent_illegal_acts", Label: "Non-violent Illegal Acts", LabelZH: "非暴力违法行为", Description: "Non-violent illegal activity"}, "sexual_content_or_sexual_acts": {ID: "sexual_content_or_sexual_acts", Label: "Sexual Content or Sexual Acts", LabelZH: "性内容或性行为", Description: "Sexual content or sexual acts"}, "pii": {ID: "pii", Label: "PII", LabelZH: "个人敏感信息", Description: "Personal identifying information"}, "suicide_and_self_harm": {ID: "suicide_and_self_harm", Label: "Suicide & Self-Harm", LabelZH: "自杀与自残", Description: "Suicide or self-harm"}, "unethical_acts": {ID: "unethical_acts", Label: "Unethical Acts", LabelZH: "不道德行为", Description: "Unethical behavior"}, "politically_sensitive_topics": {ID: "politically_sensitive_topics", Label: "Politically Sensitive Topics", LabelZH: "政治敏感话题", Description: "Politically sensitive topics"}, "copyright_violation": {ID: "copyright_violation", Label: "Copyright Violation", LabelZH: "版权侵权", Description: "Copyright infringement"}, "jailbreak": {ID: "jailbreak", Label: "Jailbreak", LabelZH: "越狱攻击", Description: "Prompt injection or jailbreak attempt"}, } var categoryAliases = map[string]string{ "violent": "violent", "violence": "violent", "non violent illegal acts": "non_violent_illegal_acts", "non-violent illegal acts": "non_violent_illegal_acts", "sexual content or sexual acts": "sexual_content_or_sexual_acts", "sexual": "sexual_content_or_sexual_acts", "pii": "pii", "personal identifying information": "pii", "personal identifiable information": "pii", "suicide self harm": "suicide_and_self_harm", "suicide and self harm": "suicide_and_self_harm", "suicide & self-harm": "suicide_and_self_harm", "unethical acts": "unethical_acts", "unethical": "unethical_acts", "politically sensitive topics": "politically_sensitive_topics", "political": "politically_sensitive_topics", "copyright violation": "copyright_violation", "copyright": "copyright_violation", "jailbreak": "jailbreak", "prompt injection": "jailbreak", } type GuardError struct { Code string HTTPStatus int Retryable bool Timeout bool Cause error } func (e *GuardError) Error() string { if e == nil { return "" } return e.Code } func (e *GuardError) Unwrap() error { return e.Cause } func NormalizeCategory(value string) string { normalized := strings.ToLower(strings.TrimSpace(value)) normalized = strings.NewReplacer("_", " ", "&", " and ", "/", " ", "-", " ", "–", " ", "—", " ").Replace(normalized) normalized = strings.Join(strings.Fields(normalized), " ") if canonical, ok := categoryAliases[normalized]; ok { return canonical } return strings.ReplaceAll(normalized, " ", "_") } func ParseQwen3Guard(content string, enabledScanners []string) (*NormalizedResult, error) { var safety string var categoryLine string for _, line := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") { line = strings.TrimSpace(line) if line == "" { continue } lower := strings.ToLower(line) switch { case strings.HasPrefix(lower, "safety:"): if safety != "" { return nil, &GuardError{Code: ErrorCodeInvalidResponse} } safety = strings.TrimSpace(line[len("safety:"):]) case strings.HasPrefix(lower, "categories:"): if categoryLine != "" { return nil, &GuardError{Code: ErrorCodeInvalidResponse} } categoryLine = strings.TrimSpace(line[len("categories:"):]) default: // Auxiliary Guard fields, such as Refusal, do not affect audit decisions. } } switch strings.ToLower(safety) { case "safe": safety = "Safe" case "controversial": safety = "Controversial" case "unsafe": safety = "Unsafe" default: return nil, &GuardError{Code: ErrorCodeInvalidResponse} } if categoryLine == "" { return nil, &GuardError{Code: ErrorCodeInvalidResponse} } enabled := make(map[string]struct{}, len(enabledScanners)) for _, scanner := range enabledScanners { enabled[NormalizeCategory(scanner)] = struct{}{} } known := map[string]struct{}{} unknown := map[string]struct{}{} for _, raw := range strings.Split(categoryLine, ",") { raw = strings.TrimSpace(raw) if raw == "" || strings.EqualFold(raw, "none") || strings.EqualFold(raw, "n/a") { continue } category := NormalizeCategory(raw) if _, ok := ScannerCatalog[category]; ok { known[category] = struct{}{} } else { unknown[unknownCategoryID(category)] = struct{}{} } } knownList := orderedScannerKeys(known) unknownList := sortedKeys(unknown) matched := make([]string, 0, len(knownList)) for _, category := range knownList { if _, ok := enabled[category]; ok { matched = append(matched, category) } } result := &NormalizedResult{ Safety: safety, Categories: knownList, MatchedScanners: matched, UnknownCategories: unknownList, ScannerScores: map[string]float64{}, ScannerEvidence: map[string]string{}, ScannerBackend: "qwen3guard-openai", ScannerVersion: "qwen3guard", PolicyID: "priority", PolicyVersion: 1, Decision: EventPass, RiskLevel: RiskLow, Action: ActionAllow, } score := 0.0 if safety == "Controversial" { score = 0.5 result.Decision, result.RiskLevel, result.Action = EventFlag, RiskMedium, ActionWarn } if safety == "Unsafe" { score = 1 if len(matched) > 0 || len(unknownList) > 0 || len(knownList) == 0 { result.Decision, result.RiskLevel, result.Action = EventCritical, RiskCritical, ActionBlock } else { result.Decision, result.RiskLevel, result.Action = EventFlag, RiskHigh, ActionWarn } } for _, category := range matched { result.ScannerScores[category] = score result.ScannerEvidence[category] = ScannerCatalog[category].Label if safety == "Controversial" && isElevatedControversial(category) { result.Decision, result.RiskLevel, result.Action = EventCritical, RiskCritical, ActionBlock } } return result, nil } func unknownCategoryID(value string) string { digest := sha256.Sum256([]byte(strings.TrimSpace(strings.ToLower(value)))) return fmt.Sprintf("unknown:%x", digest[:8]) } func isElevatedControversial(category string) bool { return category == "jailbreak" || category == "pii" || category == "suicide_and_self_harm" } type OpenAICompatibleScanner struct { clients sync.Map } func NewOpenAICompatibleScanner() *OpenAICompatibleScanner { return &OpenAICompatibleScanner{} } func (s *OpenAICompatibleScanner) Scan(ctx context.Context, endpoint ActiveEndpoint, chunk string, enabledScanners []string) (*NormalizedResult, error) { client, err := s.clientFor(endpoint) if err != nil { return nil, &GuardError{Code: ErrorCodeUnavailable, Cause: err} } requestURL, err := ChatCompletionsURL(endpoint.BaseURL) if err != nil { return nil, &GuardError{Code: ErrorCodeUnavailable, Cause: err} } payload := map[string]any{ "model": endpoint.Model, "messages": []map[string]string{{"role": "user", "content": chunk}}, "temperature": 0, "max_tokens": 64, "seed": 42, } body, err := json.Marshal(payload) if err != nil { return nil, &GuardError{Code: ErrorCodeInvalidResponse, Cause: err} } req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewReader(body)) if err != nil { return nil, &GuardError{Code: ErrorCodeUnavailable, Cause: err} } req.Header.Set("Content-Type", "application/json") if endpoint.Token != "" { req.Header.Set("Authorization", "Bearer "+endpoint.Token) } resp, err := client.Do(req) if err != nil { timeout := errors.Is(err, context.DeadlineExceeded) var netErr net.Error if errors.As(err, &netErr) && netErr.Timeout() { timeout = true } return nil, &GuardError{Code: ErrorCodeUnavailable, Retryable: true, Timeout: timeout, Cause: err} } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { retryable := resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 return nil, &GuardError{Code: ErrorCodeUnavailable, HTTPStatus: resp.StatusCode, Retryable: retryable} } limited := io.LimitReader(resp.Body, maxGuardResponseBytes+1) responseBody, err := io.ReadAll(limited) if err != nil { return nil, &GuardError{Code: ErrorCodeUnavailable, Retryable: true, Cause: err} } if int64(len(responseBody)) > maxGuardResponseBytes { return nil, &GuardError{Code: ErrorCodeInvalidResponse} } content, err := extractOpenAIContent(responseBody) if err != nil { return nil, &GuardError{Code: ErrorCodeInvalidResponse, Cause: err} } result, err := ParseQwen3Guard(content, enabledScanners) if err != nil { return nil, err } result.GuardEndpointID = endpoint.ID result.ScannerVersion = endpoint.Model return result, nil } func (s *OpenAICompatibleScanner) clientFor(endpoint ActiveEndpoint) (*http.Client, error) { key := fmt.Sprintf("%s|%s|%d", endpoint.ID, endpoint.BaseURL, endpoint.TimeoutMS) if cached, ok := s.clients.Load(key); ok { client, valid := cached.(*http.Client) if !valid { s.clients.Delete(key) return nil, errors.New("prompt guard client cache invalid") } return client, nil } client, err := NewSecureHTTPClient(endpoint) if err != nil { return nil, err } actual, _ := s.clients.LoadOrStore(key, client) actualClient, ok := actual.(*http.Client) if !ok { s.clients.Delete(key) return nil, errors.New("prompt guard client cache invalid") } return actualClient, nil } func extractOpenAIContent(body []byte) (string, error) { var response struct { Choices []struct { Message struct { Content any `json:"content"` } `json:"message"` } `json:"choices"` } if err := json.Unmarshal(body, &response); err != nil || len(response.Choices) == 0 { return "", errors.New("prompt guard response envelope invalid") } content := response.Choices[0].Message.Content switch typed := content.(type) { case string: if strings.TrimSpace(typed) == "" { return "", errors.New("prompt guard response content empty") } return typed, nil case []any: parts := make([]string, 0, len(typed)) for _, item := range typed { object, ok := item.(map[string]any) if !ok { continue } if text, ok := object["text"].(string); ok && strings.TrimSpace(text) != "" { parts = append(parts, text) } } if len(parts) == 0 { return "", errors.New("prompt guard response content empty") } return strings.Join(parts, "\n"), nil default: return "", errors.New("prompt guard response content invalid") } } func ScannerDefinitions() []ScannerDefinition { result := make([]ScannerDefinition, 0, len(AllScannerIDs)) for _, id := range AllScannerIDs { result = append(result, ScannerCatalog[id]) } sort.SliceStable(result, func(i, j int) bool { return i < j }) return result }