blindlog 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- blindlog/__init__.py +6 -0
- blindlog/config.py +58 -0
- blindlog/core.py +85 -0
- blindlog/formatters.py +65 -0
- blindlog/integrations/__init__.py +0 -0
- blindlog/integrations/fastapi.py +116 -0
- blindlog/interfaces.py +21 -0
- blindlog/registry.py +69 -0
- blindlog/rules.py +102 -0
- blindlog/utils.py +109 -0
- blindlog-1.1.0.dist-info/METADATA +210 -0
- blindlog-1.1.0.dist-info/RECORD +15 -0
- blindlog-1.1.0.dist-info/WHEEL +5 -0
- blindlog-1.1.0.dist-info/licenses/LICENSE +21 -0
- blindlog-1.1.0.dist-info/top_level.txt +1 -0
blindlog/__init__.py
ADDED
blindlog/config.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import warnings
|
|
3
|
+
import hashlib
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import FrozenSet, Optional
|
|
6
|
+
from blindlog.rules import DEFAULT_SENSITIVE_KEYS
|
|
7
|
+
|
|
8
|
+
# Fix #3: Use frozenset for sensitive_keys to prevent mutation.
|
|
9
|
+
# Fix #16: Note — `object.__setattr__` is used in __post_init__ to initialize
|
|
10
|
+
# computed fields on a frozen dataclass. This is a standard Python pattern.
|
|
11
|
+
# Any external code can also use `object.__setattr__` to bypass frozen protection —
|
|
12
|
+
# this is a Python language limitation, not a runtime-enforceable security boundary.
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class BlindLogConfig:
|
|
15
|
+
secret_key: str = ""
|
|
16
|
+
salt: str = ""
|
|
17
|
+
sensitive_keys: FrozenSet[str] = field(default_factory=lambda: frozenset(DEFAULT_SENSITIVE_KEYS))
|
|
18
|
+
debug_mode: bool = False
|
|
19
|
+
|
|
20
|
+
def __post_init__(self):
|
|
21
|
+
if not self.secret_key:
|
|
22
|
+
object.__setattr__(self, 'secret_key', os.environ.get("BLINDLOG_SECRET", ""))
|
|
23
|
+
if not self.salt:
|
|
24
|
+
object.__setattr__(self, 'salt', os.environ.get("BLINDLOG_SALT", ""))
|
|
25
|
+
|
|
26
|
+
env_debug = os.environ.get("BLINDLOG_DEBUG", "").lower() in ("true", "1", "yes")
|
|
27
|
+
if env_debug and not self.debug_mode:
|
|
28
|
+
object.__setattr__(self, 'debug_mode', True)
|
|
29
|
+
|
|
30
|
+
if not self.secret_key and not self.debug_mode:
|
|
31
|
+
raise ValueError("BlindLog: BLINDLOG_SECRET is not set. Refusing to run in non-debug mode to prevent cryptographic bypass.")
|
|
32
|
+
|
|
33
|
+
object.__setattr__(self, '_blake_key_cache', self._derive_blake_key())
|
|
34
|
+
|
|
35
|
+
def _derive_blake_key(self) -> bytes:
|
|
36
|
+
safe_secret = self.secret_key or ""
|
|
37
|
+
safe_salt = self.salt or ""
|
|
38
|
+
raw = (safe_secret + safe_salt).encode('utf-8')
|
|
39
|
+
|
|
40
|
+
if len(raw) > 64:
|
|
41
|
+
warnings.warn(
|
|
42
|
+
f"BlindLog: Combined secret+salt is {len(raw)} bytes, exceeding BLAKE2b's 64-byte key limit. "
|
|
43
|
+
"Key will be derived via BLAKE2b digest to avoid silent truncation.",
|
|
44
|
+
stacklevel=5
|
|
45
|
+
)
|
|
46
|
+
raw = hashlib.blake2b(raw, digest_size=64).digest()
|
|
47
|
+
|
|
48
|
+
if len(raw) == 0:
|
|
49
|
+
warnings.warn(
|
|
50
|
+
"BlindLog: blake_key is empty. Hashes will be unkeyed and trivially reversible.",
|
|
51
|
+
stacklevel=5
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
return raw
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def blake_key(self) -> bytes:
|
|
58
|
+
return self._blake_key_cache
|
blindlog/core.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
from blindlog.rules import EMAIL_REGEX, CREDIT_CARD_REGEX, IPV4_REGEX, API_KEY_REGEX, PHONE_REGEX, SSN_REGEX
|
|
5
|
+
from blindlog.utils import walk_and_mask
|
|
6
|
+
from blindlog.config import BlindLogConfig
|
|
7
|
+
from blindlog.registry import RuleRegistry
|
|
8
|
+
from blindlog.interfaces import MaskingEngine, Payload
|
|
9
|
+
|
|
10
|
+
_log = logging.getLogger("blindlog.core")
|
|
11
|
+
|
|
12
|
+
class BlindLogger(MaskingEngine):
|
|
13
|
+
"""
|
|
14
|
+
Core engine for Deterministic Pseudonymization and Structure-Preserving Masking.
|
|
15
|
+
"""
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
config: Optional[BlindLogConfig] = None,
|
|
19
|
+
**kwargs
|
|
20
|
+
):
|
|
21
|
+
self.config = config if config else BlindLogConfig(**kwargs)
|
|
22
|
+
|
|
23
|
+
self.registry = RuleRegistry(keyed_hash_fn=lambda v: f"blind:{self._hash(v, length=10)}")
|
|
24
|
+
self.registry.register(EMAIL_REGEX, self._mask_email)
|
|
25
|
+
self.registry.register(CREDIT_CARD_REGEX, self._mask_cc)
|
|
26
|
+
self.registry.register(IPV4_REGEX, lambda v: f"blnd_ip_{self._hash(v, length=10)}")
|
|
27
|
+
self.registry.register(API_KEY_REGEX, lambda v: f"blnd_key_{self._hash(v, length=10)}")
|
|
28
|
+
self.registry.register(PHONE_REGEX, lambda v: f"blnd_ph_{self._hash(v, length=10)}")
|
|
29
|
+
self.registry.register(SSN_REGEX, lambda v: f"blnd_ssn_{self._hash(v, length=10)}")
|
|
30
|
+
|
|
31
|
+
def _hash(self, value: str, length: int = 12) -> str:
|
|
32
|
+
if length < 1:
|
|
33
|
+
raise ValueError(f"BlindLog: hash length must be >= 1, got {length}")
|
|
34
|
+
|
|
35
|
+
if not isinstance(value, str):
|
|
36
|
+
value = str(value)
|
|
37
|
+
|
|
38
|
+
digest_bytes = max(1, (length + 1) // 2)
|
|
39
|
+
h = hashlib.blake2b(value.encode('utf-8'), key=self.config.blake_key, digest_size=digest_bytes)
|
|
40
|
+
|
|
41
|
+
return h.hexdigest()[:length]
|
|
42
|
+
|
|
43
|
+
def _mask_email(self, email: str) -> str:
|
|
44
|
+
"""Structure-preserving mask for emails: blnd_ref_HASH...@masked.com"""
|
|
45
|
+
try:
|
|
46
|
+
local, domain = email.rsplit('@', 1)
|
|
47
|
+
hash_val = self._hash(email, length=12)
|
|
48
|
+
return f"blnd_ref_{hash_val}...@masked.com"
|
|
49
|
+
except ValueError:
|
|
50
|
+
return f"blnd_ref_{self._hash(email)}...@masked.com"
|
|
51
|
+
|
|
52
|
+
def _mask_cc(self, cc: str) -> str:
|
|
53
|
+
"""Structure-preserving mask for credit cards: 4111-abcdef-abcdef-1234"""
|
|
54
|
+
clean_cc = cc.replace("-", "").replace(" ", "")
|
|
55
|
+
if len(clean_cc) >= 12:
|
|
56
|
+
first_4 = clean_cc[:4]
|
|
57
|
+
last_4 = clean_cc[-4:]
|
|
58
|
+
hash_val = self._hash(clean_cc, length=12)
|
|
59
|
+
return f"{first_4}-{hash_val[:6]}-{hash_val[6:12]}-{last_4}"
|
|
60
|
+
return self._hash(cc)
|
|
61
|
+
|
|
62
|
+
def _mask_value(self, value: str) -> str:
|
|
63
|
+
if len(value) > 10000:
|
|
64
|
+
return "<Skipped due to length: ReDoS Protection>"
|
|
65
|
+
|
|
66
|
+
fallback_hash = lambda v: f"blind:{self._hash(v, length=10)}"
|
|
67
|
+
return self.registry.process_value(value, fallback_hash)
|
|
68
|
+
|
|
69
|
+
def _mask_slow_path(self, text: str) -> str:
|
|
70
|
+
if len(text) > 10000:
|
|
71
|
+
return "<Skipped due to length: ReDoS Protection>"
|
|
72
|
+
|
|
73
|
+
return self.registry.process_text(text)
|
|
74
|
+
|
|
75
|
+
def mask(self, payload: Payload) -> Payload:
|
|
76
|
+
"""Public endpoint to mask a payload."""
|
|
77
|
+
if self.config.debug_mode:
|
|
78
|
+
return payload
|
|
79
|
+
|
|
80
|
+
return walk_and_mask(
|
|
81
|
+
data=payload,
|
|
82
|
+
sensitive_keys=self.config.sensitive_keys,
|
|
83
|
+
mask_value_fn=self._mask_value,
|
|
84
|
+
mask_text_fn=self._mask_slow_path
|
|
85
|
+
)
|
blindlog/formatters.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import copy
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from blindlog.interfaces import MaskingEngine
|
|
5
|
+
|
|
6
|
+
_log = logging.getLogger("blindlog.formatter")
|
|
7
|
+
|
|
8
|
+
class BlindLogFormatter(logging.Formatter):
|
|
9
|
+
"""
|
|
10
|
+
A custom logging formatter that intercepts and pseudonymizes sensitive data
|
|
11
|
+
before it is written to the log stream.
|
|
12
|
+
"""
|
|
13
|
+
def __init__(self, fmt: Optional[str] = None, datefmt: Optional[str] = None, style: str = '%', blind_logger: Optional[MaskingEngine] = None):
|
|
14
|
+
super().__init__(fmt, datefmt, style)
|
|
15
|
+
self._blind_logger = blind_logger
|
|
16
|
+
self._initialized = blind_logger is not None
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def blind_logger(self):
|
|
20
|
+
if not self._initialized:
|
|
21
|
+
from blindlog.core import BlindLogger
|
|
22
|
+
self._blind_logger = BlindLogger()
|
|
23
|
+
self._initialized = True
|
|
24
|
+
return self._blind_logger
|
|
25
|
+
|
|
26
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
27
|
+
record_copy = copy.copy(record)
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
# Mask the message body
|
|
31
|
+
if isinstance(record_copy.msg, (dict, list)):
|
|
32
|
+
record_copy.msg = self.blind_logger.mask(copy.deepcopy(record_copy.msg))
|
|
33
|
+
elif isinstance(record_copy.msg, str):
|
|
34
|
+
record_copy.msg = self.blind_logger.mask(record_copy.msg)
|
|
35
|
+
|
|
36
|
+
# Fix #1: Handle both tuple and dict-style log args
|
|
37
|
+
if record_copy.args:
|
|
38
|
+
args_copy = copy.deepcopy(record_copy.args)
|
|
39
|
+
if isinstance(args_copy, dict):
|
|
40
|
+
# Mapping style: logger.info("%(name)s logged in", {"name": "user@example.com"})
|
|
41
|
+
record_copy.args = {
|
|
42
|
+
k: self.blind_logger.mask(v) if isinstance(v, (str, dict, list)) else v
|
|
43
|
+
for k, v in args_copy.items()
|
|
44
|
+
}
|
|
45
|
+
elif isinstance(args_copy, tuple):
|
|
46
|
+
# Positional style: logger.info("Hello %s", "user@example.com")
|
|
47
|
+
record_copy.args = tuple(
|
|
48
|
+
self.blind_logger.mask(arg) if isinstance(arg, (str, dict, list)) else arg
|
|
49
|
+
for arg in args_copy
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Fix #2: Mask exception info to prevent PII leaks in stack traces
|
|
53
|
+
if record_copy.exc_info and record_copy.exc_info[0] is not None:
|
|
54
|
+
exc_text = self.formatException(record_copy.exc_info)
|
|
55
|
+
record_copy.exc_text = self.blind_logger.mask(exc_text)
|
|
56
|
+
record_copy.exc_info = None # Prevent super() from re-formatting unmasked
|
|
57
|
+
|
|
58
|
+
except Exception as e:
|
|
59
|
+
_log.error("BlindLog: Masking failed. Reason: %s", e)
|
|
60
|
+
record_copy.msg = "[BLINDLOG MASKING FAILED - RECORD SUPPRESSED]"
|
|
61
|
+
record_copy.args = None
|
|
62
|
+
record_copy.exc_info = None
|
|
63
|
+
record_copy.exc_text = None
|
|
64
|
+
|
|
65
|
+
return super().format(record_copy)
|
|
File without changes
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from blindlog.core import BlindLogger
|
|
4
|
+
from blindlog.interfaces import MaskingEngine
|
|
5
|
+
|
|
6
|
+
_log = logging.getLogger("blindlog.middleware")
|
|
7
|
+
|
|
8
|
+
class BlindLogFastAPIMiddleware:
|
|
9
|
+
"""
|
|
10
|
+
Pure ASGI Middleware to intercept and mask request/response bodies and metadata.
|
|
11
|
+
|
|
12
|
+
Known Limitation: Streaming responses (SSE, chunked transfer) may have PII split
|
|
13
|
+
across multiple body chunks. Chunk-by-chunk inspection cannot detect PII that spans
|
|
14
|
+
chunk boundaries.
|
|
15
|
+
"""
|
|
16
|
+
def __init__(self, app, blind_logger: MaskingEngine = None):
|
|
17
|
+
self.app = app
|
|
18
|
+
self._blind_logger = blind_logger
|
|
19
|
+
self._initialized = blind_logger is not None
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def blind_logger(self):
|
|
23
|
+
if not self._initialized:
|
|
24
|
+
self._blind_logger = BlindLogger()
|
|
25
|
+
self._initialized = True
|
|
26
|
+
return self._blind_logger
|
|
27
|
+
|
|
28
|
+
async def __call__(self, scope, receive, send):
|
|
29
|
+
if scope["type"] != "http":
|
|
30
|
+
return await self.app(scope, receive, send)
|
|
31
|
+
|
|
32
|
+
raw_headers = scope.get("headers", [])
|
|
33
|
+
|
|
34
|
+
# Extract content-length for OOM check
|
|
35
|
+
headers_dict = dict(raw_headers)
|
|
36
|
+
content_length_str = headers_dict.get(b"content-length", b"0").decode("utf-8", errors="ignore")
|
|
37
|
+
content_length = int(content_length_str) if content_length_str.isdigit() else 0
|
|
38
|
+
|
|
39
|
+
if content_length > 5_000_000:
|
|
40
|
+
_log.warning("BlindLog: Payload omitted from logging due to exceeding 5MB safety limit.")
|
|
41
|
+
return await self.app(scope, receive, send)
|
|
42
|
+
|
|
43
|
+
body_bytes = b""
|
|
44
|
+
more_body = True
|
|
45
|
+
messages = []
|
|
46
|
+
|
|
47
|
+
async def send_proxy(message):
|
|
48
|
+
if message["type"] == "http.response.start":
|
|
49
|
+
_log.info(f"BlindLog [Response Status]: {message['status']}")
|
|
50
|
+
elif message["type"] == "http.response.body":
|
|
51
|
+
resp_body = message.get("body", b"")
|
|
52
|
+
if resp_body and len(resp_body) < 5_000_000:
|
|
53
|
+
try:
|
|
54
|
+
parsed_resp = json.loads(resp_body)
|
|
55
|
+
masked_resp = self.blind_logger.mask(parsed_resp)
|
|
56
|
+
_log.info(f"BlindLog [Response Body]: {json.dumps(masked_resp)}")
|
|
57
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
58
|
+
_log.debug("BlindLog [Response Body]: <non-JSON response, skipped>")
|
|
59
|
+
elif resp_body:
|
|
60
|
+
_log.warning("BlindLog: Response body exceeds 5MB, skipping masking.")
|
|
61
|
+
await send(message)
|
|
62
|
+
|
|
63
|
+
while more_body:
|
|
64
|
+
message = await receive()
|
|
65
|
+
messages.append(message)
|
|
66
|
+
if message["type"] == "http.request":
|
|
67
|
+
body_bytes += message.get("body", b"")
|
|
68
|
+
|
|
69
|
+
if len(body_bytes) > 5_000_000:
|
|
70
|
+
_log.warning("BlindLog: OOM Protection Tripped! Chunked stream exceeds 5MB. Logging aborted.")
|
|
71
|
+
body_bytes = b""
|
|
72
|
+
|
|
73
|
+
async def dynamic_receive_proxy():
|
|
74
|
+
if messages:
|
|
75
|
+
return messages.pop(0)
|
|
76
|
+
return await receive()
|
|
77
|
+
|
|
78
|
+
return await self.app(scope, dynamic_receive_proxy, send_proxy)
|
|
79
|
+
|
|
80
|
+
more_body = message.get("more_body", False)
|
|
81
|
+
else:
|
|
82
|
+
more_body = False
|
|
83
|
+
|
|
84
|
+
if body_bytes:
|
|
85
|
+
try:
|
|
86
|
+
parsed_body = json.loads(body_bytes)
|
|
87
|
+
masked_request_body = self.blind_logger.mask(parsed_body)
|
|
88
|
+
_log.info(f"BlindLog [Request Body]: {json.dumps(masked_request_body)}")
|
|
89
|
+
except json.JSONDecodeError:
|
|
90
|
+
if len(body_bytes) < 5_000_000:
|
|
91
|
+
masked_string = self.blind_logger.mask(body_bytes.decode('utf-8', errors='ignore'))
|
|
92
|
+
_log.info(f"BlindLog [Request Body]: {masked_string}")
|
|
93
|
+
|
|
94
|
+
# Fix #8: Use list-of-tuples to preserve duplicate headers
|
|
95
|
+
# Fix #9: Use errors='replace' for non-UTF-8 header values
|
|
96
|
+
decoded_headers = [
|
|
97
|
+
(k.decode('utf-8', errors='replace'), v.decode('utf-8', errors='replace'))
|
|
98
|
+
for k, v in raw_headers
|
|
99
|
+
]
|
|
100
|
+
# Mask each header value individually using the key context, logging as list of pairs
|
|
101
|
+
masked_headers = []
|
|
102
|
+
for k, v in decoded_headers:
|
|
103
|
+
if isinstance(v, str):
|
|
104
|
+
masked_dict = self.blind_logger.mask({k: v})
|
|
105
|
+
masked_headers.append((k, masked_dict.get(k, v)))
|
|
106
|
+
else:
|
|
107
|
+
masked_headers.append((k, v))
|
|
108
|
+
_log.info(f"BlindLog [Headers]: {json.dumps(masked_headers)}")
|
|
109
|
+
|
|
110
|
+
# Forward real receive() (including http.disconnect) after buffer is exhausted
|
|
111
|
+
async def receive_proxy():
|
|
112
|
+
if messages:
|
|
113
|
+
return messages.pop(0)
|
|
114
|
+
return await receive()
|
|
115
|
+
|
|
116
|
+
await self.app(scope, receive_proxy, send_proxy)
|
blindlog/interfaces.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any, Union
|
|
3
|
+
|
|
4
|
+
# Fix #14: Payload type alias — now applied to the abstract method signature
|
|
5
|
+
Payload = Union[str, int, float, bool, None, dict, list]
|
|
6
|
+
|
|
7
|
+
class MaskingEngine(ABC):
|
|
8
|
+
"""
|
|
9
|
+
Formal Interface contract for Privacy-Preserving Observability engines.
|
|
10
|
+
Enforces standardized execution methods to allow developers to safely substitute
|
|
11
|
+
the default deterministic hash engine with proprietary ones (like AWS KMS or HashiCorp Vault)
|
|
12
|
+
without disrupting downstream middleware plugins.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def mask(self, payload: Payload) -> Payload:
|
|
17
|
+
"""
|
|
18
|
+
Recursively process a payload (String, Dictionary, or List)
|
|
19
|
+
and securely blind all matched sensitive nodes.
|
|
20
|
+
"""
|
|
21
|
+
pass
|
blindlog/registry.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from typing import Callable, List, Tuple, Optional
|
|
2
|
+
import re
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
_log = logging.getLogger("blindlog.registry")
|
|
6
|
+
|
|
7
|
+
MaskCallback = Callable[[str], str]
|
|
8
|
+
|
|
9
|
+
class RuleRegistry:
|
|
10
|
+
"""
|
|
11
|
+
Extensible configuration registry supporting mapping of deterministic
|
|
12
|
+
cryptographic functions to custom user-provided Regex patterns.
|
|
13
|
+
"""
|
|
14
|
+
def __init__(self, keyed_hash_fn: Optional[MaskCallback] = None):
|
|
15
|
+
self._rules: List[Tuple[re.Pattern, MaskCallback]] = []
|
|
16
|
+
self._keyed_hash_fn = keyed_hash_fn
|
|
17
|
+
|
|
18
|
+
def register(self, pattern: re.Pattern, callback: MaskCallback):
|
|
19
|
+
"""Registers a customized fallback regex structure."""
|
|
20
|
+
self._rules.append((pattern, callback))
|
|
21
|
+
|
|
22
|
+
def _is_already_masked(self, value: str) -> bool:
|
|
23
|
+
"""Check if a value has already been masked to ensure idempotency."""
|
|
24
|
+
from blindlog.rules import MASKED_PATTERN
|
|
25
|
+
return bool(MASKED_PATTERN.match(value))
|
|
26
|
+
|
|
27
|
+
def _secure_fallback(self, value: str) -> str:
|
|
28
|
+
"""Produce a keyed fallback hash."""
|
|
29
|
+
if self._keyed_hash_fn:
|
|
30
|
+
return self._keyed_hash_fn(value)
|
|
31
|
+
import hashlib
|
|
32
|
+
_log.warning("BlindLog: Using unkeyed fallback hash — keyed_hash_fn not configured.")
|
|
33
|
+
return f"blind:{hashlib.blake2b(value.encode(), digest_size=5).hexdigest()}"
|
|
34
|
+
|
|
35
|
+
def process_value(self, value: str, fallback_callback: MaskCallback) -> str:
|
|
36
|
+
"""
|
|
37
|
+
Executes strict Format Preserving matching. Escapes early if matched.
|
|
38
|
+
Wraps callbacks in try/except. Skips already-masked values.
|
|
39
|
+
"""
|
|
40
|
+
# Fix #10: More precise idempotency check — require sentinel + hex pattern
|
|
41
|
+
if self._is_already_masked(value):
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
for pattern, callback in self._rules:
|
|
45
|
+
if pattern.fullmatch(value):
|
|
46
|
+
try:
|
|
47
|
+
return callback(value)
|
|
48
|
+
except Exception as e:
|
|
49
|
+
_log.warning(f"BlindLog: Callback failed for pattern {pattern.pattern}: {e}. Using fallback.")
|
|
50
|
+
return fallback_callback(value)
|
|
51
|
+
return fallback_callback(value)
|
|
52
|
+
|
|
53
|
+
def process_text(self, text: str) -> str:
|
|
54
|
+
"""
|
|
55
|
+
Cumulatively masks all recognized embedded objects within a standard string block.
|
|
56
|
+
On callback failure, applies a keyed fallback hash.
|
|
57
|
+
"""
|
|
58
|
+
for pattern, callback in self._rules:
|
|
59
|
+
def safe_sub(match, cb=callback):
|
|
60
|
+
matched = match.group(0)
|
|
61
|
+
if self._is_already_masked(matched):
|
|
62
|
+
return matched
|
|
63
|
+
try:
|
|
64
|
+
return cb(matched)
|
|
65
|
+
except Exception as e:
|
|
66
|
+
_log.warning(f"BlindLog: Callback failed during text scan: {e}. Applying keyed fallback.")
|
|
67
|
+
return self._secure_fallback(matched)
|
|
68
|
+
text = pattern.sub(safe_sub, text)
|
|
69
|
+
return text
|
blindlog/rules.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from functools import lru_cache
|
|
3
|
+
|
|
4
|
+
# Optimized regular expressions for identifying PII within free-form text
|
|
5
|
+
|
|
6
|
+
# Email regex — no TLD upper bound, fixed character class
|
|
7
|
+
EMAIL_REGEX = re.compile(r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b')
|
|
8
|
+
|
|
9
|
+
# Credit Card: Standard 16 digit cards (Visa, Mastercard, Discover), optional separators
|
|
10
|
+
# Note: Pure regex CC detection cannot perform Luhn validation. This will match any
|
|
11
|
+
# 16-digit number in groups of 4. For environments with heavy numeric IDs, consider
|
|
12
|
+
# disabling this rule or adding Luhn validation via a custom callback.
|
|
13
|
+
CREDIT_CARD_REGEX = re.compile(r'\b(?:\d{4}[-\s]?){3}\d{4}\b')
|
|
14
|
+
|
|
15
|
+
# IPv4 with proper octet range validation (0-255)
|
|
16
|
+
IPV4_REGEX = re.compile(
|
|
17
|
+
r'\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b'
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Expanded API key regex covering major real-world formats
|
|
21
|
+
API_KEY_REGEX = re.compile(
|
|
22
|
+
r'\b(?:'
|
|
23
|
+
r'sk-[a-zA-Z0-9]{20,80}' # OpenAI
|
|
24
|
+
r'|sk_(?:live|test)_[a-zA-Z0-9]+' # Stripe
|
|
25
|
+
r'|ghp_[a-zA-Z0-9]{36}' # GitHub PAT classic
|
|
26
|
+
r'|github_pat_[a-zA-Z0-9_]{80,100}' # GitHub fine-grained
|
|
27
|
+
r'|AKIA[A-Z0-9]{16}' # AWS Access Key ID
|
|
28
|
+
r'|xox[bpas]-[a-zA-Z0-9-]{20,}' # Slack tokens (min 20 chars)
|
|
29
|
+
r')\b'
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Fix #5 + #6 + #14: Phone regex with improved international support
|
|
33
|
+
# Uses lookbehind/lookahead instead of \b to properly capture the + prefix
|
|
34
|
+
# Note: 10-digit standalone numbers may false-positive (inherent regex limitation, documented)
|
|
35
|
+
PHONE_REGEX = re.compile(
|
|
36
|
+
r'(?<!\w)(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}(?!\w)'
|
|
37
|
+
r'|'
|
|
38
|
+
r'\+\d{1,3}(?:[-.\s]\d+){1,5}(?!\w)'
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Fix #12: SSN regex (US format)
|
|
42
|
+
SSN_REGEX = re.compile(r'\b\d{3}-\d{2}-\d{4}\b')
|
|
43
|
+
|
|
44
|
+
# Collision-resistant sentinel prefixes for idempotency
|
|
45
|
+
MASKED_PREFIXES = ("blnd_ref_", "blind:", "blnd_ip_", "blnd_key_", "blnd_ph_", "blnd_ssn_")
|
|
46
|
+
|
|
47
|
+
# Strict idempotency validation pattern matching generated masks exactly
|
|
48
|
+
MASKED_PATTERN = re.compile(
|
|
49
|
+
r'^('
|
|
50
|
+
r'blnd_ref_[0-9a-f]{12}\.\.\.@masked\.com'
|
|
51
|
+
r'|blind:[0-9a-f]{10}'
|
|
52
|
+
r'|blnd_ip_[0-9a-f]{10}'
|
|
53
|
+
r'|blnd_key_[0-9a-f]{10}'
|
|
54
|
+
r'|blnd_ph_[0-9a-f]{10}'
|
|
55
|
+
r'|blnd_ssn_[0-9a-f]{10}'
|
|
56
|
+
r')$'
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Fix #7: Removed generic 'key' — replaced with specific sensitive key variants
|
|
60
|
+
# Fix #11: Removed 'cell' and 'telephone' — too many false positives (fuel_cell, etc.)
|
|
61
|
+
# Fix #15: Added 'auth_code' for OAuth2 coverage
|
|
62
|
+
DEFAULT_SENSITIVE_KEYS = frozenset({
|
|
63
|
+
"password",
|
|
64
|
+
"secret",
|
|
65
|
+
"token",
|
|
66
|
+
"api_key",
|
|
67
|
+
"ssn",
|
|
68
|
+
"credit_card",
|
|
69
|
+
"cc_number",
|
|
70
|
+
"email",
|
|
71
|
+
"authorization",
|
|
72
|
+
"credentials",
|
|
73
|
+
"phone",
|
|
74
|
+
"mobile",
|
|
75
|
+
"authorization_code",
|
|
76
|
+
"auth_code",
|
|
77
|
+
"private_key",
|
|
78
|
+
"secret_key",
|
|
79
|
+
"signing_key",
|
|
80
|
+
"encryption_key",
|
|
81
|
+
"ssn_number",
|
|
82
|
+
"cookie",
|
|
83
|
+
"set_cookie",
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
# Fix #4: Two-pass camelCase normalization for acronyms
|
|
87
|
+
_CAMEL_RE1 = re.compile(r'([A-Z]+)([A-Z][a-z])') # XMLParser → XML_Parser
|
|
88
|
+
_CAMEL_RE2 = re.compile(r'([a-z\d])([A-Z])') # camelCase → camel_Case
|
|
89
|
+
|
|
90
|
+
# Fix #13: Cache normalized keys for performance
|
|
91
|
+
@lru_cache(maxsize=2048)
|
|
92
|
+
def normalize_key(k: str) -> str:
|
|
93
|
+
"""
|
|
94
|
+
Normalize a key to snake_case for sensitive key matching.
|
|
95
|
+
'refreshToken' -> 'refresh_token'
|
|
96
|
+
'APIKey' -> 'api_key'
|
|
97
|
+
'OAuth2Token' -> 'oauth2_token'
|
|
98
|
+
'user.password' -> 'user.password'
|
|
99
|
+
"""
|
|
100
|
+
k = _CAMEL_RE1.sub(r'\1_\2', k)
|
|
101
|
+
k = _CAMEL_RE2.sub(r'\1_\2', k)
|
|
102
|
+
return k.replace('-', '_').lower()
|
blindlog/utils.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Set, Callable
|
|
2
|
+
from blindlog.rules import normalize_key
|
|
3
|
+
|
|
4
|
+
def walk_and_mask(
|
|
5
|
+
data: Any,
|
|
6
|
+
sensitive_keys: Set[str],
|
|
7
|
+
mask_value_fn: Callable[[str], str],
|
|
8
|
+
mask_text_fn: Callable[[str], str],
|
|
9
|
+
visited: set = None,
|
|
10
|
+
force_mask: bool = False
|
|
11
|
+
) -> Any:
|
|
12
|
+
"""
|
|
13
|
+
Recursively walks through dictionaries and lists to mask sensitive data.
|
|
14
|
+
Implements DFS with cycle detection to prevent infinite recursion on self-referencing objects.
|
|
15
|
+
|
|
16
|
+
Hybrid Strategy:
|
|
17
|
+
- Fast Path: If key matches (exact, suffix with _/-./ delimiters, or camelCase normalized), mask value.
|
|
18
|
+
- Slow Path: If string value, scans using regex via mask_text_fn.
|
|
19
|
+
"""
|
|
20
|
+
if visited is None:
|
|
21
|
+
visited = set()
|
|
22
|
+
|
|
23
|
+
# Fix #6: Use null byte sentinel for type-prefixed masking (cannot appear in JSON)
|
|
24
|
+
if data is None:
|
|
25
|
+
if force_mask:
|
|
26
|
+
return mask_value_fn("\x00NoneType\x00")
|
|
27
|
+
return data
|
|
28
|
+
|
|
29
|
+
if isinstance(data, bool) and not force_mask:
|
|
30
|
+
return data
|
|
31
|
+
if isinstance(data, bool) and force_mask:
|
|
32
|
+
return mask_value_fn(f"\x00bool\x00{data}")
|
|
33
|
+
|
|
34
|
+
if isinstance(data, (int, float)) and not force_mask:
|
|
35
|
+
return data
|
|
36
|
+
|
|
37
|
+
# Cycle detection
|
|
38
|
+
obj_id = id(data)
|
|
39
|
+
if obj_id in visited:
|
|
40
|
+
return "<CircularReference>"
|
|
41
|
+
|
|
42
|
+
visited.add(obj_id)
|
|
43
|
+
|
|
44
|
+
if isinstance(data, dict):
|
|
45
|
+
masked_dict = {}
|
|
46
|
+
for k, v in data.items():
|
|
47
|
+
# Fix #3 + #4: Normalize camelCase to snake_case before matching
|
|
48
|
+
k_normalized = normalize_key(str(k))
|
|
49
|
+
|
|
50
|
+
# Exact match, suffix match (with _, -, . delimiters)
|
|
51
|
+
is_sensitive = (
|
|
52
|
+
force_mask
|
|
53
|
+
or k_normalized in sensitive_keys
|
|
54
|
+
or any(
|
|
55
|
+
k_normalized.endswith("_" + s)
|
|
56
|
+
or k_normalized.endswith("-" + s)
|
|
57
|
+
or k_normalized.endswith("." + s)
|
|
58
|
+
for s in sensitive_keys
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if is_sensitive:
|
|
63
|
+
if isinstance(v, str):
|
|
64
|
+
masked_dict[k] = mask_value_fn(v)
|
|
65
|
+
elif isinstance(v, (int, float)):
|
|
66
|
+
masked_dict[k] = mask_value_fn(str(v))
|
|
67
|
+
elif isinstance(v, bool):
|
|
68
|
+
masked_dict[k] = mask_value_fn(f"\x00bool\x00{v}")
|
|
69
|
+
elif v is None:
|
|
70
|
+
masked_dict[k] = mask_value_fn("\x00NoneType\x00")
|
|
71
|
+
else:
|
|
72
|
+
masked_dict[k] = walk_and_mask(v, sensitive_keys, mask_value_fn, mask_text_fn, visited, force_mask=True)
|
|
73
|
+
else:
|
|
74
|
+
masked_dict[k] = walk_and_mask(v, sensitive_keys, mask_value_fn, mask_text_fn, visited, force_mask=False)
|
|
75
|
+
|
|
76
|
+
visited.remove(obj_id)
|
|
77
|
+
return masked_dict
|
|
78
|
+
|
|
79
|
+
elif isinstance(data, list):
|
|
80
|
+
masked_list = [walk_and_mask(item, sensitive_keys, mask_value_fn, mask_text_fn, visited, force_mask=force_mask) for item in data]
|
|
81
|
+
visited.remove(obj_id)
|
|
82
|
+
return masked_list
|
|
83
|
+
|
|
84
|
+
elif isinstance(data, bytes):
|
|
85
|
+
try:
|
|
86
|
+
decoded = data.decode('utf-8', errors='ignore')
|
|
87
|
+
result = mask_value_fn(decoded) if force_mask else mask_text_fn(decoded)
|
|
88
|
+
visited.remove(obj_id)
|
|
89
|
+
return result
|
|
90
|
+
except Exception:
|
|
91
|
+
visited.remove(obj_id)
|
|
92
|
+
return data
|
|
93
|
+
|
|
94
|
+
elif isinstance(data, str):
|
|
95
|
+
if force_mask:
|
|
96
|
+
result = mask_value_fn(data)
|
|
97
|
+
else:
|
|
98
|
+
result = mask_text_fn(data)
|
|
99
|
+
visited.remove(obj_id)
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
# Fallback for primitive types forced to be masked
|
|
103
|
+
if force_mask and isinstance(data, (int, float)):
|
|
104
|
+
result = mask_value_fn(str(data))
|
|
105
|
+
visited.remove(obj_id)
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
visited.remove(obj_id)
|
|
109
|
+
return data
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: blindlog
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Deterministic privacy-preserving logger for Python.
|
|
5
|
+
Project-URL: Repository, https://github.com/A-P-Shukla/Blind-Log
|
|
6
|
+
Project-URL: Bug Tracker, https://github.com/A-P-Shukla/Blind-Log/issues
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Provides-Extra: fastapi
|
|
11
|
+
Requires-Dist: fastapi; extra == "fastapi"
|
|
12
|
+
Requires-Dist: starlette; extra == "fastapi"
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# BlindLog v1.1
|
|
16
|
+
|
|
17
|
+
[](https://github.com/A-P-Shukla/Blind-Log)
|
|
18
|
+
|
|
19
|
+
BlindLog is a **zero-dependency, production-ready Privacy-Preserving Observability SDK** for Python.
|
|
20
|
+
|
|
21
|
+
It solves the fundamental conflict in backend engineering: The developer needs to see everything to fix bugs, but compliance constraints (GDPR/HIPAA/SOC2) dictate that you cannot see anything personal.
|
|
22
|
+
|
|
23
|
+
By replacing raw Personal Identifiable Information (PII) with consistent, structure-preserving deterministic hashes, developers retain perfect system observability without leaking actual identities into application logs.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 💡 The "Why": Why Use BlindLog?
|
|
28
|
+
|
|
29
|
+
### The Problem with Redaction
|
|
30
|
+
Legacy redaction tools look for emails or credit cards and replace them with static text like `*****` or `[REDACTED]`. The fatal flaw here is **context destruction**. If your logs show `[REDACTED] failed to purchase order [REDACTED]`, you cannot trace a specific user's journey through your microservices when every trace of their identity maps to the exact same generic string.
|
|
31
|
+
|
|
32
|
+
### The BlindLog Solution: Deterministic Pseudonymization
|
|
33
|
+
BlindLog uses natively-keyed **BLAKE2b cryptography** to consistently map data:
|
|
34
|
+
- `user1@gmail.com` **always** logs as `blnd_ref_8ax92bfac000...@masked.com`.
|
|
35
|
+
- `user2@gmail.com` **always** logs as `blnd_ref_1c89f81ba000...@masked.com`.
|
|
36
|
+
|
|
37
|
+
You instantly know if the *same* user triggered 50 errors across 4 microservices over a week, while remaining legally compliant because the raw identity is cryptographically destroyed.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 🚀 Installation
|
|
42
|
+
|
|
43
|
+
BlindLog has zero external dependencies and runs natively on Python 3.8+.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install blindlog
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## 🛠️ Exactly How to Use It
|
|
52
|
+
|
|
53
|
+
BlindLog is built on an extensible architecture that operates automatically once plugged into your existing application. It intercepts data, traverses JSON payloads recursively, and masks strings without breaking schema.
|
|
54
|
+
|
|
55
|
+
### 1. Mandatory Security Configuration
|
|
56
|
+
BlindLog operates on keyed hashes. To prevent rainbow-table reverse-engineering, you **must** supply a cryptographic secret.
|
|
57
|
+
|
|
58
|
+
Set the following environment variables on your production servers:
|
|
59
|
+
```bash
|
|
60
|
+
export BLINDLOG_SECRET="your-super-strong-random-secret-key"
|
|
61
|
+
export BLINDLOG_SALT="optional-additional-salt"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
> **Warning:** If `BLINDLOG_SECRET` is missing, BlindLog will violently crash on boot to protect your system from generating reversible, unkeyed hashes. For local development, you can set `export BLINDLOG_DEBUG="true"` to bypass this crash.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
### 2. Standard Python Logging Interception
|
|
69
|
+
|
|
70
|
+
BlindLog ships with a `logging.Formatter` that hooks directly into Python's native `logging` module. It intercepts all string messages, dictionary `args`, and even `Exception` tracebacks to scrub PII before it hits your terminal or logging aggregator (like Datadog/Elasticsearch).
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
import logging
|
|
74
|
+
from blindlog.formatters import BlindLogFormatter
|
|
75
|
+
|
|
76
|
+
# 1. Initialize your logger
|
|
77
|
+
logger = logging.getLogger("my_application")
|
|
78
|
+
logger.setLevel(logging.INFO)
|
|
79
|
+
|
|
80
|
+
# 2. Create a handler
|
|
81
|
+
handler = logging.StreamHandler()
|
|
82
|
+
|
|
83
|
+
# 3. Attach the BlindLogFormatter!
|
|
84
|
+
handler.setFormatter(BlindLogFormatter())
|
|
85
|
+
logger.addHandler(handler)
|
|
86
|
+
|
|
87
|
+
# Usage A: Standard Strings (Slow Path - Regex Scanning)
|
|
88
|
+
logger.info("Failed login for akhand@gmail.com on card 4111-2222-3333-4444")
|
|
89
|
+
# Output: Failed login for blnd_ref_8a9df2c00000...@masked.com on card 4111-c918a2-f8b1c4-4444
|
|
90
|
+
|
|
91
|
+
# Usage B: Dictionary Arguments (Fast Path - Key Matching)
|
|
92
|
+
# BlindLog detects keys like 'password' or 'email' instantly.
|
|
93
|
+
logger.info("User created", {"email": "ceo@corp.com", "password": "super-secret"})
|
|
94
|
+
# Output: User created {'email': 'blnd_ref_9bf... masked', 'password': 'blind:838ab...'}
|
|
95
|
+
|
|
96
|
+
# Usage C: Safe Exceptions
|
|
97
|
+
try:
|
|
98
|
+
raise ValueError("User akhand@gmail.com exhausted their API quota")
|
|
99
|
+
except ValueError:
|
|
100
|
+
logger.exception("A system error occurred")
|
|
101
|
+
# Output: The stack trace is fully processed, and akhand@gmail.com is masked inside the Traceback!
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
### 3. FastAPI & Starlette Middleware
|
|
107
|
+
|
|
108
|
+
BlindLog acts as a **Pure ASGI Middleware**. It intercepts raw HTTP traffic *before* it hits your application routers. It safely buffers HTTP payloads (up to 5MB to prevent OOM DOS) and logs masked Request Bodies, Response Bodies, and Headers.
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from fastapi import FastAPI
|
|
112
|
+
from blindlog.integrations.fastapi import BlindLogFastAPIMiddleware
|
|
113
|
+
|
|
114
|
+
app = FastAPI()
|
|
115
|
+
|
|
116
|
+
# Attach the middleware
|
|
117
|
+
app.add_middleware(BlindLogFastAPIMiddleware)
|
|
118
|
+
|
|
119
|
+
@app.post("/checkout")
|
|
120
|
+
async def checkout(payload: dict):
|
|
121
|
+
# If the user sends {"credit_card": "4111-...", "cookie": "session_123"},
|
|
122
|
+
# The middleware automatically logs the sanitized payload to standard out.
|
|
123
|
+
return {"status": "success"}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**What the Middleware handles automatically:**
|
|
127
|
+
- **Request/Response Bodies:** Deeply nested JSON is recursively traversed and masked.
|
|
128
|
+
- **HTTP Headers:** Sensitive context headers (like `Authorization`, `Cookie`, `X-API-Key`) are extracted and encrypted without losing duplicate associations.
|
|
129
|
+
- **Streaming Protections:** Safe passage for WebSockets and SSE pipelines.
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
### 4. Customizing the Configuration (`BlindLogConfig`)
|
|
134
|
+
|
|
135
|
+
You can tune BlindLog's rules by defining a `BlindLogConfig`. Once created, the config is `frozen` (immutable) to prevent runtime tampering.
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
from blindlog.core import BlindLogger
|
|
139
|
+
from blindlog.config import BlindLogConfig
|
|
140
|
+
|
|
141
|
+
# 1. Define custom sensitive keys.
|
|
142
|
+
# Note: This overwrites the defaults, so add your specific database fields.
|
|
143
|
+
custom_keys = frozenset({"internal_db_id", "auth_token", "email"})
|
|
144
|
+
|
|
145
|
+
config = BlindLogConfig(
|
|
146
|
+
secret_key="my-custom-key", # Will fall back to ENV var if omitted
|
|
147
|
+
sensitive_keys=custom_keys,
|
|
148
|
+
debug_mode=False
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
logger = BlindLogger(config=config)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**Key Matching Engine (Fast Path):**
|
|
155
|
+
BlindLog checks dictionary keys via:
|
|
156
|
+
1. **Exact Match:** e.g., `"email"` == `"email"`.
|
|
157
|
+
2. **Suffix Match:** e.g., `"user_password"` ends with `"_password"`.
|
|
158
|
+
3. **Normalization:** Hyphens (`x-api-key`) and camelCase (`APIKey`) are normalized to `snake_case` before matching, ensuring maximum coverage across varying schemas.
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
### 5. Custom Format Registration (Adding Regex)
|
|
163
|
+
|
|
164
|
+
BlindLog implements a **Registry Pattern**. If you have custom internal tokens (e.g., specific AWS KMS IDs or internal Employee IDs) you can teach BlindLog to find and format them dynamically in free-text.
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
import re
|
|
168
|
+
from blindlog.core import BlindLogger
|
|
169
|
+
|
|
170
|
+
logger = BlindLogger()
|
|
171
|
+
|
|
172
|
+
# 1. Define a regex pattern
|
|
173
|
+
aws_pattern = re.compile(r"AWS-KMS-\d{6}")
|
|
174
|
+
|
|
175
|
+
# 2. Define a callback function that takes the matched string and returns a safe string
|
|
176
|
+
# Note: You can also hash it dynamically inside the callback if you wish!
|
|
177
|
+
def mask_aws(matched_string: str) -> str:
|
|
178
|
+
return "blnd_aws_TOKEN_REDACTED"
|
|
179
|
+
|
|
180
|
+
# 3. Register the rule
|
|
181
|
+
logger.registry.register(aws_pattern, mask_aws)
|
|
182
|
+
|
|
183
|
+
masked = logger.mask("Exception: AWS-KMS-123456 failed to load.")
|
|
184
|
+
# Output: "Exception: blnd_aws_TOKEN_REDACTED failed to load."
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## 🛡️ Default Out-Of-The-Box Protections
|
|
190
|
+
|
|
191
|
+
BlindLog actively protects the following data types automatically via RegEx and Key-discovery:
|
|
192
|
+
|
|
193
|
+
- **Emails:** Truncated and hashed (`blnd_ref_HASH...@masked.com`)
|
|
194
|
+
- **Credit Cards:** Preserves major industry format (`4111-HASH-HASH-1234`)
|
|
195
|
+
- **API Keys & Tokens:** Covers OpenAI, Stripe, AWS, Slack, and GitHub PATs natively.
|
|
196
|
+
- **Phone Numbers:** International and NANP routing.
|
|
197
|
+
- **Social Security Numbers (SSN):** US Formats.
|
|
198
|
+
- **IPv4 Addresses:** Validated octet arrays.
|
|
199
|
+
- **HTTP/Web Standard Keys:** `cookie`, `set_cookie`, `authorization`, `password`, `secret`, `private_key`, `credentials`.
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## 🧮 Idempotency & System Guarantees
|
|
204
|
+
|
|
205
|
+
- **Idempotent Masking Guarantees:** BlindLog uses strict structural RegEx evaluations (`MASKED_PATTERN`). If you pass already-masked data into the engine multiple times, it skips it instantly. You will never double-hash a log.
|
|
206
|
+
- **ReDoS Mitigation:** Slow-path Regex execution cuts off after 10,000 characters, averting CPU exhaustion DOS attacks.
|
|
207
|
+
- **OOM Prevention:** Middlewares strictly cap at 5MB buffer payloads.
|
|
208
|
+
- **Type Sabotage Checks:** Gracefully handles `None`, `True`, and circular nested dictionary references without crashing or returning unmasked memory addresses.
|
|
209
|
+
|
|
210
|
+
For further exploration, please review our [Architecture Guide](./ARCHITECTURE.md) and the `CHANGELOG.md`!
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
blindlog/__init__.py,sha256=4pDU5eCF7e3EJcQkL00sb7y-pN7ThNgeDB3556DYQX8,246
|
|
2
|
+
blindlog/config.py,sha256=ebgT6FsQf3S827f5ewCGW_iAjnvr2PeL61uIiXUMD3U,2365
|
|
3
|
+
blindlog/core.py,sha256=VHuVxH7TPq37RO5SfvD4zSBFjpM0GV9aZegb0ciiJ5E,3490
|
|
4
|
+
blindlog/formatters.py,sha256=TbHQs0ax-Xa5JzpNNJHmrm2CJCRbgjf0pUh9TIs6Yu4,2915
|
|
5
|
+
blindlog/interfaces.py,sha256=GMv-81qPDtiQnTYv-6Ua9d67Ng9cTsu3YjXMTgdtWJE,794
|
|
6
|
+
blindlog/registry.py,sha256=M9NPgB2rpB1hRxJZkblycIVPXpo9IQoezhDKO2NSHcw,2915
|
|
7
|
+
blindlog/rules.py,sha256=LzHXV-dK3pOHEMZ_KnikgDQ4t1RTJwD5NDVVLSe3-i4,3484
|
|
8
|
+
blindlog/utils.py,sha256=j9FpFx6uKBw603nfwTrPxRccC-QqFAhs2i1Rml4xwSM,3856
|
|
9
|
+
blindlog/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
blindlog/integrations/fastapi.py,sha256=MdxUVqxhMwVb2R-_C91kHD3gcgM6HxYTYYhclQ-DkzM,4970
|
|
11
|
+
blindlog-1.1.0.dist-info/licenses/LICENSE,sha256=JuDRkpJ1tG2YXCjsW0iF9Ob8IH0K2Sd3c9WMTBPfO7o,1055
|
|
12
|
+
blindlog-1.1.0.dist-info/METADATA,sha256=8YA1xIUXOU5eJFzuUcXJOdrzh4H6RDSsU731mJQ1m0I,9166
|
|
13
|
+
blindlog-1.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
14
|
+
blindlog-1.1.0.dist-info/top_level.txt,sha256=0xWLShRA7WflARBOnJVGJymE_rVx-CJJEkfArjrjPDY,9
|
|
15
|
+
blindlog-1.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
blindlog
|