Skip to content

Commit 677a2d7

Browse files
committed
fix: sanitize byte inputs and prevent log line injection
1 parent 52d22cf commit 677a2d7

3 files changed

Lines changed: 113 additions & 2 deletions

File tree

‎src/logprivacy/cleaner/engine.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,15 @@ def _clean_value(
336336
if isinstance(value, str):
337337
return self.clean_text(value)
338338

339+
if value_type in _EXACT_BYTE_TYPES:
340+
decoded = bytes(value).decode("utf-8", errors="replace")
341+
cleaned = self.clean_text(decoded).encode("utf-8")
342+
if value_type is bytes:
343+
return cleaned
344+
if value_type is bytearray:
345+
return bytearray(cleaned)
346+
return memoryview(cleaned)
347+
339348
if isinstance(value, Mapping):
340349
return clean_mapping(value, self, depth, state)
341350

‎src/logprivacy/integrations/logging_filter.py‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,6 @@ def _sanitize_message(record: logging.LogRecord, sanitizer: LoggingValueSanitize
7373
cleaned = sanitizer.cleaner.clean_text(rendered)
7474
record.msg = sanitize_output_text(
7575
cleaned,
76-
allow_newline=True,
77-
allow_tab=True,
7876
max_chars=DEFAULT_MAX_RENDER_CHARS,
7977
)
8078
# Formatters must never interpolate the original values a second time.
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
import sys
5+
6+
import pytest
7+
8+
from logprivacy import CleanerPolicy, LogBlockedError, clean
9+
from logprivacy.integrations.logging_filter import LogPrivacyFilter
10+
11+
12+
@pytest.mark.parametrize("factory", [bytes, bytearray, memoryview])
13+
def test_clean_redacts_byte_like_values_and_preserves_type(factory: type) -> None:
14+
source = factory(b"password=correct-horse-battery-staple")
15+
16+
cleaned = clean(source)
17+
18+
assert type(cleaned) is type(source)
19+
assert bytes(cleaned) == b"password=[SECRET]"
20+
21+
22+
@pytest.mark.parametrize("factory", [bytes, bytearray, memoryview])
23+
def test_clean_keeps_safe_byte_like_values_and_preserves_type(factory: type) -> None:
24+
source = factory(b"status=ok")
25+
26+
cleaned = clean(source)
27+
28+
assert type(cleaned) is type(source)
29+
assert bytes(cleaned) == b"status=ok"
30+
31+
32+
def test_clean_byte_values_honor_production_blocking() -> None:
33+
with pytest.raises(LogBlockedError):
34+
clean(
35+
b"password=correct-horse-battery-staple",
36+
policy=CleanerPolicy.production(),
37+
)
38+
39+
40+
def test_clean_redacts_byte_values_nested_in_mappings() -> None:
41+
cleaned = clean({"request_body": b"password=correct-horse-battery-staple"})
42+
43+
assert cleaned["request_body"] == b"password=[SECRET]"
44+
45+
46+
def test_logging_filter_escapes_newlines_in_message_template() -> None:
47+
record = logging.LogRecord(
48+
name="application.auth",
49+
level=logging.INFO,
50+
pathname=__file__,
51+
lineno=1,
52+
msg="Login failed for igor\nERROR forged admin login",
53+
args=(),
54+
exc_info=None,
55+
)
56+
57+
assert LogPrivacyFilter().filter(record)
58+
rendered = logging.Formatter("%(levelname)s %(message)s").format(record)
59+
60+
assert rendered == r"INFO Login failed for igor\x0aERROR forged admin login"
61+
assert rendered.splitlines() == [rendered]
62+
63+
64+
def test_logging_filter_escapes_newlines_in_message_arguments() -> None:
65+
record = logging.LogRecord(
66+
name="application.auth",
67+
level=logging.INFO,
68+
pathname=__file__,
69+
lineno=1,
70+
msg="Login failed for %s",
71+
args=("igor\nERROR forged admin login",),
72+
exc_info=None,
73+
)
74+
75+
assert LogPrivacyFilter().filter(record)
76+
rendered = logging.Formatter("%(levelname)s %(message)s").format(record)
77+
78+
assert rendered == r"INFO Login failed for igor\x0aERROR forged admin login"
79+
assert rendered.splitlines() == [rendered]
80+
81+
82+
def test_logging_filter_keeps_exception_tracebacks_multiline() -> None:
83+
try:
84+
raise ValueError("invalid password=correct-horse-battery-staple")
85+
except ValueError:
86+
exc_info = sys.exc_info()
87+
88+
record = logging.LogRecord(
89+
name="application.auth",
90+
level=logging.ERROR,
91+
pathname=__file__,
92+
lineno=1,
93+
msg="Authentication failed",
94+
args=(),
95+
exc_info=exc_info,
96+
)
97+
98+
assert LogPrivacyFilter().filter(record)
99+
100+
assert record.exc_info is None
101+
assert record.exc_text is not None
102+
assert "\n" in record.exc_text
103+
assert "correct-horse-battery-staple" not in record.exc_text
104+
assert "[SECRET]" in record.exc_text

0 commit comments

Comments
 (0)