audit.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. """
  2. 异步/批量审计写入器(含采样与轮转)
  3. - errors: 100% 写入
  4. - success: 默认采样率 1%(可通过环境/配置调整)
  5. - 批量写入:达到 batch_size 或超时则刷新
  6. - 日志轮转:按天文件,并支持按大小轮转(max_bytes)
  7. """
  8. import os
  9. import json
  10. import hashlib
  11. import threading
  12. import queue
  13. import time
  14. from datetime import datetime
  15. from typing import Any, Dict, List, Optional
  16. from app.core.logger import logger as core_logger
  17. from app.utils.timezone import beijing_now_naive
  18. # 后端固定审计参数(硬编码)
  19. # - AUDIT_SUCCESS_SAMPLE_RATE: 成功请求采样率(0.0 - 1.0)
  20. # - AUDIT_BATCH_SIZE: 批量写入阈值
  21. # - AUDIT_FLUSH_INTERVAL: 最长等待秒数(到达则强制刷新批次)
  22. # - AUDIT_MAX_BYTES: 日志文件大小轮转阈值
  23. AUDIT_SUCCESS_SAMPLE_RATE = 0.01 # 1% 成功请求采样
  24. AUDIT_BATCH_SIZE = 50
  25. AUDIT_FLUSH_INTERVAL = 5 # seconds
  26. AUDIT_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
  27. # 日志目录(确保存在)
  28. SECURE_LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logs", "secure")
  29. SECURE_LOG_DIR = os.path.abspath(SECURE_LOG_DIR)
  30. os.makedirs(SECURE_LOG_DIR, exist_ok=True)
  31. # 要脱敏的字段
  32. REDACT_FIELDS = {
  33. "password",
  34. "card_code",
  35. "shared_secret",
  36. "identity_secret",
  37. "aes_key",
  38. "encrypted_aes_key",
  39. "signature",
  40. "access_token",
  41. "refresh_token",
  42. "session_token",
  43. "steamLoginSecure",
  44. }
  45. def _redact(obj: Any, redacted: Optional[List[str]] = None) -> Any:
  46. """递归脱敏对象中的敏感字段"""
  47. if redacted is None:
  48. redacted = []
  49. if isinstance(obj, dict):
  50. out = {}
  51. for k, v in obj.items():
  52. if k in REDACT_FIELDS:
  53. out[k] = "<REDACTED>"
  54. redacted.append(k)
  55. else:
  56. out[k] = _redact(v, redacted)
  57. return out
  58. if isinstance(obj, list):
  59. return [_redact(i, redacted) for i in obj]
  60. return obj
  61. def _payload_hash(payload: Any) -> str:
  62. try:
  63. j = json.dumps(payload, sort_keys=True, ensure_ascii=False)
  64. return hashlib.sha256(j.encode("utf-8")).hexdigest()
  65. except Exception:
  66. return ""
  67. class AuditWriter:
  68. def __init__(self):
  69. self._q: "queue.Queue[dict]" = queue.Queue()
  70. self._stop_event = threading.Event()
  71. self._worker = threading.Thread(target=self._run, daemon=True)
  72. self._worker.start()
  73. def enqueue(self, entry: Dict[str, Any]):
  74. try:
  75. self._q.put_nowait(entry)
  76. except Exception:
  77. core_logger.exception("enqueue 审计条目失败")
  78. def _current_log_path(self) -> str:
  79. fname = beijing_now_naive().strftime("secure-%Y-%m-%d.jsonl")
  80. return os.path.join(SECURE_LOG_DIR, fname)
  81. def _rotate_if_needed(self, path: str):
  82. try:
  83. if os.path.exists(path) and os.path.getsize(path) >= AUDIT_MAX_BYTES:
  84. ts = beijing_now_naive().strftime("%Y%m%d%H%M%S")
  85. new_name = f"{path}.{ts}.rot"
  86. os.rename(path, new_name)
  87. except Exception:
  88. core_logger.exception("审计日志轮转失败")
  89. def _flush_batch(self, batch: List[Dict[str, Any]]):
  90. if not batch:
  91. return
  92. path = self._current_log_path()
  93. try:
  94. self._rotate_if_needed(path)
  95. with open(path, "a", encoding="utf-8") as f:
  96. for entry in batch:
  97. f.write(json.dumps(entry, ensure_ascii=False) + "\n")
  98. except Exception:
  99. core_logger.exception("批量写审计日志失败")
  100. def _run(self):
  101. buffer: List[Dict[str, Any]] = []
  102. last_flush = time.time()
  103. while not self._stop_event.is_set():
  104. try:
  105. timeout = max(0.0, AUDIT_FLUSH_INTERVAL - (time.time() - last_flush))
  106. item = None
  107. try:
  108. item = self._q.get(timeout=timeout)
  109. except queue.Empty:
  110. item = None
  111. if item is not None:
  112. buffer.append(item)
  113. # flush conditions
  114. if len(buffer) >= AUDIT_BATCH_SIZE or (time.time() - last_flush) >= AUDIT_FLUSH_INTERVAL:
  115. self._flush_batch(buffer)
  116. buffer = []
  117. last_flush = time.time()
  118. except Exception:
  119. core_logger.exception("审计写入工作线程异常")
  120. # on stop, flush remaining
  121. try:
  122. remaining = []
  123. while not self._q.empty():
  124. remaining.append(self._q.get_nowait())
  125. self._flush_batch(remaining)
  126. except Exception:
  127. core_logger.exception("停机时刷新审计队列失败")
  128. def stop(self):
  129. self._stop_event.set()
  130. self._worker.join(timeout=2)
  131. # 全局实例
  132. _AUDIT_WRITER = AuditWriter()
  133. def write_audit_entry(
  134. phase: str,
  135. endpoint: str,
  136. payload: Any,
  137. key_version: Optional[int] = None,
  138. session_id: Optional[str] = None,
  139. level: str = "info",
  140. processing_time_ms: Optional[int] = None,
  141. ):
  142. """
  143. 采样逻辑:
  144. - 当 level == "error" 时始终写入(errors=100%)
  145. - 其他(成功)按 AUDIT_SUCCESS_SAMPLE_RATE 采样
  146. """
  147. try:
  148. if level != "error":
  149. # sample successes
  150. import random
  151. if random.random() >= AUDIT_SUCCESS_SAMPLE_RATE:
  152. return
  153. timestamp = beijing_now_naive().strftime("%Y-%m-%dT%H:%M:%S") + "+08:00"
  154. redacted_fields = []
  155. redacted_payload = _redact(payload, redacted_fields)
  156. entry = {
  157. "timestamp": timestamp,
  158. "phase": phase,
  159. "endpoint": endpoint,
  160. "key_version": key_version,
  161. "session_id": session_id,
  162. "processing_time_ms": processing_time_ms,
  163. "redacted_fields": list(set(redacted_fields)),
  164. "payload_hash": _payload_hash(payload),
  165. "truncated": False,
  166. "level": level,
  167. "payload": redacted_payload if level != "error" else None,
  168. }
  169. _AUDIT_WRITER.enqueue(entry)
  170. except Exception:
  171. core_logger.exception("写审计日志失败")