security_audit_helper_test.go 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. package handler
  2. import (
  3. "context"
  4. "net/http"
  5. "net/http/httptest"
  6. "sync/atomic"
  7. "testing"
  8. "github.com/Wei-Shaw/sub2api/internal/securityaudit"
  9. middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  10. "github.com/gin-gonic/gin"
  11. "github.com/stretchr/testify/require"
  12. "go.uber.org/zap"
  13. "go.uber.org/zap/zaptest/observer"
  14. )
  15. func TestCachesSecurityAuditCompletionSkipsWebSocketStages(t *testing.T) {
  16. require.True(t, cachesSecurityAuditCompletion("http"))
  17. require.True(t, cachesSecurityAuditCompletion(""))
  18. require.False(t, cachesSecurityAuditCompletion("first_turn"))
  19. require.False(t, cachesSecurityAuditCompletion("subsequent_turn"))
  20. require.True(t, isSecurityAuditWebSocketStage("first_turn"))
  21. require.True(t, isSecurityAuditWebSocketStage("subsequent_turn"))
  22. require.False(t, isSecurityAuditWebSocketStage("http"))
  23. }
  24. func TestRunSecurityAuditDoesNotSkipSubsequentWebSocketTurns(t *testing.T) {
  25. gin.SetMode(gin.TestMode)
  26. engine := &turnCountingEngine{mode: securityaudit.ModeAsync}
  27. coordinator := securityaudit.NewCoordinator(nil, engine)
  28. recorder := httptest.NewRecorder()
  29. c, _ := gin.CreateTestContext(recorder)
  30. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  31. subject := middleware2.AuthSubject{UserID: 7, Concurrency: 1}
  32. first := runSecurityAudit(c, nil, coordinator, nil, nil, subject, "openai_responses", "gpt-test",
  33. []byte(`{"type":"response.create","response":{"input":"benign"}}`), "first_turn")
  34. require.NotNil(t, first)
  35. require.True(t, first.AllowNextStage)
  36. require.Equal(t, int64(1), engine.enqueues.Load())
  37. _, cached := c.Get(securityAuditCompletedContextKey)
  38. require.False(t, cached, "WebSocket stages must not set the HTTP completion cache")
  39. // Even if an HTTP path previously cached completion on this Context, WS turns
  40. // must still audit every response.create payload.
  41. c.Set(securityAuditCompletedContextKey, true)
  42. second := runSecurityAudit(c, nil, coordinator, nil, nil, subject, "openai_responses", "gpt-test",
  43. []byte(`{"type":"response.create","response":{"input":"malicious follow-up"}}`), "subsequent_turn")
  44. require.NotNil(t, second)
  45. require.Equal(t, int64(2), engine.enqueues.Load(), "subsequent WebSocket turns must be audited again")
  46. }
  47. func TestRunSecurityAuditDeduplicatesRepeatedPayloadWithinWebSocketTurn(t *testing.T) {
  48. gin.SetMode(gin.TestMode)
  49. engine := &turnCountingEngine{mode: securityaudit.ModeBlocking}
  50. coordinator := securityaudit.NewCoordinator(nil, engine)
  51. recorder := httptest.NewRecorder()
  52. c, _ := gin.CreateTestContext(recorder)
  53. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  54. payload := []byte(`{"type":"response.create","response":{"input":"same turn"}}`)
  55. c.Set(securityAuditWSTurnContextKey, 2)
  56. first := runSecurityAudit(c, nil, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn")
  57. second := runSecurityAudit(c, nil, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn")
  58. require.NotNil(t, first)
  59. require.NotNil(t, second)
  60. require.True(t, first.AllowNextStage)
  61. require.True(t, second.AllowNextStage)
  62. require.Equal(t, int64(1), engine.evaluates.Load())
  63. // The cache holds only one successful same-turn result.
  64. entry, exists := c.Get(securityAuditWSDedupeContextKey)
  65. require.True(t, exists)
  66. require.IsType(t, securityAuditWSDedupeEntry{}, entry)
  67. c.Set(securityAuditWSTurnContextKey, 3)
  68. runSecurityAudit(c, nil, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn")
  69. require.Equal(t, int64(2), engine.evaluates.Load())
  70. }
  71. func TestRunSecurityAuditDoesNotCacheFailedWebSocketDecision(t *testing.T) {
  72. gin.SetMode(gin.TestMode)
  73. engine := &turnCountingEngine{
  74. mode: securityaudit.ModeBlocking,
  75. decisions: []*securityaudit.PromptDecision{
  76. {Kind: securityaudit.DecisionUnavailable, AllowNextStage: false},
  77. {Kind: securityaudit.DecisionAllow, AllowNextStage: true},
  78. },
  79. }
  80. coordinator := securityaudit.NewCoordinator(nil, engine)
  81. recorder := httptest.NewRecorder()
  82. c, _ := gin.CreateTestContext(recorder)
  83. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  84. c.Set(securityAuditWSTurnContextKey, 2)
  85. payload := []byte(`{"type":"response.create","response":{"input":"retry me"}}`)
  86. first := runSecurityAudit(c, nil, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn")
  87. _, cachedAfterFailure := c.Get(securityAuditWSDedupeContextKey)
  88. second := runSecurityAudit(c, nil, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn")
  89. require.False(t, first.AllowNextStage)
  90. require.False(t, cachedAfterFailure)
  91. require.True(t, second.AllowNextStage)
  92. require.Equal(t, int64(2), engine.evaluates.Load())
  93. }
  94. func TestRunSecurityAuditDoesNotCacheFlaggedWebSocketDecision(t *testing.T) {
  95. gin.SetMode(gin.TestMode)
  96. engine := &turnCountingEngine{
  97. mode: securityaudit.ModeBlocking,
  98. decisions: []*securityaudit.PromptDecision{
  99. {Kind: securityaudit.DecisionFlag, AllowNextStage: true},
  100. {Kind: securityaudit.DecisionAllow, AllowNextStage: true},
  101. },
  102. }
  103. coordinator := securityaudit.NewCoordinator(nil, engine)
  104. recorder := httptest.NewRecorder()
  105. c, _ := gin.CreateTestContext(recorder)
  106. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  107. c.Set(securityAuditWSTurnContextKey, 2)
  108. payload := []byte(`{"type":"response.create","response":{"input":"retry flagged"}}`)
  109. first := runSecurityAudit(c, nil, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn")
  110. _, cachedAfterFlag := c.Get(securityAuditWSDedupeContextKey)
  111. second := runSecurityAudit(c, nil, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn")
  112. require.Equal(t, securityaudit.DecisionFlag, first.Kind)
  113. require.True(t, first.AllowNextStage)
  114. require.False(t, cachedAfterFlag)
  115. require.Equal(t, securityaudit.DecisionAllow, second.Kind)
  116. require.Equal(t, int64(2), engine.evaluates.Load())
  117. }
  118. func TestRunSecurityAuditLogsWebSocketChecksAndCacheHits(t *testing.T) {
  119. gin.SetMode(gin.TestMode)
  120. engine := &turnCountingEngine{mode: securityaudit.ModeBlocking}
  121. coordinator := securityaudit.NewCoordinator(nil, engine)
  122. core, logs := observer.New(zap.InfoLevel)
  123. reqLog := zap.New(core)
  124. recorder := httptest.NewRecorder()
  125. c, _ := gin.CreateTestContext(recorder)
  126. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  127. c.Set(securityAuditWSTurnContextKey, 2)
  128. payload := []byte(`{"type":"response.create","response":{"input":"same turn"}}`)
  129. runSecurityAudit(c, reqLog, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn")
  130. runSecurityAudit(c, reqLog, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn")
  131. startLogs := logs.FilterMessage("security_audit.gateway_check_start").All()
  132. require.Len(t, startLogs, 1)
  133. require.Equal(t, false, startLogs[0].ContextMap()["cached"])
  134. doneLogs := logs.FilterMessage("security_audit.gateway_check_done").All()
  135. require.Len(t, doneLogs, 2)
  136. require.Equal(t, false, doneLogs[0].ContextMap()["cached"])
  137. require.Equal(t, true, doneLogs[1].ContextMap()["cached"])
  138. require.Equal(t, "allow", doneLogs[1].ContextMap()["decision"])
  139. require.Equal(t, "subsequent_turn", doneLogs[1].ContextMap()["stage"])
  140. require.Equal(t, int64(1), engine.evaluates.Load())
  141. }
  142. type turnCountingEngine struct {
  143. mode securityaudit.Mode
  144. enqueues atomic.Int64
  145. evaluates atomic.Int64
  146. decisions []*securityaudit.PromptDecision
  147. }
  148. func (e *turnCountingEngine) EffectiveMode() securityaudit.Mode { return e.mode }
  149. func (e *turnCountingEngine) Enqueue(context.Context, securityaudit.Request) error {
  150. e.enqueues.Add(1)
  151. return nil
  152. }
  153. func (e *turnCountingEngine) Evaluate(context.Context, securityaudit.Request) (*securityaudit.PromptDecision, error) {
  154. call := e.evaluates.Add(1)
  155. if int(call) <= len(e.decisions) {
  156. return e.decisions[call-1], nil
  157. }
  158. return &securityaudit.PromptDecision{Kind: securityaudit.DecisionAllow, AllowNextStage: true}, nil
  159. }