ai / _utils /_telemetry.py
celik-muhammed's picture
Upload 34 files
89e4531 verified
Raw
History Blame Contribute Delete
6.35 kB
# Authors: The scikit-plots developers
# SPDX-License-Identifier: BSD-3-Clause
"""
Privacy-safe logging helpers for bundled AI services.
The project is open source, so logging policy must remain safe even when an
attacker knows every redaction rule. The primary control is data minimization:
callers log fixed event metadata, never request/conversation bodies. The
helpers below are a defence-in-depth boundary for exception text and values
that reach logging through libraries or future code.
"""
from __future__ import annotations
import json
import logging
import re
from pathlib import Path
from types import TracebackType
from typing import Any
MAX_LOG_TEXT = 512
MAX_EXCEPTION_FRAMES = 12
MAX_EXCEPTION_MESSAGE = 256
# Keep these patterns deliberately high-confidence. Detection is a fallback,
# not permission to log sensitive data in the first place.
_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
(
re.compile(
r"-----BEGIN [^-\r\n]{1,64} PRIVATE KEY-----.*?-----END [^-\r\n]{1,64} PRIVATE KEY-----",
re.IGNORECASE | re.DOTALL,
),
"<private-key-redacted>",
),
(re.compile(r"\bBearer\s+[^\s,;]+", re.IGNORECASE), "Bearer <credential-redacted>"),
(re.compile(r"\bhf_[A-Za-z0-9]{4,}\b"), "<credential-redacted>"),
(re.compile(r"\bsk-(?:ant-)?[A-Za-z0-9_-]{8,}\b"), "<credential-redacted>"),
(
re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{8,}|github_pat_[A-Za-z0-9_]{8,})\b"),
"<credential-redacted>",
),
(re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "<credential-redacted>"),
(
re.compile(r"\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\b"),
"<credential-redacted>",
),
(
re.compile(
r"(?i)\b(?:api[_-]?key|access[_-]?token|auth[_-]?token|password|passwd|secret|token)\s*[:=]\s*[^\s,;&]+"
),
"<credential-field-redacted>",
),
(
re.compile(r"\b[A-Za-z]:[\\/](?:[^\r\n\t ]+[\\/])*[^\r\n\t ]*"),
"<local-path-redacted>",
),
(re.compile(r"\bfile://[^\s\"'<>]+", re.IGNORECASE), "<local-url-redacted>"),
(re.compile(r"\bhttps?://[^\s\"'<>]+", re.IGNORECASE), "<url-redacted>"),
(
re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.IGNORECASE),
"<email-redacted>",
),
(
re.compile(
r"\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b"
),
"<ip-redacted>",
),
)
# Field names that should not survive a structured-event helper even if the
# value happens not to match a known secret shape.
_SENSITIVE_FIELD_NAMES = frozenset(
{
"authorization",
"cookie",
"setcookie",
"token",
"edittoken",
"shareid",
"uuid",
"sessionid",
"conversationid",
"query",
"answer",
"content",
"body",
"prompt",
"messages",
"feedbackmessage",
"url",
"pageurl",
"email",
"password",
"secret",
"apikey",
"accesstoken",
}
)
def sanitize_log_text(value: Any, *, max_chars: int = MAX_LOG_TEXT) -> str:
"""Return a bounded single logical log value with high-confidence redaction."""
text = str(value or "")
# Redact before converting control characters so multi-line secret shapes
# (for example PEM private keys) are still recognized as one value.
for pattern, replacement in _PATTERNS:
text = pattern.sub(replacement, text)
# Prevent terminal/log forging while retaining a readable separator.
text = text.replace("\x00", "<nul>").replace("\r", "\\r").replace("\n", "\\n")
if len(text) > max_chars:
text = text[:max_chars] + "…<truncated>"
return text
def safe_exception_summary(
exc_info: tuple[type[BaseException], BaseException, TracebackType | None] | None,
) -> dict[str, Any] | None:
"""Return a bounded stack summary without source lines or filesystem paths."""
if not exc_info:
return None
exc_type, exc, tb = exc_info
frames: list[dict[str, Any]] = []
cur = tb
while cur is not None:
code = cur.tb_frame.f_code
frames.append(
{
"file": Path(code.co_filename).name,
"function": sanitize_log_text(code.co_name, max_chars=80),
"line": int(cur.tb_lineno),
}
)
cur = cur.tb_next
if len(frames) > MAX_EXCEPTION_FRAMES:
frames = frames[-MAX_EXCEPTION_FRAMES:]
return {
"type": sanitize_log_text(
getattr(exc_type, "__name__", "Exception"), max_chars=80
),
"message": sanitize_log_text(exc, max_chars=MAX_EXCEPTION_MESSAGE),
"frames": frames,
}
def safe_event_fields(fields: dict[str, Any] | None) -> dict[str, Any]:
"""Normalize optional structured fields, dropping sensitive field names."""
out: dict[str, Any] = {}
for key, value in (fields or {}).items():
name = str(key)
if name.lower().replace("-", "").replace("_", "") in _SENSITIVE_FIELD_NAMES:
continue
if value is None or isinstance(value, (bool, int, float)):
out[name] = value
else:
out[name] = sanitize_log_text(value, max_chars=160)
return out
class PrivacyJsonFormatter(logging.Formatter):
"""Emit bounded JSON logs with sanitized exception metadata."""
def format(self, record: logging.LogRecord) -> str: # noqa: A003
payload: dict[str, Any] = {
"ts": self.formatTime(record, datefmt="%Y-%m-%dT%H:%M:%S"),
"level": record.levelname,
"logger": sanitize_log_text(record.name, max_chars=80),
"event": sanitize_log_text(record.getMessage()),
}
summary = safe_exception_summary(record.exc_info)
if summary:
payload["exception"] = summary
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
def configure_privacy_logging(*, level: int = logging.INFO) -> logging.Logger:
"""Install one root handler with privacy-safe JSON formatting."""
handler = logging.StreamHandler()
handler.setFormatter(PrivacyJsonFormatter())
logging.root.handlers = [handler]
logging.root.setLevel(level)
return logging.getLogger()