log_reader.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. """Shared primitives for reading, parsing, and sanitizing the Bambuddy app log.
  2. Extracted from ``routes/support.py`` so service-layer code (e.g. the log-health
  3. scanner in ``log_health.py``) can reuse log reading and redaction without
  4. importing from the API layer. ``support.py`` re-imports these helpers and keeps
  5. its own route handlers.
  6. """
  7. import logging
  8. import re
  9. from pydantic import BaseModel
  10. from sqlalchemy import select
  11. from sqlalchemy.ext.asyncio import AsyncSession
  12. from backend.app.core.config import settings
  13. from backend.app.core.logging_filters import (
  14. HOST_ONLY_LOGGERS,
  15. URL_CREDENTIALS_PATTERN,
  16. redact_device_urls,
  17. redact_query_tokens,
  18. redact_refused_headers,
  19. redact_url_credentials,
  20. )
  21. from backend.app.models.printer import Printer
  22. from backend.app.models.settings import Settings
  23. from backend.app.models.user import User
  24. from backend.app.utils.printer_models import PRINTER_MODEL_ID_MAP, PRINTER_MODEL_MAP
  25. logger = logging.getLogger(__name__)
  26. # Log line format: "2024-01-15 10:30:45,123 INFO [module.name] [trace_id] Message"
  27. # The trace_id is left as part of the message group — callers that need it can
  28. # parse it out; the log-health scanner does not.
  29. LOG_LINE_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2},\d{3})\s+(\w+)\s+\[([^\]]+)\]\s+(.*)$")
  30. # LDAP Distinguished Names carry PII — the leaf ``CN=`` is the user's real name
  31. # (#2681). Match a run of at least two ``attr=value`` RDN components joined by
  32. # commas, where ``attr`` is a known LDAP attribute type. Requiring two components
  33. # keeps this from clobbering an incidental ``key=value`` in an unrelated log line,
  34. # while still catching DNs wherever they surface — the deliberate "auth successful"
  35. # line, ldap3 exception strings, and group DNs alike. Bias is intentionally toward
  36. # redaction: over-redacting a rare debug line to ``[DN]`` is a safe failure; leaking
  37. # a name is not.
  38. # The value char class excludes `<>;+` — RFC 4514 requires those escaped inside a
  39. # DN value, so an unescaped one marks the end of the DN, not part of it. That stops
  40. # the final (comma-unbounded) component from greedily swallowing trailing log text
  41. # such as ``… -> GroupName``.
  42. _LDAP_RDN = r"(?:CN|OU|DC|UID|O|L|ST|C|SN|GN|DN|E|MAIL|STREET|GIVENNAME|SURNAME)=[^,\n<>;+]+"
  43. _LDAP_DN_PATTERN = re.compile(rf"(?i)\b{_LDAP_RDN}(?:\s*,\s*{_LDAP_RDN})+")
  44. class LogEntry(BaseModel):
  45. """A single parsed log entry."""
  46. timestamp: str
  47. level: str
  48. logger_name: str
  49. message: str
  50. def parse_log_line(line: str) -> LogEntry | None:
  51. """Parse a single log line into a LogEntry, or None if it is not a line start."""
  52. match = LOG_LINE_PATTERN.match(line.strip())
  53. if match:
  54. return LogEntry(
  55. timestamp=match.group(1),
  56. level=match.group(2),
  57. logger_name=match.group(3),
  58. message=match.group(4),
  59. )
  60. return None
  61. def _logger_of(line: str) -> str | None:
  62. match = LOG_LINE_PATTERN.match(line.strip())
  63. return match.group(3) if match else None
  64. def _redacted(entry: LogEntry) -> LogEntry:
  65. # Lines on disk may predate a redaction: masked as they are read, before
  66. # any search, so a search cannot confirm a secret either.
  67. entry.message = redact_refused_headers(redact_url_credentials(redact_query_tokens(entry.message))) or ""
  68. if entry.logger_name in HOST_ONLY_LOGGERS:
  69. entry.message = redact_device_urls(entry.message) or ""
  70. return entry
  71. def read_log_entries(
  72. limit: int = 200,
  73. level_filter: str | None = None,
  74. search: str | None = None,
  75. ) -> tuple[list[LogEntry], int]:
  76. """Read and parse log entries from ``bambuddy.log``, newest first.
  77. Continuation lines (tracebacks etc.) are folded into the message of the
  78. entry they belong to. Returns ``(entries, total_lines_in_file)``.
  79. """
  80. log_file = settings.log_dir / "bambuddy.log"
  81. if not log_file.exists():
  82. return [], 0
  83. entries: list[LogEntry] = []
  84. total_lines = 0
  85. try:
  86. with open(log_file, encoding="utf-8", errors="replace") as f:
  87. lines = f.readlines()
  88. total_lines = len(lines)
  89. # Parse lines in reverse order (newest first)
  90. current_entry: LogEntry | None = None
  91. multi_line_buffer: list[str] = []
  92. for line in reversed(lines):
  93. parsed = parse_log_line(line)
  94. if parsed:
  95. # Found a new log entry start
  96. if current_entry:
  97. current_entry = _redacted(current_entry)
  98. # Apply filters and add previous entry (without multi_line_buffer - it belongs to new entry)
  99. should_include = True
  100. # Level filter
  101. if level_filter and current_entry.level.upper() != level_filter.upper():
  102. should_include = False
  103. # Search filter (case-insensitive)
  104. if search and should_include:
  105. search_lower = search.lower()
  106. if not (
  107. search_lower in current_entry.message.lower()
  108. or search_lower in current_entry.logger_name.lower()
  109. ):
  110. should_include = False
  111. if should_include:
  112. entries.append(current_entry)
  113. if len(entries) >= limit:
  114. break
  115. # Set new entry and attach any accumulated multi-line content to it
  116. # (in reverse order, continuation lines come before their parent entry)
  117. current_entry = parsed
  118. if multi_line_buffer:
  119. current_entry.message += "\n" + "\n".join(reversed(multi_line_buffer))
  120. multi_line_buffer = []
  121. elif line.strip():
  122. # Continuation of multi-line log entry (will be attached to next parsed entry)
  123. multi_line_buffer.append(line.rstrip())
  124. # Don't forget the last (oldest) entry
  125. # Note: any remaining multi_line_buffer would be orphaned lines before the first entry
  126. if current_entry and len(entries) < limit:
  127. current_entry = _redacted(current_entry)
  128. should_include = True
  129. if level_filter and current_entry.level.upper() != level_filter.upper():
  130. should_include = False
  131. if search and should_include:
  132. search_lower = search.lower()
  133. if not (
  134. search_lower in current_entry.message.lower()
  135. or search_lower in current_entry.logger_name.lower()
  136. ):
  137. should_include = False
  138. if should_include:
  139. entries.append(current_entry)
  140. except Exception as e:
  141. logger.error("Error reading log file: %s", e)
  142. return [], 0
  143. # Entries are already in newest-first order
  144. return entries, total_lines
  145. def sanitize_log_content(content: str, sensitive_strings: dict[str, str] | None = None) -> str:
  146. """Remove sensitive data from log content.
  147. ``sensitive_strings`` maps known exact values (printer names, serials, etc.)
  148. to replacement labels; pass the result of :func:`collect_sensitive_strings`.
  149. Regex passes additionally redact credentials in URLs, emails, serials, and
  150. IP addresses that were not captured by exact matching.
  151. """
  152. # Lines from loggers that name a device by its whole URL keep its host only.
  153. # First, while each line's logger can still be read: a known value below
  154. # may be part of a logger name.
  155. content = "\n".join(
  156. redact_device_urls(line) or "" if _logger_of(line) in HOST_ONLY_LOGGERS else line
  157. for line in content.split("\n")
  158. )
  159. # Then replace known sensitive values (database-aware exact matching)
  160. # This catches printer names, usernames, and other arbitrary user-chosen strings
  161. # that regex patterns cannot detect
  162. if sensitive_strings:
  163. # Sort by length descending to avoid partial matches (e.g. "My Printer 1" before "My Printer")
  164. for value, label in sorted(sensitive_strings.items(), key=lambda x: len(x[0]), reverse=True):
  165. if len(value) < 3:
  166. continue # Skip very short strings to prevent over-redaction
  167. content = re.sub(re.escape(value), label, content)
  168. # Replace credentials in URLs (e.g. http://user:pass@host, rtsps://bblp:code@host).
  169. # Shares its pattern with the log-pipeline redaction in ``core.logging_filters`` so
  170. # the two can't drift; the bundle drops the username too, where the live log keeps
  171. # it for diagnosis.
  172. content = URL_CREDENTIALS_PATTERN.sub(r"\g<scheme>[CREDENTIALS]@", content)
  173. # Query-string tokens (?token=...), for log lines written before the live
  174. # filter existed or by a logger it is not attached to.
  175. content = redact_query_tokens(content) or ""
  176. # A header an HTTP client refused, quoted with its value.
  177. content = redact_refused_headers(content) or ""
  178. # Replace email addresses
  179. content = re.sub(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]", content)
  180. # Replace LDAP Distinguished Names (#2681) — PII on par with email.
  181. content = _LDAP_DN_PATTERN.sub("[DN]", content)
  182. # Replace Bambu Lab printer serial numbers (format: 00M/01D/01S/01P/03W + alphanumeric, 12-16 chars total)
  183. content = re.sub(r"\b0[0-3][A-Z0-9][A-Z0-9]{9,13}\b", "[SERIAL]", content, flags=re.IGNORECASE)
  184. # Replace IPv4 addresses (skip firmware versions like 01.09.01.00 which have leading zeros,
  185. # and four-number runs inside a longer dotted number, such as the LDAP StartTLS OID
  186. # 1.3.6.1.4.1.1466.20037, whose masked "[IP].4.1.1466.20037" hid what failed, #3197)
  187. content = re.sub(
  188. r"(?<!\d\.)\b(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)\b(?!\.\d)",
  189. "[IP]",
  190. content,
  191. )
  192. # Replace paths with usernames
  193. content = re.sub(r"/home/[^/\s]+/", "/home/[user]/", content)
  194. content = re.sub(r"/Users/[^/\s]+/", "/Users/[user]/", content)
  195. content = re.sub(r"/opt/[^/\s]+/", "/opt/[user]/", content)
  196. return content
  197. def _model_key(value: str) -> str:
  198. return value.upper().replace(" ", "").replace("-", "")
  199. # Every model name and code Bambuddy knows. A printer named after its model
  200. # keeps nothing private in that name, and redacting it would wipe the model
  201. # from the whole support package (#3442).
  202. _MODEL_NAME_KEYS = frozenset(
  203. _model_key(v)
  204. for v in (*PRINTER_MODEL_MAP, *PRINTER_MODEL_MAP.values(), *PRINTER_MODEL_ID_MAP, *PRINTER_MODEL_ID_MAP.values())
  205. )
  206. def _is_model_name(name: str, model: str | None) -> bool:
  207. """True when the printer name is just a model name, so not worth redacting.
  208. The printer's own model counts too, which covers a model Bambuddy does not
  209. list yet.
  210. """
  211. key = _model_key(name.strip())
  212. return key in _MODEL_NAME_KEYS or (bool(model) and key == _model_key(model.strip()))
  213. async def collect_sensitive_strings(db: AsyncSession) -> dict[str, str]:
  214. """Collect known sensitive values from the database for log redaction.
  215. Covers printer names, serial numbers, IP addresses, access codes, auth
  216. usernames, and the Bambu Cloud email. Pass the result to
  217. :func:`sanitize_log_content`.
  218. """
  219. sensitive_strings: dict[str, str] = {}
  220. # Printer names, serial numbers, IP addresses, and access codes
  221. result = await db.execute(
  222. select(Printer.name, Printer.serial_number, Printer.ip_address, Printer.access_code, Printer.model)
  223. )
  224. for name, serial, ip_address, access_code, model in result.all():
  225. if name and not _is_model_name(name, model):
  226. sensitive_strings[name] = "[PRINTER]"
  227. if serial:
  228. sensitive_strings[serial] = "[SERIAL]"
  229. if ip_address:
  230. sensitive_strings[ip_address] = "[IP]"
  231. if access_code:
  232. sensitive_strings[access_code] = "[ACCESS_CODE]"
  233. # Auth usernames
  234. result = await db.execute(select(User.username))
  235. for (username,) in result.all():
  236. if username:
  237. sensitive_strings[username] = "[USER]"
  238. # Bambu Cloud email
  239. result = await db.execute(select(Settings.value).where(Settings.key == "bambu_cloud_email"))
  240. cloud_email = result.scalar_one_or_none()
  241. if cloud_email:
  242. sensitive_strings[cloud_email] = "[EMAIL]"
  243. return sensitive_strings