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 app.utils.timezone import beijing_now_naive
  16. from typing import Any, Dict, List, Optional
  17. from app.core.logger import logger as core_logger
  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("写审计日志失败")