112 lines
4.0 KiB
Python
112 lines
4.0 KiB
Python
|
|
"""日志脱敏单元测试。
|
|||
|
|
|
|||
|
|
规范第 8 节禁止日志输出密码、手机号、身份证、Token。
|
|||
|
|
脱敏是最容易在重构中悄悄失效的一环,故用参数化用例覆盖各条规则。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import logging
|
|||
|
|
|
|||
|
|
import pytest
|
|||
|
|
|
|||
|
|
from app.core.logging import SensitiveFormatter, scrub
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestScrub:
|
|||
|
|
"""文本脱敏规则。"""
|
|||
|
|
|
|||
|
|
@pytest.mark.parametrize(
|
|||
|
|
("raw", "leaked"),
|
|||
|
|
[
|
|||
|
|
('{"password": "Admin@123"}', "Admin@123"),
|
|||
|
|
("password=Secret123", "Secret123"),
|
|||
|
|
("pwd: hunter2xyz", "hunter2xyz"),
|
|||
|
|
('{"app_secret_key": "abcdef123456"}', "abcdef123456"),
|
|||
|
|
("token=eyJhbGciOiJIUzI1NiJ9", "eyJhbGciOiJIUzI1NiJ9"),
|
|||
|
|
],
|
|||
|
|
)
|
|||
|
|
def test_凭据值被脱敏(self, raw: str, leaked: str) -> None:
|
|||
|
|
assert leaked not in scrub(raw)
|
|||
|
|
|
|||
|
|
def test_Bearer令牌被脱敏(self) -> None:
|
|||
|
|
result = scrub("Authorization: Bearer eyJhbGciOi.J9.abc-_123")
|
|||
|
|
assert "eyJhbGciOi.J9.abc-_123" not in result
|
|||
|
|
assert "Bearer ***" in result
|
|||
|
|
|
|||
|
|
def test_手机号保留前三后四(self) -> None:
|
|||
|
|
assert scrub("联系电话 13812345678") == "联系电话 138****5678"
|
|||
|
|
|
|||
|
|
def test_身份证保留前六后四(self) -> None:
|
|||
|
|
assert scrub("证件号 110101199003071234") == "证件号 110101********1234"
|
|||
|
|
|
|||
|
|
def test_普通文本不被误伤(self) -> None:
|
|||
|
|
text = "用户 admin 登录成功,耗时 120ms"
|
|||
|
|
assert scrub(text) == text
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _format(msg: str, args: object = None) -> str:
|
|||
|
|
"""用 SensitiveFormatter 渲染一条日志,返回最终文本。"""
|
|||
|
|
record = logging.LogRecord(
|
|||
|
|
name="t",
|
|||
|
|
level=logging.INFO,
|
|||
|
|
pathname="",
|
|||
|
|
lineno=0,
|
|||
|
|
msg=msg,
|
|||
|
|
args=args,
|
|||
|
|
exc_info=None,
|
|||
|
|
)
|
|||
|
|
return SensitiveFormatter("%(message)s").format(record)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestSensitiveFormatter:
|
|||
|
|
"""脱敏必须在 %-格式化之后进行。"""
|
|||
|
|
|
|||
|
|
def test_脱敏位置参数中的敏感值(self) -> None:
|
|||
|
|
# 回归用例:早期实现在 filter 阶段改写 record.msg,把 %s 占位符一并抹掉,
|
|||
|
|
# 导致 logging 内部 msg % args 抛 TypeError,整条日志丢失
|
|||
|
|
result = _format("登录失败 password=%s", ("Secret123",))
|
|||
|
|
assert "Secret123" not in result
|
|||
|
|
assert "登录失败" in result
|
|||
|
|
|
|||
|
|
def test_脱敏字典参数中的敏感值(self) -> None:
|
|||
|
|
# LogRecord 要求 args 为元组;字典参数须包成单元素元组,
|
|||
|
|
# 这正是 logger.info(msg, {...}) 的内部形态
|
|||
|
|
result = _format("token=%(token)s", ({"token": "abc123xyz"},))
|
|||
|
|
assert "abc123xyz" not in result
|
|||
|
|
|
|||
|
|
def test_脱敏消息模板内联的敏感值(self) -> None:
|
|||
|
|
"""敏感值直接写在消息模板里(无占位符)时同样要脱敏。"""
|
|||
|
|
result = _format('请求体 {"password": "Admin@123"}')
|
|||
|
|
assert "Admin@123" not in result
|
|||
|
|
|
|||
|
|
def test_普通日志不被改动(self) -> None:
|
|||
|
|
assert (
|
|||
|
|
_format("用户 admin 登录成功,耗时 %d ms", (120,)) == "用户 admin 登录成功,耗时 120 ms"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_无参数日志正常通过(self) -> None:
|
|||
|
|
assert _format("服务已启动") == "服务已启动"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestSetupLogging:
|
|||
|
|
"""setup_logging 必须挂上脱敏格式化器,否则规则形同虚设。"""
|
|||
|
|
|
|||
|
|
def test_所有handler均使用脱敏格式化器(self) -> None:
|
|||
|
|
import logging as _logging
|
|||
|
|
|
|||
|
|
from app.core.logging import setup_logging
|
|||
|
|
|
|||
|
|
setup_logging(level="INFO", log_to_file=False)
|
|||
|
|
handlers = _logging.getLogger().handlers
|
|||
|
|
assert handlers, "根日志器应至少有一个 handler"
|
|||
|
|
assert all(isinstance(h.formatter, SensitiveFormatter) for h in handlers)
|
|||
|
|
|
|||
|
|
def test_重复调用不会累积handler(self) -> None:
|
|||
|
|
import logging as _logging
|
|||
|
|
|
|||
|
|
from app.core.logging import setup_logging
|
|||
|
|
|
|||
|
|
setup_logging(log_to_file=False)
|
|||
|
|
first = len(_logging.getLogger().handlers)
|
|||
|
|
setup_logging(log_to_file=False)
|
|||
|
|
assert len(_logging.getLogger().handlers) == first
|