File size: 6,348 Bytes
89e4531
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
# 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()