pseudonymize 0.1.0a1__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.
@@ -0,0 +1,28 @@
1
+ from pseudonymize.api import generate_key, pseudonymize, redact
2
+ from pseudonymize.backends import CompositeBackend, DetectionBackend, RulesBackend
3
+ from pseudonymize.engine import ProcessingScope, Pseudonymizer
4
+ from pseudonymize.policy import Policy
5
+ from pseudonymize.resolution import EntityResolver, ExactEntityResolver, ResolvedEntity
6
+ from pseudonymize.result import Detection, EntityType, Replacement, Result
7
+ from pseudonymize.transforms import Alias, TransformationMode
8
+
9
+ __all__ = [
10
+ "Alias",
11
+ "CompositeBackend",
12
+ "Detection",
13
+ "DetectionBackend",
14
+ "EntityResolver",
15
+ "EntityType",
16
+ "ExactEntityResolver",
17
+ "Policy",
18
+ "ProcessingScope",
19
+ "Pseudonymizer",
20
+ "Replacement",
21
+ "ResolvedEntity",
22
+ "Result",
23
+ "RulesBackend",
24
+ "TransformationMode",
25
+ "generate_key",
26
+ "pseudonymize",
27
+ "redact",
28
+ ]
pseudonymize/api.py ADDED
@@ -0,0 +1,50 @@
1
+ from collections.abc import Sequence
2
+
3
+ from pseudonymize.backends import DetectionBackend
4
+ from pseudonymize.engine import Pseudonymizer
5
+ from pseudonymize.policy import Policy
6
+ from pseudonymize.transforms import TransformationMode, generate_key
7
+
8
+
9
+ def pseudonymize(
10
+ text: str,
11
+ *,
12
+ mode: TransformationMode | str = TransformationMode.NUMBERED,
13
+ key: bytes | None = None,
14
+ namespace: str = "default",
15
+ policy: Policy | None = None,
16
+ backends: Sequence[DetectionBackend] | None = None,
17
+ ) -> str:
18
+ return (
19
+ Pseudonymizer(
20
+ mode=mode,
21
+ key=key,
22
+ namespace=namespace,
23
+ policy=policy,
24
+ backends=backends,
25
+ )
26
+ .process(text)
27
+ .text
28
+ )
29
+
30
+
31
+ def redact(
32
+ text: str,
33
+ *,
34
+ typed: bool = False,
35
+ policy: Policy | None = None,
36
+ backends: Sequence[DetectionBackend] | None = None,
37
+ ) -> str:
38
+ return (
39
+ Pseudonymizer(
40
+ mode=TransformationMode.REDACTED,
41
+ policy=policy,
42
+ backends=backends,
43
+ typed_redaction=typed,
44
+ )
45
+ .process(text)
46
+ .text
47
+ )
48
+
49
+
50
+ __all__ = ["generate_key", "pseudonymize", "redact"]
@@ -0,0 +1,5 @@
1
+ from pseudonymize.backends.base import DetectionBackend, EntityBackend
2
+ from pseudonymize.backends.composite import CompositeBackend
3
+ from pseudonymize.backends.rules import RulesBackend
4
+
5
+ __all__ = ["CompositeBackend", "DetectionBackend", "EntityBackend", "RulesBackend"]
@@ -0,0 +1,14 @@
1
+ from collections.abc import Sequence
2
+ from typing import Protocol
3
+
4
+ from pseudonymize.result import Detection
5
+
6
+
7
+ class DetectionBackend(Protocol):
8
+ @property
9
+ def name(self) -> str: ...
10
+
11
+ def detect(self, text: str) -> Sequence[Detection]: ...
12
+
13
+
14
+ EntityBackend = DetectionBackend
@@ -0,0 +1,17 @@
1
+ from collections.abc import Sequence
2
+ from dataclasses import dataclass
3
+
4
+ from pseudonymize.backends.base import DetectionBackend
5
+ from pseudonymize.result import Detection
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class CompositeBackend:
10
+ backends: Sequence[DetectionBackend]
11
+ name: str = "composite"
12
+
13
+ def __post_init__(self) -> None:
14
+ object.__setattr__(self, "backends", tuple(self.backends))
15
+
16
+ def detect(self, text: str) -> tuple[Detection, ...]:
17
+ return tuple(detection for backend in self.backends for detection in backend.detect(text))
@@ -0,0 +1,19 @@
1
+ from collections.abc import Sequence
2
+ from dataclasses import dataclass
3
+
4
+ from pseudonymize.detectors import DEFAULT_DETECTORS, Detector
5
+ from pseudonymize.result import Detection
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class RulesBackend:
10
+ detectors: Sequence[Detector] = DEFAULT_DETECTORS
11
+ name: str = "rules"
12
+
13
+ def __post_init__(self) -> None:
14
+ object.__setattr__(self, "detectors", tuple(self.detectors))
15
+
16
+ def detect(self, text: str) -> tuple[Detection, ...]:
17
+ return tuple(
18
+ detection for detector in self.detectors for detection in detector.detect(text)
19
+ )
pseudonymize/cli.py ADDED
@@ -0,0 +1,125 @@
1
+ import argparse
2
+ import base64
3
+ import binascii
4
+ import json
5
+ import os
6
+ import stat
7
+ import sys
8
+ from collections.abc import Sequence
9
+ from pathlib import Path
10
+
11
+ from pseudonymize.api import generate_key
12
+ from pseudonymize.detectors import DEFAULT_DETECTORS
13
+ from pseudonymize.engine import Data, Pseudonymizer
14
+ from pseudonymize.exceptions import PseudonymizeError
15
+ from pseudonymize.policy import Policy
16
+ from pseudonymize.transforms import TransformationMode
17
+
18
+
19
+ def _parser() -> argparse.ArgumentParser:
20
+ parser = argparse.ArgumentParser(prog="pseudonymize")
21
+ subparsers = parser.add_subparsers(dest="command", required=True)
22
+ subparsers.add_parser("keygen", help="print a new base64-encoded key")
23
+ subparsers.add_parser("detectors", help="list bundled detectors")
24
+
25
+ text_parser = subparsers.add_parser("text", help="process text")
26
+ text_parser.add_argument("text", help="text to process, or - for standard input")
27
+ _add_key_options(text_parser, allow_stdin=True)
28
+ text_parser.add_argument("--namespace", default="default")
29
+ _add_mode_options(text_parser)
30
+
31
+ json_parser = subparsers.add_parser("json", help="process JSON from standard input")
32
+ _add_key_options(json_parser)
33
+ json_parser.add_argument("--namespace", default="default")
34
+ _add_mode_options(json_parser)
35
+ return parser
36
+
37
+
38
+ def _add_key_options(parser: argparse.ArgumentParser, *, allow_stdin: bool = False) -> None:
39
+ sources = parser.add_mutually_exclusive_group()
40
+ sources.add_argument("--key-env", metavar="NAME")
41
+ sources.add_argument("--key-file", type=Path)
42
+ sources.add_argument("--key-fd", type=int)
43
+ if allow_stdin:
44
+ sources.add_argument("--key-stdin", action="store_true")
45
+
46
+
47
+ def _add_mode_options(parser: argparse.ArgumentParser) -> None:
48
+ modes = parser.add_mutually_exclusive_group()
49
+ modes.add_argument(
50
+ "--mode", choices=tuple(TransformationMode), default=TransformationMode.NUMBERED
51
+ )
52
+ modes.add_argument(
53
+ "--redact", action="store_const", const=TransformationMode.REDACTED, dest="mode"
54
+ )
55
+ parser.add_argument("--typed-redaction", action="store_true")
56
+
57
+
58
+ def _has_key_source(arguments: argparse.Namespace) -> bool:
59
+ return any(
60
+ (
61
+ getattr(arguments, "key_env", None),
62
+ getattr(arguments, "key_file", None),
63
+ getattr(arguments, "key_fd", None) is not None,
64
+ getattr(arguments, "key_stdin", False),
65
+ )
66
+ )
67
+
68
+
69
+ def _read_key(arguments: argparse.Namespace) -> bytes:
70
+ encoded: str
71
+ if getattr(arguments, "key_env", None):
72
+ encoded = os.environ.get(arguments.key_env, "")
73
+ if not encoded:
74
+ raise ValueError(f"environment variable {arguments.key_env!r} is empty or unset")
75
+ elif getattr(arguments, "key_file", None):
76
+ path: Path = arguments.key_file
77
+ if os.name != "nt" and stat.S_IMODE(path.stat().st_mode) & 0o077:
78
+ raise ValueError("key file must not be accessible by group or other users")
79
+ encoded = path.read_text(encoding="ascii").strip()
80
+ elif getattr(arguments, "key_fd", None) is not None:
81
+ with os.fdopen(os.dup(arguments.key_fd), encoding="ascii") as stream:
82
+ encoded = stream.read().strip()
83
+ else:
84
+ if arguments.text == "-":
85
+ raise ValueError("standard input cannot provide both the key and text")
86
+ encoded = sys.stdin.read().strip()
87
+ try:
88
+ return base64.b64decode(encoded, validate=True)
89
+ except (binascii.Error, ValueError) as error:
90
+ raise ValueError("key must be valid base64") from error
91
+
92
+
93
+ def main(argv: Sequence[str] | None = None) -> int:
94
+ parser = _parser()
95
+ arguments = parser.parse_args(argv)
96
+ if arguments.command == "keygen":
97
+ print(base64.b64encode(generate_key()).decode("ascii"))
98
+ return 0
99
+ if arguments.command == "detectors":
100
+ print("\n".join(detector.name for detector in DEFAULT_DETECTORS))
101
+ return 0
102
+ try:
103
+ key = _read_key(arguments) if _has_key_source(arguments) else None
104
+ engine = Pseudonymizer(
105
+ mode=arguments.mode,
106
+ key=key,
107
+ namespace=arguments.namespace,
108
+ policy=Policy.llm() if arguments.command == "json" else Policy.default(),
109
+ typed_redaction=arguments.typed_redaction,
110
+ )
111
+ if arguments.command == "text":
112
+ source = sys.stdin.read() if arguments.text == "-" else arguments.text
113
+ print(engine.process(source).text)
114
+ return 0
115
+ payload = json.load(sys.stdin)
116
+ output: Data = engine.process_data(payload)
117
+ json.dump(output, sys.stdout, ensure_ascii=False)
118
+ sys.stdout.write("\n")
119
+ return 0
120
+ except (OSError, ValueError, json.JSONDecodeError, PseudonymizeError) as error:
121
+ parser.exit(2, f"pseudonymize: error: {error}\n")
122
+
123
+
124
+ if __name__ == "__main__":
125
+ raise SystemExit(main())
@@ -0,0 +1,4 @@
1
+ from pseudonymize.detectors.base import Detector
2
+ from pseudonymize.detectors.registry import DEFAULT_DETECTORS
3
+
4
+ __all__ = ["DEFAULT_DETECTORS", "Detector"]
@@ -0,0 +1,10 @@
1
+ from typing import Protocol
2
+
3
+ from pseudonymize.result import Detection
4
+
5
+
6
+ class Detector(Protocol):
7
+ @property
8
+ def name(self) -> str: ...
9
+
10
+ def detect(self, text: str) -> list[Detection]: ...
@@ -0,0 +1,21 @@
1
+ import re
2
+ from dataclasses import dataclass
3
+
4
+ from pseudonymize.result import Detection, EntityType
5
+
6
+ _EMAIL = re.compile(
7
+ r"(?<![\w.+-])[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@"
8
+ r"(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}(?![\w-])"
9
+ )
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class EmailDetector:
14
+ name: str = "email"
15
+
16
+ def detect(self, text: str) -> list[Detection]:
17
+ return [
18
+ Detection(EntityType.EMAIL, match.start(), match.end(), 0.99, self.name)
19
+ for match in _EMAIL.finditer(text)
20
+ if len(match.group().partition("@")[0]) <= 64 and len(match.group()) <= 254
21
+ ]
@@ -0,0 +1,31 @@
1
+ import re
2
+ from dataclasses import dataclass
3
+
4
+ from pseudonymize.result import Detection, EntityType
5
+
6
+ _IBAN = re.compile(r"(?<![A-Z0-9])[A-Z]{2}\d{2}(?:[ ]?[A-Z0-9]){11,30}(?![A-Z0-9])", re.I)
7
+
8
+
9
+ def _valid_mod97(value: str) -> bool:
10
+ compact = "".join(value.split()).upper()
11
+ if not 15 <= len(compact) <= 34:
12
+ return False
13
+ rearranged = compact[4:] + compact[:4]
14
+ remainder = 0
15
+ for character in rearranged:
16
+ digits = str(ord(character) - 55) if character.isalpha() else character
17
+ for digit in digits:
18
+ remainder = (remainder * 10 + int(digit)) % 97
19
+ return remainder == 1
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class IbanDetector:
24
+ name: str = "iban"
25
+
26
+ def detect(self, text: str) -> list[Detection]:
27
+ return [
28
+ Detection(EntityType.IBAN, match.start(), match.end(), 1.0, self.name)
29
+ for match in _IBAN.finditer(text)
30
+ if _valid_mod97(match.group())
31
+ ]
@@ -0,0 +1,26 @@
1
+ import ipaddress
2
+ import re
3
+ from dataclasses import dataclass
4
+
5
+ from pseudonymize.result import Detection, EntityType
6
+
7
+ _IP_CANDIDATE = re.compile(
8
+ r"(?<![\w:.])(?:\d{1,3}(?:\.\d{1,3}){3}|[0-9A-Fa-f]{0,4}:[0-9A-Fa-f:]+)(?![\w:.])"
9
+ )
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class IpAddressDetector:
14
+ name: str = "ip_address"
15
+
16
+ def detect(self, text: str) -> list[Detection]:
17
+ detections: list[Detection] = []
18
+ for match in _IP_CANDIDATE.finditer(text):
19
+ try:
20
+ ipaddress.ip_address(match.group())
21
+ except ValueError:
22
+ continue
23
+ detections.append(
24
+ Detection(EntityType.IP_ADDRESS, match.start(), match.end(), 0.99, self.name)
25
+ )
26
+ return detections
@@ -0,0 +1,33 @@
1
+ import re
2
+ from dataclasses import dataclass
3
+
4
+ from pseudonymize.result import Detection, EntityType
5
+
6
+ _CARD = re.compile(r"(?<!\d)(?:\d[ -]?){12,18}\d(?!\d)")
7
+
8
+
9
+ def _valid_luhn(value: str) -> bool:
10
+ digits = [int(character) for character in value if character.isdigit()]
11
+ if not 13 <= len(digits) <= 19 or len(set(digits)) == 1:
12
+ return False
13
+ total = 0
14
+ parity = len(digits) % 2
15
+ for index, digit in enumerate(digits):
16
+ if index % 2 == parity:
17
+ digit *= 2
18
+ if digit > 9:
19
+ digit -= 9
20
+ total += digit
21
+ return total % 10 == 0
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class PaymentCardDetector:
26
+ name: str = "payment_card"
27
+
28
+ def detect(self, text: str) -> list[Detection]:
29
+ return [
30
+ Detection(EntityType.PAYMENT_CARD, match.start(), match.end(), 1.0, self.name)
31
+ for match in _CARD.finditer(text)
32
+ if _valid_luhn(match.group())
33
+ ]
@@ -0,0 +1,26 @@
1
+ import re
2
+ from dataclasses import dataclass
3
+
4
+ from pseudonymize.result import Detection, EntityType
5
+
6
+ _PHONE = re.compile(r"(?<![\w+])(?:\+\d{1,3}[ .()-]*)?(?:\d[ .()-]*){6,14}\d(?!\w)")
7
+
8
+
9
+ def _credible_phone(value: str) -> bool:
10
+ digits = "".join(character for character in value if character.isdigit())
11
+ if not 7 <= len(digits) <= 15 or len(set(digits)) == 1:
12
+ return False
13
+ separators = sum(character in " .()-" for character in value)
14
+ return value.startswith("+") or separators >= 2
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class PhoneDetector:
19
+ name: str = "phone"
20
+
21
+ def detect(self, text: str) -> list[Detection]:
22
+ return [
23
+ Detection(EntityType.PHONE, match.start(), match.end(), 0.86, self.name)
24
+ for match in _PHONE.finditer(text)
25
+ if _credible_phone(match.group())
26
+ ]
@@ -0,0 +1,18 @@
1
+ from pseudonymize.detectors.base import Detector
2
+ from pseudonymize.detectors.email import EmailDetector
3
+ from pseudonymize.detectors.iban import IbanDetector
4
+ from pseudonymize.detectors.ip_address import IpAddressDetector
5
+ from pseudonymize.detectors.payment_card import PaymentCardDetector
6
+ from pseudonymize.detectors.phone import PhoneDetector
7
+ from pseudonymize.detectors.secret import SecretDetector
8
+ from pseudonymize.detectors.url import UrlDetector
9
+
10
+ DEFAULT_DETECTORS: tuple[Detector, ...] = (
11
+ EmailDetector(),
12
+ IpAddressDetector(),
13
+ PaymentCardDetector(),
14
+ IbanDetector(),
15
+ PhoneDetector(),
16
+ UrlDetector(),
17
+ SecretDetector(),
18
+ )
@@ -0,0 +1,31 @@
1
+ import re
2
+ from dataclasses import dataclass
3
+
4
+ from pseudonymize.result import Detection, EntityType
5
+
6
+ _SECRET_PATTERNS = (
7
+ re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
8
+ re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,255}\b"),
9
+ re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"),
10
+ re.compile(
11
+ r"(?i)\b(?:api[_-]?key|client[_-]?secret|password|secret|token)\b\s*[:=]\s*"
12
+ r"(?P<value>['\"]?[A-Za-z0-9_./+=-]{8,}['\"]?)"
13
+ ),
14
+ )
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class SecretDetector:
19
+ name: str = "secret"
20
+
21
+ def detect(self, text: str) -> list[Detection]:
22
+ detections: list[Detection] = []
23
+ for pattern in _SECRET_PATTERNS:
24
+ for match in pattern.finditer(text):
25
+ start, end = match.span("value") if "value" in pattern.groupindex else match.span()
26
+ if text[start : start + 1] in {'"', "'"}:
27
+ start += 1
28
+ if text[end - 1 : end] in {'"', "'"}:
29
+ end -= 1
30
+ detections.append(Detection(EntityType.SECRET, start, end, 0.96, self.name))
31
+ return detections
@@ -0,0 +1,53 @@
1
+ import re
2
+ import urllib.parse
3
+ from dataclasses import dataclass
4
+
5
+ from pseudonymize.result import Detection, EntityType
6
+
7
+ _URL = re.compile(r"https?://[^\s<>\"']+", re.I)
8
+ _SENSITIVE_QUERY_NAMES = frozenset(
9
+ {"access_token", "api_key", "apikey", "auth", "key", "password", "secret", "token"}
10
+ )
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class UrlDetector:
15
+ name: str = "url"
16
+
17
+ def detect(self, text: str) -> list[Detection]:
18
+ detections: list[Detection] = []
19
+ for match in _URL.finditer(text):
20
+ url = match.group().rstrip(".,;:!?)]}")
21
+ parsed = urllib.parse.urlsplit(url)
22
+ if parsed.username is not None or parsed.password is not None:
23
+ authority_start = url.find("//") + 2
24
+ at = url.find("@", authority_start)
25
+ detections.append(
26
+ Detection(
27
+ EntityType.URL_CREDENTIAL,
28
+ match.start() + authority_start,
29
+ match.start() + at,
30
+ 1.0,
31
+ self.name,
32
+ )
33
+ )
34
+ query_start = url.find("?") + 1
35
+ if query_start == 0:
36
+ continue
37
+ cursor = query_start
38
+ for item in url[query_start:].split("&"):
39
+ name, separator, value = item.partition("=")
40
+ if separator and urllib.parse.unquote_plus(name).lower() in _SENSITIVE_QUERY_NAMES:
41
+ value_start = cursor + len(name) + 1
42
+ if value:
43
+ detections.append(
44
+ Detection(
45
+ EntityType.URL_CREDENTIAL,
46
+ match.start() + value_start,
47
+ match.start() + value_start + len(value),
48
+ 0.98,
49
+ self.name,
50
+ )
51
+ )
52
+ cursor += len(item) + 1
53
+ return detections