| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478 |
- package securityaudit
- import (
- "context"
- "encoding/json"
- "errors"
- "io"
- "net"
- "net/http"
- "strings"
- "sync"
- "time"
- )
- type PromptService struct {
- config ConfigStore
- repo *PostgreSQLRepository
- payload *RedisPayloadStore
- enqueuer *Enqueuer
- runner *Runner
- evaluator *GuardEvaluator
- scanner *OpenAICompatibleScanner
- metrics *AtomicMetrics
- clock Clock
- lifecycleMu sync.Mutex
- cancel context.CancelFunc
- background context.Context
- enqueueWG sync.WaitGroup
- enqueueSlots chan struct{}
- probeMu sync.RWMutex
- probes map[string]ProbeResult
- }
- func NewPromptService(
- config ConfigStore,
- repo *PostgreSQLRepository,
- payload *RedisPayloadStore,
- scanner *OpenAICompatibleScanner,
- metrics *AtomicMetrics,
- ) *PromptService {
- enqueuer := NewEnqueuer(config, repo, payload, metrics)
- evaluator := NewGuardEvaluator(scanner, repo, metrics)
- runner := NewRunner(config, repo, payload, scanner, metrics)
- return &PromptService{
- config: config, repo: repo, payload: payload, scanner: scanner, metrics: metrics,
- enqueuer: enqueuer, evaluator: evaluator, runner: runner, clock: realClock{},
- enqueueSlots: make(chan struct{}, 128), probes: map[string]ProbeResult{},
- }
- }
- func (s *PromptService) Start(ctx context.Context) error {
- if s == nil || s.config == nil || s.runner == nil {
- return errors.New("prompt audit service unavailable")
- }
- s.lifecycleMu.Lock()
- if s.cancel != nil {
- s.lifecycleMu.Unlock()
- return nil
- }
- background, cancel := context.WithCancel(ctx)
- s.background, s.cancel = background, cancel
- s.lifecycleMu.Unlock()
- configErr := s.config.Start(background)
- workerErr := s.runner.Start(background)
- return errors.Join(configErr, workerErr)
- }
- func (s *PromptService) Shutdown(ctx context.Context) error {
- if s == nil {
- return nil
- }
- s.lifecycleMu.Lock()
- cancel := s.cancel
- s.cancel = nil
- s.lifecycleMu.Unlock()
- if cancel != nil {
- cancel()
- }
- var workerErr error
- if s.runner != nil {
- workerErr = s.runner.Shutdown(ctx)
- }
- done := make(chan struct{})
- go func() { s.enqueueWG.Wait(); close(done) }()
- select {
- case <-done:
- case <-ctx.Done():
- if workerErr == nil {
- workerErr = ctx.Err()
- }
- }
- var configErr error
- if s.config != nil {
- configErr = s.config.Shutdown(ctx)
- }
- if workerErr != nil {
- return workerErr
- }
- return configErr
- }
- func (s *PromptService) EffectiveMode() Mode {
- if s == nil || s.config == nil {
- return ModeOff
- }
- return s.config.EffectiveMode()
- }
- func (s *PromptService) Enqueue(_ context.Context, req Request) error {
- if s == nil || s.enqueuer == nil || s.EffectiveMode() != ModeAsync {
- return nil
- }
- select {
- case s.enqueueSlots <- struct{}{}:
- default:
- if s.metrics != nil {
- s.metrics.IncDropped()
- }
- LogWarn(EventEnqueueDropped, map[string]any{"request_id": req.RequestID, "status": "dropped", "error_code": "local_enqueue_busy"})
- return nil
- }
- s.lifecycleMu.Lock()
- background := s.background
- s.lifecycleMu.Unlock()
- if background == nil {
- <-s.enqueueSlots
- return errors.New("prompt audit service not started")
- }
- requestCopy := req.Clone()
- s.enqueueWG.Add(1)
- go func() {
- defer s.enqueueWG.Done()
- defer func() { <-s.enqueueSlots }()
- ctx, cancel := context.WithTimeout(background, 2*time.Second)
- defer cancel()
- _ = s.enqueuer.Enqueue(ctx, requestCopy)
- }()
- return nil
- }
- func (s *PromptService) Evaluate(ctx context.Context, req Request) (*PromptDecision, error) {
- if s == nil || s.config == nil || s.evaluator == nil {
- return nil, &GuardError{Code: ErrorCodeUnavailable}
- }
- if s.config.BlockingActivationDegraded() {
- return nil, &GuardError{Code: ErrorCodeUnavailable}
- }
- cfg, ok := s.config.Active()
- if !ok {
- if s.config.EffectiveMode() == ModeBlocking {
- return nil, &GuardError{Code: ErrorCodeUnavailable}
- }
- return &PromptDecision{Kind: DecisionAllow, AllowNextStage: true}, nil
- }
- if cfg.EffectiveMode() != ModeBlocking || !cfg.IncludesGroup(req.GroupID) {
- return &PromptDecision{Kind: DecisionAllow, AllowNextStage: true}, nil
- }
- snapshot, err := ExtractBlockingPromptSnapshot(req, cfg.BlockingLatestTurnOnly)
- if errors.Is(err, ErrNoPromptText) {
- return &PromptDecision{Kind: DecisionAllow, AllowNextStage: true}, nil
- }
- if err != nil {
- return nil, &GuardError{Code: ErrorCodeInvalidResponse, Cause: err}
- }
- return s.evaluator.Evaluate(ctx, cfg, snapshot)
- }
- func (s *PromptService) GetConfig() (PublicConfig, error) { return s.config.Public() }
- func (s *PromptService) SaveConfig(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) {
- return s.config.Save(ctx, req, actorID)
- }
- func (s *PromptService) Runtime(ctx context.Context) RuntimeSnapshot {
- expected, activeVersion, loadedAt, loadError := s.config.RuntimeState()
- cfg, hasConfig := s.config.Active()
- mode := s.EffectiveMode()
- workerTotal, queueCapacity := 0, 0
- if hasConfig {
- workerTotal, queueCapacity = cfg.WorkerCount, cfg.QueueCapacity
- }
- runtime := RuntimeSnapshot{
- ProcessStatus: "disabled", EffectiveMode: mode, ExpectedConfigVersion: expected,
- ActiveConfigVersion: activeVersion, ConfigLoadedAt: loadedAt, ConfigLoadError: loadError,
- WorkerTotal: workerTotal, QueueCapacity: queueCapacity, DatabaseStatus: "ok", RedisStatus: "ok",
- Endpoints: s.probeSnapshot(), GuardMetrics: s.metrics.Snapshot(),
- }
- if s.repo != nil {
- stats, err := s.repo.QueueStats(ctx)
- if err != nil {
- runtime.DatabaseStatus = "error"
- runtime.LastErrorCode = "database_unavailable"
- } else {
- runtime.Queue = stats
- }
- } else {
- runtime.DatabaseStatus = "error"
- }
- if s.payload == nil || s.payload.Ping(ctx) != nil {
- runtime.RedisStatus = "error"
- if runtime.LastErrorCode == "" {
- runtime.LastErrorCode = "payload_store_unavailable"
- }
- }
- activeWorkers, processed, failed, heartbeat, lastProcessed, workerCode, workerMessage := s.runner.Snapshot()
- runtime.WorkerActive, runtime.ProcessedTotal, runtime.FailedTotal = activeWorkers, processed, failed
- if s.metrics != nil {
- auditMetrics := s.metrics.AuditSnapshot()
- runtime.EnqueuedTotal, runtime.DroppedTotal = auditMetrics.Enqueued, auditMetrics.Dropped
- }
- runtime.WorkerHeartbeatAt, runtime.LastProcessedAt = heartbeat, lastProcessed
- if workerCode != "" {
- runtime.LastErrorCode, runtime.LastErrorMessage = workerCode, workerMessage
- }
- if mode != ModeOff {
- runtime.ProcessStatus = "running"
- if loadError != "" || runtime.DatabaseStatus != "ok" || runtime.RedisStatus != "ok" || activeVersion != expected {
- runtime.ProcessStatus = "degraded"
- }
- if heartbeat == nil || s.clock.Now().Sub(*heartbeat) > 10*time.Second {
- runtime.ProcessStatus = "degraded"
- }
- }
- return runtime
- }
- type ProbeRequest struct {
- Endpoint UpdateEndpoint `json:"endpoint"`
- }
- func (s *PromptService) Probe(ctx context.Context, request ProbeRequest) ProbeResult {
- started := s.clock.Now()
- endpoint, tokenApplied, err := s.resolveProbeEndpoint(request.Endpoint)
- if err != nil {
- return s.finishProbe(request.Endpoint.ID, started, ProbeResult{Status: "failed", ErrorCode: "endpoint_invalid", Message: "审计节点配置无效"})
- }
- LogInfo(EventProbeStarted, map[string]any{"guard_endpoint_id": endpoint.ID, "status": "started"})
- client, err := NewSecureHTTPClient(endpoint)
- if err != nil {
- return s.finishProbe(endpoint.ID, started, ProbeResult{Status: "failed", ErrorCode: "endpoint_unsafe", Message: "审计节点地址不在允许范围", TokenApplied: tokenApplied})
- }
- modelsURL, _ := ModelsURL(endpoint.BaseURL)
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsURL, nil)
- if err != nil {
- return s.finishProbe(endpoint.ID, started, ProbeResult{Status: "failed", ErrorCode: "probe_request_invalid", Message: "无法创建探测请求", TokenApplied: tokenApplied})
- }
- if endpoint.Token != "" {
- req.Header.Set("Authorization", "Bearer "+endpoint.Token)
- }
- resp, err := client.Do(req)
- if err != nil {
- code := "connection_failed"
- var netErr net.Error
- if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout()) {
- code = "timeout"
- }
- return s.finishProbe(endpoint.ID, started, ProbeResult{Status: "failed", ErrorCode: code, Message: "无法连接审计节点", Retryable: true, TokenApplied: tokenApplied})
- }
- responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, maxGuardResponseBytes+1))
- _ = resp.Body.Close()
- if readErr != nil {
- return s.finishProbe(endpoint.ID, started, ProbeResult{Status: "failed", ErrorCode: "response_read_failed", Message: "审计节点响应读取失败", HTTPStatus: resp.StatusCode, Retryable: true, TokenApplied: tokenApplied})
- }
- if int64(len(responseBody)) > maxGuardResponseBytes {
- return s.finishProbe(endpoint.ID, started, ProbeResult{Status: "failed", ErrorCode: "response_too_large", Message: "审计节点响应无效", HTTPStatus: resp.StatusCode, TokenApplied: tokenApplied})
- }
- if resp.StatusCode >= 200 && resp.StatusCode < 300 && modelsResponseReady(responseBody, endpoint.Model) {
- return s.finishProbe(endpoint.ID, started, ProbeResult{OK: true, Status: "healthy", Message: "审计节点连接正常", HTTPStatus: resp.StatusCode, TokenApplied: tokenApplied})
- }
- if (resp.StatusCode >= 200 && resp.StatusCode < 300) || resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusMethodNotAllowed {
- result, scanErr := s.scanner.Scan(ctx, endpoint, "Hello", AllScannerIDs)
- if scanErr == nil && result != nil {
- return s.finishProbe(endpoint.ID, started, ProbeResult{OK: true, Status: "healthy", Message: "审计节点模型调用正常", HTTPStatus: http.StatusOK, TokenApplied: tokenApplied})
- }
- code, status, retryable := guardErrorCode(scanErr), 0, false
- var guardErr *GuardError
- if errors.As(scanErr, &guardErr) {
- status, retryable = guardErr.HTTPStatus, guardErr.Retryable
- }
- if code == "" {
- code = ErrorCodeInvalidResponse
- }
- return s.finishProbe(endpoint.ID, started, ProbeResult{Status: "failed", ErrorCode: code, Message: "审计节点模型调用失败", HTTPStatus: status, Retryable: retryable, TokenApplied: tokenApplied})
- }
- code, retryable := "probe_http_error", resp.StatusCode == 429 || resp.StatusCode >= 500
- if resp.StatusCode == 401 || resp.StatusCode == 403 {
- code = "authentication_failed"
- }
- return s.finishProbe(endpoint.ID, started, ProbeResult{Status: "failed", ErrorCode: code, Message: "审计节点探测失败", HTTPStatus: resp.StatusCode, Retryable: retryable, TokenApplied: tokenApplied})
- }
- func modelsResponseReady(body []byte, model string) bool {
- var response struct {
- Data []struct {
- ID string `json:"id"`
- } `json:"data"`
- }
- if json.Unmarshal(body, &response) != nil || response.Data == nil {
- return false
- }
- model = strings.TrimSpace(model)
- if model == "" {
- return true
- }
- for _, item := range response.Data {
- if strings.TrimSpace(item.ID) == model {
- return true
- }
- }
- return false
- }
- func (s *PromptService) resolveProbeEndpoint(input UpdateEndpoint) (ActiveEndpoint, bool, error) {
- baseURL, err := NormalizeBaseURL(input.BaseURL)
- if err != nil {
- return ActiveEndpoint{}, false, err
- }
- token := strings.TrimSpace(input.Token)
- if token == "" {
- if cfg, ok := s.config.Active(); ok {
- for _, endpoint := range cfg.Endpoints {
- if endpoint.ID != strings.TrimSpace(input.ID) {
- continue
- }
- // Reuse a stored credential only when the probe targets the same
- // normalized base URL. Otherwise an admin probe could exfiltrate
- // the Guard token to an attacker-controlled HTTPS host.
- if endpoint.BaseURL == baseURL {
- token = endpoint.Token
- }
- break
- }
- }
- }
- model := strings.TrimSpace(input.Model)
- if model == "" {
- model = DefaultGuardModel
- }
- timeout := input.TimeoutMS
- if timeout == 0 {
- timeout = DefaultTimeoutMS
- }
- limit := input.InputLimit
- if limit == 0 {
- limit = DefaultInputLimit
- }
- storage := storageConfig{Enabled: false, Strategy: "priority", WorkerCount: DefaultWorkerCount, QueueCapacity: DefaultQueueCapacity, Scanners: append([]string(nil), AllScannerIDs...), AllGroups: true,
- Endpoints: []StorageEndpoint{{ID: strings.TrimSpace(input.ID), Name: strings.TrimSpace(input.Name), Protocol: "openai_compatible", BaseURL: baseURL, Model: model, TimeoutMS: timeout, InputLimit: limit}}}
- if storage.Endpoints[0].ID == "" {
- storage.Endpoints[0].ID = "probe"
- }
- if storage.Endpoints[0].Name == "" {
- storage.Endpoints[0].Name = "Probe"
- }
- if err := validateStorageConfig(storage); err != nil {
- return ActiveEndpoint{}, false, err
- }
- 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
- }
- func (s *PromptService) finishProbe(id string, started time.Time, result ProbeResult) ProbeResult {
- result.CheckedAt = s.clock.Now()
- result.LatencyMS = int(result.CheckedAt.Sub(started).Milliseconds())
- if result.OK {
- LogInfo(EventProbeFinished, map[string]any{"guard_endpoint_id": id, "status": result.Status, "latency_ms": result.LatencyMS, "http_status": result.HTTPStatus})
- } else {
- 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})
- }
- s.probeMu.Lock()
- s.probes[id] = result
- s.probeMu.Unlock()
- return result
- }
- func (s *PromptService) probeSnapshot() map[string]ProbeResult {
- s.probeMu.RLock()
- defer s.probeMu.RUnlock()
- result := make(map[string]ProbeResult, len(s.probes))
- for id, probe := range s.probes {
- result[id] = probe
- }
- return result
- }
- func (s *PromptService) ListEvents(ctx context.Context, filter EventFilter, page, pageSize int) (*EventPage, error) {
- return s.repo.ListEvents(ctx, filter, page, pageSize)
- }
- func (s *PromptService) GetEvent(ctx context.Context, id int64) (*Event, error) {
- return s.repo.GetEvent(ctx, id)
- }
- func (s *PromptService) DeleteEvent(ctx context.Context, id int64) (*DeleteResult, error) {
- result, err := s.repo.DeleteEvent(ctx, id)
- if err == nil {
- s.deletePayloads(ctx, result.JobIDs)
- }
- return result, err
- }
- func (s *PromptService) DeleteEventsByIDs(ctx context.Context, ids []int64) (*DeleteResult, error) {
- result, err := s.repo.DeleteEventsByIDs(ctx, ids)
- if err == nil {
- s.deletePayloads(ctx, result.JobIDs)
- }
- return result, err
- }
- type deleteClaims struct {
- FilterHash string `json:"filter_hash"`
- SnapshotMaxID int64 `json:"snapshot_max_id"`
- AdminID int64 `json:"admin_id"`
- IssuedAt time.Time `json:"issued_at"`
- ExpiresAt time.Time `json:"expires_at"`
- }
- func (s *PromptService) PreviewDelete(ctx context.Context, filter EventFilter, adminID int64) (*DeletePreview, error) {
- preview, err := s.repo.PreviewDelete(ctx, filter)
- if err != nil {
- return nil, err
- }
- now := s.clock.Now()
- expires := now.Add(5 * time.Minute)
- claimsRaw, _ := json.Marshal(deleteClaims{FilterHash: preview.FilterHash, SnapshotMaxID: preview.SnapshotMaxID, AdminID: adminID, IssuedAt: now, ExpiresAt: expires})
- token, err := s.config.Encrypt(string(claimsRaw))
- if err != nil {
- return nil, err
- }
- preview.ConfirmationToken, preview.ExpiresAt = token, expires
- LogInfo(EventDeletePreviewed, map[string]any{"user_id": adminID, "status": "previewed"})
- return preview, nil
- }
- type DeleteByFilterRequest struct {
- Filter EventFilter `json:"filter"`
- SnapshotMaxID int64 `json:"snapshot_max_id"`
- FilterHash string `json:"filter_hash"`
- ConfirmationToken string `json:"confirmation_token"`
- Confirm bool `json:"confirm"`
- }
- func (s *PromptService) DeleteByFilter(ctx context.Context, request DeleteByFilterRequest, adminID int64) (*DeleteResult, error) {
- if !request.Confirm {
- return nil, errors.New("prompt audit filter delete requires confirm=true")
- }
- plain, err := s.config.Decrypt(strings.TrimSpace(request.ConfirmationToken))
- if err != nil {
- return nil, errors.New("prompt audit confirmation token invalid")
- }
- var claims deleteClaims
- if json.Unmarshal([]byte(plain), &claims) != nil {
- return nil, errors.New("prompt audit confirmation token invalid")
- }
- computed := FilterHash(request.Filter, request.SnapshotMaxID)
- if claims.AdminID != adminID || claims.SnapshotMaxID != request.SnapshotMaxID || claims.FilterHash != request.FilterHash || request.FilterHash != computed || !s.clock.Now().Before(claims.ExpiresAt) {
- return nil, errors.New("prompt audit confirmation token does not match deletion request")
- }
- result, err := s.repo.DeleteEventsByFilter(ctx, request.Filter, request.SnapshotMaxID, 200)
- if err == nil {
- s.deletePayloads(ctx, result.JobIDs)
- LogWarn(EventEventsFilterDeleted, map[string]any{"user_id": adminID, "status": "deleted"})
- }
- return result, err
- }
- func (s *PromptService) deletePayloads(ctx context.Context, jobIDs []int64) {
- for _, id := range jobIDs {
- _ = s.payload.Delete(ctx, id)
- }
- }
- func parseTimeQuery(value string) *time.Time {
- parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value))
- if err != nil {
- return nil
- }
- parsed = parsed.UTC()
- return &parsed
- }
|