prompt_service.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  1. package securityaudit
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "io"
  7. "net"
  8. "net/http"
  9. "strings"
  10. "sync"
  11. "time"
  12. )
  13. type PromptService struct {
  14. config ConfigStore
  15. repo *PostgreSQLRepository
  16. payload *RedisPayloadStore
  17. enqueuer *Enqueuer
  18. runner *Runner
  19. evaluator *GuardEvaluator
  20. scanner *OpenAICompatibleScanner
  21. metrics *AtomicMetrics
  22. clock Clock
  23. lifecycleMu sync.Mutex
  24. cancel context.CancelFunc
  25. background context.Context
  26. enqueueWG sync.WaitGroup
  27. enqueueSlots chan struct{}
  28. probeMu sync.RWMutex
  29. probes map[string]ProbeResult
  30. }
  31. func NewPromptService(
  32. config ConfigStore,
  33. repo *PostgreSQLRepository,
  34. payload *RedisPayloadStore,
  35. scanner *OpenAICompatibleScanner,
  36. metrics *AtomicMetrics,
  37. ) *PromptService {
  38. enqueuer := NewEnqueuer(config, repo, payload, metrics)
  39. evaluator := NewGuardEvaluator(scanner, repo, metrics)
  40. runner := NewRunner(config, repo, payload, scanner, metrics)
  41. return &PromptService{
  42. config: config, repo: repo, payload: payload, scanner: scanner, metrics: metrics,
  43. enqueuer: enqueuer, evaluator: evaluator, runner: runner, clock: realClock{},
  44. enqueueSlots: make(chan struct{}, 128), probes: map[string]ProbeResult{},
  45. }
  46. }
  47. func (s *PromptService) Start(ctx context.Context) error {
  48. if s == nil || s.config == nil || s.runner == nil {
  49. return errors.New("prompt audit service unavailable")
  50. }
  51. s.lifecycleMu.Lock()
  52. if s.cancel != nil {
  53. s.lifecycleMu.Unlock()
  54. return nil
  55. }
  56. background, cancel := context.WithCancel(ctx)
  57. s.background, s.cancel = background, cancel
  58. s.lifecycleMu.Unlock()
  59. configErr := s.config.Start(background)
  60. workerErr := s.runner.Start(background)
  61. return errors.Join(configErr, workerErr)
  62. }
  63. func (s *PromptService) Shutdown(ctx context.Context) error {
  64. if s == nil {
  65. return nil
  66. }
  67. s.lifecycleMu.Lock()
  68. cancel := s.cancel
  69. s.cancel = nil
  70. s.lifecycleMu.Unlock()
  71. if cancel != nil {
  72. cancel()
  73. }
  74. var workerErr error
  75. if s.runner != nil {
  76. workerErr = s.runner.Shutdown(ctx)
  77. }
  78. done := make(chan struct{})
  79. go func() { s.enqueueWG.Wait(); close(done) }()
  80. select {
  81. case <-done:
  82. case <-ctx.Done():
  83. if workerErr == nil {
  84. workerErr = ctx.Err()
  85. }
  86. }
  87. var configErr error
  88. if s.config != nil {
  89. configErr = s.config.Shutdown(ctx)
  90. }
  91. if workerErr != nil {
  92. return workerErr
  93. }
  94. return configErr
  95. }
  96. func (s *PromptService) EffectiveMode() Mode {
  97. if s == nil || s.config == nil {
  98. return ModeOff
  99. }
  100. return s.config.EffectiveMode()
  101. }
  102. func (s *PromptService) Enqueue(_ context.Context, req Request) error {
  103. if s == nil || s.enqueuer == nil || s.EffectiveMode() != ModeAsync {
  104. return nil
  105. }
  106. select {
  107. case s.enqueueSlots <- struct{}{}:
  108. default:
  109. if s.metrics != nil {
  110. s.metrics.IncDropped()
  111. }
  112. LogWarn(EventEnqueueDropped, map[string]any{"request_id": req.RequestID, "status": "dropped", "error_code": "local_enqueue_busy"})
  113. return nil
  114. }
  115. s.lifecycleMu.Lock()
  116. background := s.background
  117. s.lifecycleMu.Unlock()
  118. if background == nil {
  119. <-s.enqueueSlots
  120. return errors.New("prompt audit service not started")
  121. }
  122. requestCopy := req.Clone()
  123. s.enqueueWG.Add(1)
  124. go func() {
  125. defer s.enqueueWG.Done()
  126. defer func() { <-s.enqueueSlots }()
  127. ctx, cancel := context.WithTimeout(background, 2*time.Second)
  128. defer cancel()
  129. _ = s.enqueuer.Enqueue(ctx, requestCopy)
  130. }()
  131. return nil
  132. }
  133. func (s *PromptService) Evaluate(ctx context.Context, req Request) (*PromptDecision, error) {
  134. if s == nil || s.config == nil || s.evaluator == nil {
  135. return nil, &GuardError{Code: ErrorCodeUnavailable}
  136. }
  137. if s.config.BlockingActivationDegraded() {
  138. return nil, &GuardError{Code: ErrorCodeUnavailable}
  139. }
  140. cfg, ok := s.config.Active()
  141. if !ok {
  142. if s.config.EffectiveMode() == ModeBlocking {
  143. return nil, &GuardError{Code: ErrorCodeUnavailable}
  144. }
  145. return &PromptDecision{Kind: DecisionAllow, AllowNextStage: true}, nil
  146. }
  147. if cfg.EffectiveMode() != ModeBlocking || !cfg.IncludesGroup(req.GroupID) {
  148. return &PromptDecision{Kind: DecisionAllow, AllowNextStage: true}, nil
  149. }
  150. snapshot, err := ExtractBlockingPromptSnapshot(req, cfg.BlockingLatestTurnOnly)
  151. if errors.Is(err, ErrNoPromptText) {
  152. return &PromptDecision{Kind: DecisionAllow, AllowNextStage: true}, nil
  153. }
  154. if err != nil {
  155. return nil, &GuardError{Code: ErrorCodeInvalidResponse, Cause: err}
  156. }
  157. return s.evaluator.Evaluate(ctx, cfg, snapshot)
  158. }
  159. func (s *PromptService) GetConfig() (PublicConfig, error) { return s.config.Public() }
  160. func (s *PromptService) SaveConfig(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) {
  161. return s.config.Save(ctx, req, actorID)
  162. }
  163. func (s *PromptService) Runtime(ctx context.Context) RuntimeSnapshot {
  164. expected, activeVersion, loadedAt, loadError := s.config.RuntimeState()
  165. cfg, hasConfig := s.config.Active()
  166. mode := s.EffectiveMode()
  167. workerTotal, queueCapacity := 0, 0
  168. if hasConfig {
  169. workerTotal, queueCapacity = cfg.WorkerCount, cfg.QueueCapacity
  170. }
  171. runtime := RuntimeSnapshot{
  172. ProcessStatus: "disabled", EffectiveMode: mode, ExpectedConfigVersion: expected,
  173. ActiveConfigVersion: activeVersion, ConfigLoadedAt: loadedAt, ConfigLoadError: loadError,
  174. WorkerTotal: workerTotal, QueueCapacity: queueCapacity, DatabaseStatus: "ok", RedisStatus: "ok",
  175. Endpoints: s.probeSnapshot(), GuardMetrics: s.metrics.Snapshot(),
  176. }
  177. if s.repo != nil {
  178. stats, err := s.repo.QueueStats(ctx)
  179. if err != nil {
  180. runtime.DatabaseStatus = "error"
  181. runtime.LastErrorCode = "database_unavailable"
  182. } else {
  183. runtime.Queue = stats
  184. }
  185. } else {
  186. runtime.DatabaseStatus = "error"
  187. }
  188. if s.payload == nil || s.payload.Ping(ctx) != nil {
  189. runtime.RedisStatus = "error"
  190. if runtime.LastErrorCode == "" {
  191. runtime.LastErrorCode = "payload_store_unavailable"
  192. }
  193. }
  194. activeWorkers, processed, failed, heartbeat, lastProcessed, workerCode, workerMessage := s.runner.Snapshot()
  195. runtime.WorkerActive, runtime.ProcessedTotal, runtime.FailedTotal = activeWorkers, processed, failed
  196. if s.metrics != nil {
  197. auditMetrics := s.metrics.AuditSnapshot()
  198. runtime.EnqueuedTotal, runtime.DroppedTotal = auditMetrics.Enqueued, auditMetrics.Dropped
  199. }
  200. runtime.WorkerHeartbeatAt, runtime.LastProcessedAt = heartbeat, lastProcessed
  201. if workerCode != "" {
  202. runtime.LastErrorCode, runtime.LastErrorMessage = workerCode, workerMessage
  203. }
  204. if mode != ModeOff {
  205. runtime.ProcessStatus = "running"
  206. if loadError != "" || runtime.DatabaseStatus != "ok" || runtime.RedisStatus != "ok" || activeVersion != expected {
  207. runtime.ProcessStatus = "degraded"
  208. }
  209. if heartbeat == nil || s.clock.Now().Sub(*heartbeat) > 10*time.Second {
  210. runtime.ProcessStatus = "degraded"
  211. }
  212. }
  213. return runtime
  214. }
  215. type ProbeRequest struct {
  216. Endpoint UpdateEndpoint `json:"endpoint"`
  217. }
  218. func (s *PromptService) Probe(ctx context.Context, request ProbeRequest) ProbeResult {
  219. started := s.clock.Now()
  220. endpoint, tokenApplied, err := s.resolveProbeEndpoint(request.Endpoint)
  221. if err != nil {
  222. return s.finishProbe(request.Endpoint.ID, started, ProbeResult{Status: "failed", ErrorCode: "endpoint_invalid", Message: "审计节点配置无效"})
  223. }
  224. LogInfo(EventProbeStarted, map[string]any{"guard_endpoint_id": endpoint.ID, "status": "started"})
  225. client, err := NewSecureHTTPClient(endpoint)
  226. if err != nil {
  227. return s.finishProbe(endpoint.ID, started, ProbeResult{Status: "failed", ErrorCode: "endpoint_unsafe", Message: "审计节点地址不在允许范围", TokenApplied: tokenApplied})
  228. }
  229. modelsURL, _ := ModelsURL(endpoint.BaseURL)
  230. req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsURL, nil)
  231. if err != nil {
  232. return s.finishProbe(endpoint.ID, started, ProbeResult{Status: "failed", ErrorCode: "probe_request_invalid", Message: "无法创建探测请求", TokenApplied: tokenApplied})
  233. }
  234. if endpoint.Token != "" {
  235. req.Header.Set("Authorization", "Bearer "+endpoint.Token)
  236. }
  237. resp, err := client.Do(req)
  238. if err != nil {
  239. code := "connection_failed"
  240. var netErr net.Error
  241. if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout()) {
  242. code = "timeout"
  243. }
  244. return s.finishProbe(endpoint.ID, started, ProbeResult{Status: "failed", ErrorCode: code, Message: "无法连接审计节点", Retryable: true, TokenApplied: tokenApplied})
  245. }
  246. responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, maxGuardResponseBytes+1))
  247. _ = resp.Body.Close()
  248. if readErr != nil {
  249. return s.finishProbe(endpoint.ID, started, ProbeResult{Status: "failed", ErrorCode: "response_read_failed", Message: "审计节点响应读取失败", HTTPStatus: resp.StatusCode, Retryable: true, TokenApplied: tokenApplied})
  250. }
  251. if int64(len(responseBody)) > maxGuardResponseBytes {
  252. return s.finishProbe(endpoint.ID, started, ProbeResult{Status: "failed", ErrorCode: "response_too_large", Message: "审计节点响应无效", HTTPStatus: resp.StatusCode, TokenApplied: tokenApplied})
  253. }
  254. if resp.StatusCode >= 200 && resp.StatusCode < 300 && modelsResponseReady(responseBody, endpoint.Model) {
  255. return s.finishProbe(endpoint.ID, started, ProbeResult{OK: true, Status: "healthy", Message: "审计节点连接正常", HTTPStatus: resp.StatusCode, TokenApplied: tokenApplied})
  256. }
  257. if (resp.StatusCode >= 200 && resp.StatusCode < 300) || resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusMethodNotAllowed {
  258. result, scanErr := s.scanner.Scan(ctx, endpoint, "Hello", AllScannerIDs)
  259. if scanErr == nil && result != nil {
  260. return s.finishProbe(endpoint.ID, started, ProbeResult{OK: true, Status: "healthy", Message: "审计节点模型调用正常", HTTPStatus: http.StatusOK, TokenApplied: tokenApplied})
  261. }
  262. code, status, retryable := guardErrorCode(scanErr), 0, false
  263. var guardErr *GuardError
  264. if errors.As(scanErr, &guardErr) {
  265. status, retryable = guardErr.HTTPStatus, guardErr.Retryable
  266. }
  267. if code == "" {
  268. code = ErrorCodeInvalidResponse
  269. }
  270. return s.finishProbe(endpoint.ID, started, ProbeResult{Status: "failed", ErrorCode: code, Message: "审计节点模型调用失败", HTTPStatus: status, Retryable: retryable, TokenApplied: tokenApplied})
  271. }
  272. code, retryable := "probe_http_error", resp.StatusCode == 429 || resp.StatusCode >= 500
  273. if resp.StatusCode == 401 || resp.StatusCode == 403 {
  274. code = "authentication_failed"
  275. }
  276. return s.finishProbe(endpoint.ID, started, ProbeResult{Status: "failed", ErrorCode: code, Message: "审计节点探测失败", HTTPStatus: resp.StatusCode, Retryable: retryable, TokenApplied: tokenApplied})
  277. }
  278. func modelsResponseReady(body []byte, model string) bool {
  279. var response struct {
  280. Data []struct {
  281. ID string `json:"id"`
  282. } `json:"data"`
  283. }
  284. if json.Unmarshal(body, &response) != nil || response.Data == nil {
  285. return false
  286. }
  287. model = strings.TrimSpace(model)
  288. if model == "" {
  289. return true
  290. }
  291. for _, item := range response.Data {
  292. if strings.TrimSpace(item.ID) == model {
  293. return true
  294. }
  295. }
  296. return false
  297. }
  298. func (s *PromptService) resolveProbeEndpoint(input UpdateEndpoint) (ActiveEndpoint, bool, error) {
  299. baseURL, err := NormalizeBaseURL(input.BaseURL)
  300. if err != nil {
  301. return ActiveEndpoint{}, false, err
  302. }
  303. token := strings.TrimSpace(input.Token)
  304. if token == "" {
  305. if cfg, ok := s.config.Active(); ok {
  306. for _, endpoint := range cfg.Endpoints {
  307. if endpoint.ID != strings.TrimSpace(input.ID) {
  308. continue
  309. }
  310. // Reuse a stored credential only when the probe targets the same
  311. // normalized base URL. Otherwise an admin probe could exfiltrate
  312. // the Guard token to an attacker-controlled HTTPS host.
  313. if endpoint.BaseURL == baseURL {
  314. token = endpoint.Token
  315. }
  316. break
  317. }
  318. }
  319. }
  320. model := strings.TrimSpace(input.Model)
  321. if model == "" {
  322. model = DefaultGuardModel
  323. }
  324. timeout := input.TimeoutMS
  325. if timeout == 0 {
  326. timeout = DefaultTimeoutMS
  327. }
  328. limit := input.InputLimit
  329. if limit == 0 {
  330. limit = DefaultInputLimit
  331. }
  332. storage := storageConfig{Enabled: false, Strategy: "priority", WorkerCount: DefaultWorkerCount, QueueCapacity: DefaultQueueCapacity, Scanners: append([]string(nil), AllScannerIDs...), AllGroups: true,
  333. Endpoints: []StorageEndpoint{{ID: strings.TrimSpace(input.ID), Name: strings.TrimSpace(input.Name), Protocol: "openai_compatible", BaseURL: baseURL, Model: model, TimeoutMS: timeout, InputLimit: limit}}}
  334. if storage.Endpoints[0].ID == "" {
  335. storage.Endpoints[0].ID = "probe"
  336. }
  337. if storage.Endpoints[0].Name == "" {
  338. storage.Endpoints[0].Name = "Probe"
  339. }
  340. if err := validateStorageConfig(storage); err != nil {
  341. return ActiveEndpoint{}, false, err
  342. }
  343. return ActiveEndpoint{ID: storage.Endpoints[0].ID, Name: storage.Endpoints[0].Name, Protocol: "openai_compatible", BaseURL: baseURL, Model: model, Token: token, TimeoutMS: timeout, InputLimit: limit, Enabled: true}, token != "", nil
  344. }
  345. func (s *PromptService) finishProbe(id string, started time.Time, result ProbeResult) ProbeResult {
  346. result.CheckedAt = s.clock.Now()
  347. result.LatencyMS = int(result.CheckedAt.Sub(started).Milliseconds())
  348. if result.OK {
  349. LogInfo(EventProbeFinished, map[string]any{"guard_endpoint_id": id, "status": result.Status, "latency_ms": result.LatencyMS, "http_status": result.HTTPStatus})
  350. } else {
  351. LogWarn(EventProbeFailed, map[string]any{"guard_endpoint_id": id, "status": result.Status, "latency_ms": result.LatencyMS, "http_status": result.HTTPStatus, "error_code": result.ErrorCode, "retryable": result.Retryable})
  352. }
  353. s.probeMu.Lock()
  354. s.probes[id] = result
  355. s.probeMu.Unlock()
  356. return result
  357. }
  358. func (s *PromptService) probeSnapshot() map[string]ProbeResult {
  359. s.probeMu.RLock()
  360. defer s.probeMu.RUnlock()
  361. result := make(map[string]ProbeResult, len(s.probes))
  362. for id, probe := range s.probes {
  363. result[id] = probe
  364. }
  365. return result
  366. }
  367. func (s *PromptService) ListEvents(ctx context.Context, filter EventFilter, page, pageSize int) (*EventPage, error) {
  368. return s.repo.ListEvents(ctx, filter, page, pageSize)
  369. }
  370. func (s *PromptService) GetEvent(ctx context.Context, id int64) (*Event, error) {
  371. return s.repo.GetEvent(ctx, id)
  372. }
  373. func (s *PromptService) DeleteEvent(ctx context.Context, id int64) (*DeleteResult, error) {
  374. result, err := s.repo.DeleteEvent(ctx, id)
  375. if err == nil {
  376. s.deletePayloads(ctx, result.JobIDs)
  377. }
  378. return result, err
  379. }
  380. func (s *PromptService) DeleteEventsByIDs(ctx context.Context, ids []int64) (*DeleteResult, error) {
  381. result, err := s.repo.DeleteEventsByIDs(ctx, ids)
  382. if err == nil {
  383. s.deletePayloads(ctx, result.JobIDs)
  384. }
  385. return result, err
  386. }
  387. type deleteClaims struct {
  388. FilterHash string `json:"filter_hash"`
  389. SnapshotMaxID int64 `json:"snapshot_max_id"`
  390. AdminID int64 `json:"admin_id"`
  391. IssuedAt time.Time `json:"issued_at"`
  392. ExpiresAt time.Time `json:"expires_at"`
  393. }
  394. func (s *PromptService) PreviewDelete(ctx context.Context, filter EventFilter, adminID int64) (*DeletePreview, error) {
  395. preview, err := s.repo.PreviewDelete(ctx, filter)
  396. if err != nil {
  397. return nil, err
  398. }
  399. now := s.clock.Now()
  400. expires := now.Add(5 * time.Minute)
  401. claimsRaw, _ := json.Marshal(deleteClaims{FilterHash: preview.FilterHash, SnapshotMaxID: preview.SnapshotMaxID, AdminID: adminID, IssuedAt: now, ExpiresAt: expires})
  402. token, err := s.config.Encrypt(string(claimsRaw))
  403. if err != nil {
  404. return nil, err
  405. }
  406. preview.ConfirmationToken, preview.ExpiresAt = token, expires
  407. LogInfo(EventDeletePreviewed, map[string]any{"user_id": adminID, "status": "previewed"})
  408. return preview, nil
  409. }
  410. type DeleteByFilterRequest struct {
  411. Filter EventFilter `json:"filter"`
  412. SnapshotMaxID int64 `json:"snapshot_max_id"`
  413. FilterHash string `json:"filter_hash"`
  414. ConfirmationToken string `json:"confirmation_token"`
  415. Confirm bool `json:"confirm"`
  416. }
  417. func (s *PromptService) DeleteByFilter(ctx context.Context, request DeleteByFilterRequest, adminID int64) (*DeleteResult, error) {
  418. if !request.Confirm {
  419. return nil, errors.New("prompt audit filter delete requires confirm=true")
  420. }
  421. plain, err := s.config.Decrypt(strings.TrimSpace(request.ConfirmationToken))
  422. if err != nil {
  423. return nil, errors.New("prompt audit confirmation token invalid")
  424. }
  425. var claims deleteClaims
  426. if json.Unmarshal([]byte(plain), &claims) != nil {
  427. return nil, errors.New("prompt audit confirmation token invalid")
  428. }
  429. computed := FilterHash(request.Filter, request.SnapshotMaxID)
  430. if claims.AdminID != adminID || claims.SnapshotMaxID != request.SnapshotMaxID || claims.FilterHash != request.FilterHash || request.FilterHash != computed || !s.clock.Now().Before(claims.ExpiresAt) {
  431. return nil, errors.New("prompt audit confirmation token does not match deletion request")
  432. }
  433. result, err := s.repo.DeleteEventsByFilter(ctx, request.Filter, request.SnapshotMaxID, 200)
  434. if err == nil {
  435. s.deletePayloads(ctx, result.JobIDs)
  436. LogWarn(EventEventsFilterDeleted, map[string]any{"user_id": adminID, "status": "deleted"})
  437. }
  438. return result, err
  439. }
  440. func (s *PromptService) deletePayloads(ctx context.Context, jobIDs []int64) {
  441. for _, id := range jobIDs {
  442. _ = s.payload.Delete(ctx, id)
  443. }
  444. }
  445. func parseTimeQuery(value string) *time.Time {
  446. parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value))
  447. if err != nil {
  448. return nil
  449. }
  450. parsed = parsed.UTC()
  451. return &parsed
  452. }