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
« 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."""
3from __future__ import annotations
5from collections.abc import Iterable
6import re
7from typing import Any
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}
17class DefaultPiiRedactor:
18 """Default PII redactor combining field-name denylist and pattern matching.
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 """
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 ]
37 def redact(self, payload: dict[str, Any]) -> dict[str, Any]:
38 """Return a redacted copy of *payload*."""
39 return self._redact_value(payload)
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
56__all__ = ["DefaultPiiRedactor"]