Coverage for src / lexigram / admin / audit / redactor.py: 0%

24 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-11 02:25 +0800

1"""DefaultPiiRedactor — field-name and pattern-based PII redaction.""" 

2 

3from __future__ import annotations 

4 

5from collections.abc import Iterable 

6import re 

7from typing import Any 

8 

9_EMAIL_RE = re.compile(r"[A-Za-z0-9_.+-]+@[A-Za-z0-9-]+\.[A-Za-z0-9-.]+") 

10_PHONE_RE = re.compile(r"\+?\d[\d\-\s().]{7,}\d") 

11_PATTERN_REGISTRY: dict[str, re.Pattern[str]] = { 

12 "email": _EMAIL_RE, 

13 "phone": _PHONE_RE, 

14} 

15 

16 

17class DefaultPiiRedactor: 

18 """Default PII redactor combining field-name denylist and pattern matching. 

19 

20 Args: 

21 field_denylist: Set of field names whose values are always 

22 replaced with ``"<redacted>"`` (checked recursively). 

23 patterns: Named patterns from ``{"email", "phone"}`` to match 

24 against string values at any nesting depth. 

25 """ 

26 

27 def __init__( 

28 self, 

29 field_denylist: Iterable[str] = (), 

30 patterns: Iterable[str] = (), 

31 ) -> None: 

32 self._fields = set(field_denylist) 

33 self._compiled = [ 

34 _PATTERN_REGISTRY[p] for p in patterns if p in _PATTERN_REGISTRY 

35 ] 

36 

37 def redact(self, payload: dict[str, Any]) -> dict[str, Any]: 

38 """Return a redacted copy of *payload*.""" 

39 return self._redact_value(payload) 

40 

41 def _redact_value(self, v: Any) -> Any: 

42 if isinstance(v, dict): 

43 return { 

44 k: ("<redacted>" if k in self._fields else self._redact_value(val)) 

45 for k, val in v.items() 

46 } 

47 if isinstance(v, list): 

48 return [self._redact_value(x) for x in v] 

49 if isinstance(v, str): 

50 for pat in self._compiled: 

51 v = pat.sub("<redacted>", v) 

52 return v 

53 return v 

54 

55 

56__all__ = ["DefaultPiiRedactor"]