prompt_qwen3guard.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. package securityaudit
  2. import (
  3. "bytes"
  4. "context"
  5. "crypto/sha256"
  6. "encoding/json"
  7. "errors"
  8. "fmt"
  9. "io"
  10. "net"
  11. "net/http"
  12. "sort"
  13. "strings"
  14. "sync"
  15. )
  16. type ScannerDefinition struct {
  17. ID string `json:"id"`
  18. Label string `json:"label"`
  19. LabelZH string `json:"label_zh"`
  20. Description string `json:"description"`
  21. }
  22. var AllScannerIDs = []string{
  23. "violent",
  24. "non_violent_illegal_acts",
  25. "sexual_content_or_sexual_acts",
  26. "pii",
  27. "suicide_and_self_harm",
  28. "unethical_acts",
  29. "politically_sensitive_topics",
  30. "copyright_violation",
  31. "jailbreak",
  32. }
  33. var ScannerCatalog = map[string]ScannerDefinition{
  34. "violent": {ID: "violent", Label: "Violent", LabelZH: "暴力", Description: "Violence or threats of violence"},
  35. "non_violent_illegal_acts": {ID: "non_violent_illegal_acts", Label: "Non-violent Illegal Acts", LabelZH: "非暴力违法行为", Description: "Non-violent illegal activity"},
  36. "sexual_content_or_sexual_acts": {ID: "sexual_content_or_sexual_acts", Label: "Sexual Content or Sexual Acts", LabelZH: "性内容或性行为", Description: "Sexual content or sexual acts"},
  37. "pii": {ID: "pii", Label: "PII", LabelZH: "个人敏感信息", Description: "Personal identifying information"},
  38. "suicide_and_self_harm": {ID: "suicide_and_self_harm", Label: "Suicide & Self-Harm", LabelZH: "自杀与自残", Description: "Suicide or self-harm"},
  39. "unethical_acts": {ID: "unethical_acts", Label: "Unethical Acts", LabelZH: "不道德行为", Description: "Unethical behavior"},
  40. "politically_sensitive_topics": {ID: "politically_sensitive_topics", Label: "Politically Sensitive Topics", LabelZH: "政治敏感话题", Description: "Politically sensitive topics"},
  41. "copyright_violation": {ID: "copyright_violation", Label: "Copyright Violation", LabelZH: "版权侵权", Description: "Copyright infringement"},
  42. "jailbreak": {ID: "jailbreak", Label: "Jailbreak", LabelZH: "越狱攻击", Description: "Prompt injection or jailbreak attempt"},
  43. }
  44. var categoryAliases = map[string]string{
  45. "violent": "violent", "violence": "violent",
  46. "non violent illegal acts": "non_violent_illegal_acts", "non-violent illegal acts": "non_violent_illegal_acts",
  47. "sexual content or sexual acts": "sexual_content_or_sexual_acts", "sexual": "sexual_content_or_sexual_acts",
  48. "pii": "pii", "personal identifying information": "pii", "personal identifiable information": "pii",
  49. "suicide self harm": "suicide_and_self_harm", "suicide and self harm": "suicide_and_self_harm", "suicide & self-harm": "suicide_and_self_harm",
  50. "unethical acts": "unethical_acts", "unethical": "unethical_acts",
  51. "politically sensitive topics": "politically_sensitive_topics", "political": "politically_sensitive_topics",
  52. "copyright violation": "copyright_violation", "copyright": "copyright_violation",
  53. "jailbreak": "jailbreak", "prompt injection": "jailbreak",
  54. }
  55. type GuardError struct {
  56. Code string
  57. HTTPStatus int
  58. Retryable bool
  59. Timeout bool
  60. Cause error
  61. }
  62. func (e *GuardError) Error() string {
  63. if e == nil {
  64. return "<nil>"
  65. }
  66. return e.Code
  67. }
  68. func (e *GuardError) Unwrap() error { return e.Cause }
  69. func NormalizeCategory(value string) string {
  70. normalized := strings.ToLower(strings.TrimSpace(value))
  71. normalized = strings.NewReplacer("_", " ", "&", " and ", "/", " ", "-", " ", "–", " ", "—", " ").Replace(normalized)
  72. normalized = strings.Join(strings.Fields(normalized), " ")
  73. if canonical, ok := categoryAliases[normalized]; ok {
  74. return canonical
  75. }
  76. return strings.ReplaceAll(normalized, " ", "_")
  77. }
  78. func ParseQwen3Guard(content string, enabledScanners []string) (*NormalizedResult, error) {
  79. var safety string
  80. var categoryLine string
  81. for _, line := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") {
  82. line = strings.TrimSpace(line)
  83. if line == "" {
  84. continue
  85. }
  86. lower := strings.ToLower(line)
  87. switch {
  88. case strings.HasPrefix(lower, "safety:"):
  89. if safety != "" {
  90. return nil, &GuardError{Code: ErrorCodeInvalidResponse}
  91. }
  92. safety = strings.TrimSpace(line[len("safety:"):])
  93. case strings.HasPrefix(lower, "categories:"):
  94. if categoryLine != "" {
  95. return nil, &GuardError{Code: ErrorCodeInvalidResponse}
  96. }
  97. categoryLine = strings.TrimSpace(line[len("categories:"):])
  98. default:
  99. // Auxiliary Guard fields, such as Refusal, do not affect audit decisions.
  100. }
  101. }
  102. switch strings.ToLower(safety) {
  103. case "safe":
  104. safety = "Safe"
  105. case "controversial":
  106. safety = "Controversial"
  107. case "unsafe":
  108. safety = "Unsafe"
  109. default:
  110. return nil, &GuardError{Code: ErrorCodeInvalidResponse}
  111. }
  112. if categoryLine == "" {
  113. return nil, &GuardError{Code: ErrorCodeInvalidResponse}
  114. }
  115. enabled := make(map[string]struct{}, len(enabledScanners))
  116. for _, scanner := range enabledScanners {
  117. enabled[NormalizeCategory(scanner)] = struct{}{}
  118. }
  119. known := map[string]struct{}{}
  120. unknown := map[string]struct{}{}
  121. for _, raw := range strings.Split(categoryLine, ",") {
  122. raw = strings.TrimSpace(raw)
  123. if raw == "" || strings.EqualFold(raw, "none") || strings.EqualFold(raw, "n/a") {
  124. continue
  125. }
  126. category := NormalizeCategory(raw)
  127. if _, ok := ScannerCatalog[category]; ok {
  128. known[category] = struct{}{}
  129. } else {
  130. unknown[unknownCategoryID(category)] = struct{}{}
  131. }
  132. }
  133. knownList := orderedScannerKeys(known)
  134. unknownList := sortedKeys(unknown)
  135. matched := make([]string, 0, len(knownList))
  136. for _, category := range knownList {
  137. if _, ok := enabled[category]; ok {
  138. matched = append(matched, category)
  139. }
  140. }
  141. result := &NormalizedResult{
  142. Safety: safety, Categories: knownList, MatchedScanners: matched, UnknownCategories: unknownList,
  143. ScannerScores: map[string]float64{}, ScannerEvidence: map[string]string{},
  144. ScannerBackend: "qwen3guard-openai", ScannerVersion: "qwen3guard",
  145. PolicyID: "priority", PolicyVersion: 1,
  146. Decision: EventPass, RiskLevel: RiskLow, Action: ActionAllow,
  147. }
  148. score := 0.0
  149. if safety == "Controversial" {
  150. score = 0.5
  151. result.Decision, result.RiskLevel, result.Action = EventFlag, RiskMedium, ActionWarn
  152. }
  153. if safety == "Unsafe" {
  154. score = 1
  155. if len(matched) > 0 || len(unknownList) > 0 || len(knownList) == 0 {
  156. result.Decision, result.RiskLevel, result.Action = EventCritical, RiskCritical, ActionBlock
  157. } else {
  158. result.Decision, result.RiskLevel, result.Action = EventFlag, RiskHigh, ActionWarn
  159. }
  160. }
  161. for _, category := range matched {
  162. result.ScannerScores[category] = score
  163. result.ScannerEvidence[category] = ScannerCatalog[category].Label
  164. if safety == "Controversial" && isElevatedControversial(category) {
  165. result.Decision, result.RiskLevel, result.Action = EventCritical, RiskCritical, ActionBlock
  166. }
  167. }
  168. return result, nil
  169. }
  170. func unknownCategoryID(value string) string {
  171. digest := sha256.Sum256([]byte(strings.TrimSpace(strings.ToLower(value))))
  172. return fmt.Sprintf("unknown:%x", digest[:8])
  173. }
  174. func isElevatedControversial(category string) bool {
  175. return category == "jailbreak" || category == "pii" || category == "suicide_and_self_harm"
  176. }
  177. type OpenAICompatibleScanner struct {
  178. clients sync.Map
  179. }
  180. func NewOpenAICompatibleScanner() *OpenAICompatibleScanner { return &OpenAICompatibleScanner{} }
  181. func (s *OpenAICompatibleScanner) Scan(ctx context.Context, endpoint ActiveEndpoint, chunk string, enabledScanners []string) (*NormalizedResult, error) {
  182. client, err := s.clientFor(endpoint)
  183. if err != nil {
  184. return nil, &GuardError{Code: ErrorCodeUnavailable, Cause: err}
  185. }
  186. requestURL, err := ChatCompletionsURL(endpoint.BaseURL)
  187. if err != nil {
  188. return nil, &GuardError{Code: ErrorCodeUnavailable, Cause: err}
  189. }
  190. payload := map[string]any{
  191. "model": endpoint.Model,
  192. "messages": []map[string]string{{"role": "user", "content": chunk}},
  193. "temperature": 0,
  194. "max_tokens": 64,
  195. "seed": 42,
  196. }
  197. body, err := json.Marshal(payload)
  198. if err != nil {
  199. return nil, &GuardError{Code: ErrorCodeInvalidResponse, Cause: err}
  200. }
  201. req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewReader(body))
  202. if err != nil {
  203. return nil, &GuardError{Code: ErrorCodeUnavailable, Cause: err}
  204. }
  205. req.Header.Set("Content-Type", "application/json")
  206. if endpoint.Token != "" {
  207. req.Header.Set("Authorization", "Bearer "+endpoint.Token)
  208. }
  209. resp, err := client.Do(req)
  210. if err != nil {
  211. timeout := errors.Is(err, context.DeadlineExceeded)
  212. var netErr net.Error
  213. if errors.As(err, &netErr) && netErr.Timeout() {
  214. timeout = true
  215. }
  216. return nil, &GuardError{Code: ErrorCodeUnavailable, Retryable: true, Timeout: timeout, Cause: err}
  217. }
  218. defer func() { _ = resp.Body.Close() }()
  219. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  220. retryable := resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500
  221. return nil, &GuardError{Code: ErrorCodeUnavailable, HTTPStatus: resp.StatusCode, Retryable: retryable}
  222. }
  223. limited := io.LimitReader(resp.Body, maxGuardResponseBytes+1)
  224. responseBody, err := io.ReadAll(limited)
  225. if err != nil {
  226. return nil, &GuardError{Code: ErrorCodeUnavailable, Retryable: true, Cause: err}
  227. }
  228. if int64(len(responseBody)) > maxGuardResponseBytes {
  229. return nil, &GuardError{Code: ErrorCodeInvalidResponse}
  230. }
  231. content, err := extractOpenAIContent(responseBody)
  232. if err != nil {
  233. return nil, &GuardError{Code: ErrorCodeInvalidResponse, Cause: err}
  234. }
  235. result, err := ParseQwen3Guard(content, enabledScanners)
  236. if err != nil {
  237. return nil, err
  238. }
  239. result.GuardEndpointID = endpoint.ID
  240. result.ScannerVersion = endpoint.Model
  241. return result, nil
  242. }
  243. func (s *OpenAICompatibleScanner) clientFor(endpoint ActiveEndpoint) (*http.Client, error) {
  244. key := fmt.Sprintf("%s|%s|%d", endpoint.ID, endpoint.BaseURL, endpoint.TimeoutMS)
  245. if cached, ok := s.clients.Load(key); ok {
  246. client, valid := cached.(*http.Client)
  247. if !valid {
  248. s.clients.Delete(key)
  249. return nil, errors.New("prompt guard client cache invalid")
  250. }
  251. return client, nil
  252. }
  253. client, err := NewSecureHTTPClient(endpoint)
  254. if err != nil {
  255. return nil, err
  256. }
  257. actual, _ := s.clients.LoadOrStore(key, client)
  258. actualClient, ok := actual.(*http.Client)
  259. if !ok {
  260. s.clients.Delete(key)
  261. return nil, errors.New("prompt guard client cache invalid")
  262. }
  263. return actualClient, nil
  264. }
  265. func extractOpenAIContent(body []byte) (string, error) {
  266. var response struct {
  267. Choices []struct {
  268. Message struct {
  269. Content any `json:"content"`
  270. } `json:"message"`
  271. } `json:"choices"`
  272. }
  273. if err := json.Unmarshal(body, &response); err != nil || len(response.Choices) == 0 {
  274. return "", errors.New("prompt guard response envelope invalid")
  275. }
  276. content := response.Choices[0].Message.Content
  277. switch typed := content.(type) {
  278. case string:
  279. if strings.TrimSpace(typed) == "" {
  280. return "", errors.New("prompt guard response content empty")
  281. }
  282. return typed, nil
  283. case []any:
  284. parts := make([]string, 0, len(typed))
  285. for _, item := range typed {
  286. object, ok := item.(map[string]any)
  287. if !ok {
  288. continue
  289. }
  290. if text, ok := object["text"].(string); ok && strings.TrimSpace(text) != "" {
  291. parts = append(parts, text)
  292. }
  293. }
  294. if len(parts) == 0 {
  295. return "", errors.New("prompt guard response content empty")
  296. }
  297. return strings.Join(parts, "\n"), nil
  298. default:
  299. return "", errors.New("prompt guard response content invalid")
  300. }
  301. }
  302. func ScannerDefinitions() []ScannerDefinition {
  303. result := make([]ScannerDefinition, 0, len(AllScannerIDs))
  304. for _, id := range AllScannerIDs {
  305. result = append(result, ScannerCatalog[id])
  306. }
  307. sort.SliceStable(result, func(i, j int) bool { return i < j })
  308. return result
  309. }