pseudonymize 0.1.0__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.
- pseudonymize/__init__.py +66 -0
- pseudonymize/adapters.py +17 -0
- pseudonymize/api.py +50 -0
- pseudonymize/backends/__init__.py +16 -0
- pseudonymize/backends/base.py +90 -0
- pseudonymize/backends/composite.py +54 -0
- pseudonymize/backends/rules.py +43 -0
- pseudonymize/cli.py +206 -0
- pseudonymize/detectors/__init__.py +4 -0
- pseudonymize/detectors/base.py +10 -0
- pseudonymize/detectors/email.py +21 -0
- pseudonymize/detectors/iban.py +31 -0
- pseudonymize/detectors/ip_address.py +27 -0
- pseudonymize/detectors/payment_card.py +33 -0
- pseudonymize/detectors/phone.py +26 -0
- pseudonymize/detectors/registry.py +18 -0
- pseudonymize/detectors/secret.py +31 -0
- pseudonymize/detectors/url.py +53 -0
- pseudonymize/document.py +107 -0
- pseudonymize/engine.py +542 -0
- pseudonymize/exceptions.py +42 -0
- pseudonymize/formats.py +361 -0
- pseudonymize/normalization.py +22 -0
- pseudonymize/policy.py +82 -0
- pseudonymize/processing.py +45 -0
- pseudonymize/py.typed +0 -0
- pseudonymize/resolution.py +28 -0
- pseudonymize/result.py +58 -0
- pseudonymize/spans.py +46 -0
- pseudonymize/transforms/__init__.py +27 -0
- pseudonymize/transforms/alias.py +74 -0
- pseudonymize/transforms/base.py +8 -0
- pseudonymize/transforms/hmac.py +7 -0
- pseudonymize/transforms/mode.py +8 -0
- pseudonymize/transforms/placeholder.py +11 -0
- pseudonymize/transforms/redact.py +13 -0
- pseudonymize-0.1.0.dist-info/METADATA +295 -0
- pseudonymize-0.1.0.dist-info/RECORD +41 -0
- pseudonymize-0.1.0.dist-info/WHEEL +4 -0
- pseudonymize-0.1.0.dist-info/entry_points.txt +2 -0
- pseudonymize-0.1.0.dist-info/licenses/LICENSE +201 -0
pseudonymize/__init__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from pseudonymize.adapters import InputAdapter, OutputAdapter
|
|
2
|
+
from pseudonymize.api import generate_key, pseudonymize, redact
|
|
3
|
+
from pseudonymize.backends import (
|
|
4
|
+
BackendCapabilities,
|
|
5
|
+
CompositeBackend,
|
|
6
|
+
DetectionBackend,
|
|
7
|
+
RulesBackend,
|
|
8
|
+
)
|
|
9
|
+
from pseudonymize.document import (
|
|
10
|
+
ContentBlock,
|
|
11
|
+
CSVCellLocation,
|
|
12
|
+
Document,
|
|
13
|
+
JSONPathLocation,
|
|
14
|
+
MetadataValue,
|
|
15
|
+
SourceLocation,
|
|
16
|
+
TextOffsetLocation,
|
|
17
|
+
)
|
|
18
|
+
from pseudonymize.engine import ProcessingScope, Pseudonymizer
|
|
19
|
+
from pseudonymize.formats import FileFormat
|
|
20
|
+
from pseudonymize.policy import NetworkPolicy, Policy
|
|
21
|
+
from pseudonymize.processing import (
|
|
22
|
+
DetectionReport,
|
|
23
|
+
ProcessingResult,
|
|
24
|
+
ProcessingStatistics,
|
|
25
|
+
ProcessingWarning,
|
|
26
|
+
)
|
|
27
|
+
from pseudonymize.resolution import EntityResolver, ExactEntityResolver, ResolvedEntity
|
|
28
|
+
from pseudonymize.result import Detection, EntityType, Replacement, Result
|
|
29
|
+
from pseudonymize.transforms import Alias, TransformationMode
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"Alias",
|
|
33
|
+
"BackendCapabilities",
|
|
34
|
+
"CSVCellLocation",
|
|
35
|
+
"CompositeBackend",
|
|
36
|
+
"ContentBlock",
|
|
37
|
+
"Detection",
|
|
38
|
+
"DetectionBackend",
|
|
39
|
+
"DetectionReport",
|
|
40
|
+
"Document",
|
|
41
|
+
"EntityResolver",
|
|
42
|
+
"EntityType",
|
|
43
|
+
"ExactEntityResolver",
|
|
44
|
+
"FileFormat",
|
|
45
|
+
"InputAdapter",
|
|
46
|
+
"JSONPathLocation",
|
|
47
|
+
"MetadataValue",
|
|
48
|
+
"NetworkPolicy",
|
|
49
|
+
"OutputAdapter",
|
|
50
|
+
"Policy",
|
|
51
|
+
"ProcessingResult",
|
|
52
|
+
"ProcessingScope",
|
|
53
|
+
"ProcessingStatistics",
|
|
54
|
+
"ProcessingWarning",
|
|
55
|
+
"Pseudonymizer",
|
|
56
|
+
"Replacement",
|
|
57
|
+
"ResolvedEntity",
|
|
58
|
+
"Result",
|
|
59
|
+
"RulesBackend",
|
|
60
|
+
"SourceLocation",
|
|
61
|
+
"TextOffsetLocation",
|
|
62
|
+
"TransformationMode",
|
|
63
|
+
"generate_key",
|
|
64
|
+
"pseudonymize",
|
|
65
|
+
"redact",
|
|
66
|
+
]
|
pseudonymize/adapters.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Protocol, TypeVar
|
|
3
|
+
|
|
4
|
+
from pseudonymize.document import Document
|
|
5
|
+
|
|
6
|
+
SourceT = TypeVar("SourceT", contravariant=True)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InputAdapter(Protocol[SourceT]):
|
|
10
|
+
def extract(self, source: SourceT) -> Document: ...
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OutputAdapter(Protocol):
|
|
14
|
+
def render(self, document: Document) -> bytes: ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
FileInputAdapter = InputAdapter[Path]
|
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,16 @@
|
|
|
1
|
+
from pseudonymize.backends.base import (
|
|
2
|
+
BackendCapabilities,
|
|
3
|
+
DetectionBackend,
|
|
4
|
+
backend_capabilities,
|
|
5
|
+
)
|
|
6
|
+
from pseudonymize.backends.composite import CompositeBackend, leaf_backends
|
|
7
|
+
from pseudonymize.backends.rules import RulesBackend
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"BackendCapabilities",
|
|
11
|
+
"CompositeBackend",
|
|
12
|
+
"DetectionBackend",
|
|
13
|
+
"RulesBackend",
|
|
14
|
+
"backend_capabilities",
|
|
15
|
+
"leaf_backends",
|
|
16
|
+
]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
from dataclasses import dataclass, replace
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from pseudonymize.document import ContentBlock
|
|
6
|
+
from pseudonymize.exceptions import (
|
|
7
|
+
BackendContractError,
|
|
8
|
+
BackendExecutionError,
|
|
9
|
+
InvalidDetectionError,
|
|
10
|
+
NetworkPolicyError,
|
|
11
|
+
)
|
|
12
|
+
from pseudonymize.policy import NetworkPolicy, Policy
|
|
13
|
+
from pseudonymize.result import Detection, EntityType
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class BackendCapabilities:
|
|
18
|
+
entity_types: frozenset[EntityType]
|
|
19
|
+
remote: bool = False
|
|
20
|
+
|
|
21
|
+
def __post_init__(self) -> None:
|
|
22
|
+
object.__setattr__(self, "entity_types", frozenset(self.entity_types))
|
|
23
|
+
if any(not isinstance(entity_type, EntityType) for entity_type in self.entity_types):
|
|
24
|
+
raise TypeError("backend entity types must be EntityType values")
|
|
25
|
+
if not isinstance(self.remote, bool):
|
|
26
|
+
raise TypeError("backend remote capability must be a boolean")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DetectionBackend(Protocol):
|
|
30
|
+
@property
|
|
31
|
+
def name(self) -> str: ...
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def capabilities(self) -> BackendCapabilities: ...
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def allow_remote_processing(self) -> bool: ...
|
|
38
|
+
|
|
39
|
+
def detect(self, block: ContentBlock, policy: Policy) -> Sequence[Detection]: ...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def backend_capabilities(backend: DetectionBackend) -> BackendCapabilities:
|
|
43
|
+
try:
|
|
44
|
+
capabilities = backend.capabilities
|
|
45
|
+
allow_remote_processing = backend.allow_remote_processing
|
|
46
|
+
name = backend.name
|
|
47
|
+
except Exception:
|
|
48
|
+
raise BackendContractError(
|
|
49
|
+
"backend does not declare the required block-aware contract"
|
|
50
|
+
) from None
|
|
51
|
+
if not isinstance(capabilities, BackendCapabilities):
|
|
52
|
+
raise BackendContractError("backend capabilities are invalid")
|
|
53
|
+
if not isinstance(allow_remote_processing, bool):
|
|
54
|
+
raise BackendContractError("backend remote consent must be a boolean")
|
|
55
|
+
if not isinstance(name, str) or not name:
|
|
56
|
+
raise BackendContractError("backend name must be a non-empty string")
|
|
57
|
+
return capabilities
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def invoke_backend(
|
|
61
|
+
backend: DetectionBackend, block: ContentBlock, policy: Policy
|
|
62
|
+
) -> tuple[Detection, ...]:
|
|
63
|
+
capabilities = backend_capabilities(backend)
|
|
64
|
+
name = backend.name
|
|
65
|
+
if capabilities.remote:
|
|
66
|
+
if policy.network_policy is NetworkPolicy.DENY:
|
|
67
|
+
raise NetworkPolicyError("network policy denies remote processing")
|
|
68
|
+
if not backend.allow_remote_processing:
|
|
69
|
+
raise NetworkPolicyError("remote backend lacks explicit consent")
|
|
70
|
+
if (
|
|
71
|
+
policy.network_policy is NetworkPolicy.ALLOW_CONFIGURED
|
|
72
|
+
and name not in policy.allowed_remote_backends
|
|
73
|
+
):
|
|
74
|
+
raise NetworkPolicyError("remote backend is not allowlisted")
|
|
75
|
+
try:
|
|
76
|
+
candidates = tuple(backend.detect(block, policy))
|
|
77
|
+
except TypeError:
|
|
78
|
+
raise BackendContractError("backend does not implement block-aware detection") from None
|
|
79
|
+
except Exception:
|
|
80
|
+
raise BackendExecutionError("backend failed during detection") from None
|
|
81
|
+
detections: list[Detection] = []
|
|
82
|
+
for detection in candidates:
|
|
83
|
+
if not isinstance(detection, Detection):
|
|
84
|
+
raise BackendContractError("backend returned a value that is not a Detection")
|
|
85
|
+
if detection.entity_type not in capabilities.entity_types:
|
|
86
|
+
raise BackendContractError("backend returned an undeclared entity type")
|
|
87
|
+
if detection.end > len(block.text):
|
|
88
|
+
raise InvalidDetectionError("backend returned offsets outside the content block")
|
|
89
|
+
detections.append(detection if detection.backend else replace(detection, backend=name))
|
|
90
|
+
return tuple(detections)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
from pseudonymize.backends.base import (
|
|
5
|
+
BackendCapabilities,
|
|
6
|
+
DetectionBackend,
|
|
7
|
+
backend_capabilities,
|
|
8
|
+
invoke_backend,
|
|
9
|
+
)
|
|
10
|
+
from pseudonymize.document import ContentBlock
|
|
11
|
+
from pseudonymize.policy import Policy
|
|
12
|
+
from pseudonymize.result import Detection
|
|
13
|
+
from pseudonymize.spans import resolve_overlaps
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class CompositeBackend:
|
|
18
|
+
backends: Sequence[DetectionBackend]
|
|
19
|
+
name: str = "composite"
|
|
20
|
+
allow_remote_processing: bool = False
|
|
21
|
+
|
|
22
|
+
def __post_init__(self) -> None:
|
|
23
|
+
object.__setattr__(self, "backends", tuple(self.backends))
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def capabilities(self) -> BackendCapabilities:
|
|
27
|
+
return BackendCapabilities(
|
|
28
|
+
frozenset(
|
|
29
|
+
entity_type
|
|
30
|
+
for backend in self.backends
|
|
31
|
+
for entity_type in backend_capabilities(backend).entity_types
|
|
32
|
+
),
|
|
33
|
+
any(backend_capabilities(backend).remote for backend in self.backends),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def detect(self, block: ContentBlock, policy: Policy) -> tuple[Detection, ...]:
|
|
37
|
+
candidates = (
|
|
38
|
+
detection
|
|
39
|
+
for backend in self.backends
|
|
40
|
+
for detection in invoke_backend(backend, block, policy)
|
|
41
|
+
if detection.entity_type in policy.entity_types
|
|
42
|
+
and detection.confidence >= policy.minimum_confidence
|
|
43
|
+
)
|
|
44
|
+
return resolve_overlaps(candidates, policy.detector_priority)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def leaf_backends(backends: Sequence[DetectionBackend]) -> tuple[DetectionBackend, ...]:
|
|
48
|
+
leaves: list[DetectionBackend] = []
|
|
49
|
+
for backend in backends:
|
|
50
|
+
if isinstance(backend, CompositeBackend):
|
|
51
|
+
leaves.extend(leaf_backends(backend.backends))
|
|
52
|
+
else:
|
|
53
|
+
leaves.append(backend)
|
|
54
|
+
return tuple(leaves)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
from pseudonymize.backends.base import BackendCapabilities
|
|
5
|
+
from pseudonymize.detectors import DEFAULT_DETECTORS, Detector
|
|
6
|
+
from pseudonymize.document import ContentBlock
|
|
7
|
+
from pseudonymize.policy import Policy
|
|
8
|
+
from pseudonymize.result import Detection, EntityType
|
|
9
|
+
|
|
10
|
+
_RULE_ENTITY_TYPES = frozenset(
|
|
11
|
+
{
|
|
12
|
+
EntityType.EMAIL,
|
|
13
|
+
EntityType.PHONE,
|
|
14
|
+
EntityType.IP_ADDRESS,
|
|
15
|
+
EntityType.IBAN,
|
|
16
|
+
EntityType.PAYMENT_CARD,
|
|
17
|
+
EntityType.URL_CREDENTIAL,
|
|
18
|
+
EntityType.SECRET,
|
|
19
|
+
}
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class RulesBackend:
|
|
25
|
+
detectors: Sequence[Detector] = DEFAULT_DETECTORS
|
|
26
|
+
name: str = "rules"
|
|
27
|
+
allow_remote_processing: bool = False
|
|
28
|
+
|
|
29
|
+
def __post_init__(self) -> None:
|
|
30
|
+
object.__setattr__(self, "detectors", tuple(self.detectors))
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def capabilities(self) -> BackendCapabilities:
|
|
34
|
+
return BackendCapabilities(_RULE_ENTITY_TYPES)
|
|
35
|
+
|
|
36
|
+
def detect(self, block: ContentBlock, policy: Policy) -> tuple[Detection, ...]:
|
|
37
|
+
return tuple(
|
|
38
|
+
detection
|
|
39
|
+
for detector in self.detectors
|
|
40
|
+
for detection in detector.detect(block.text)
|
|
41
|
+
if detection.entity_type in policy.entity_types
|
|
42
|
+
and detection.confidence >= policy.minimum_confidence
|
|
43
|
+
)
|
pseudonymize/cli.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
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.document import CSVCellLocation, JSONPathLocation, TextOffsetLocation
|
|
14
|
+
from pseudonymize.engine import Data, Pseudonymizer
|
|
15
|
+
from pseudonymize.exceptions import PseudonymizeError
|
|
16
|
+
from pseudonymize.formats import FileFormat
|
|
17
|
+
from pseudonymize.policy import Policy
|
|
18
|
+
from pseudonymize.processing import DetectionReport, ProcessingResult
|
|
19
|
+
from pseudonymize.transforms import TransformationMode
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parser() -> argparse.ArgumentParser:
|
|
23
|
+
parser = argparse.ArgumentParser(prog="pseudonymize")
|
|
24
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
25
|
+
subparsers.add_parser("keygen", help="print a new base64-encoded key")
|
|
26
|
+
subparsers.add_parser("detectors", help="list bundled detectors")
|
|
27
|
+
|
|
28
|
+
text_parser = subparsers.add_parser("text", help="process text")
|
|
29
|
+
text_parser.add_argument("text", help="text to process, or - for standard input")
|
|
30
|
+
_add_key_options(text_parser, allow_stdin=True)
|
|
31
|
+
text_parser.add_argument("--namespace", default="default")
|
|
32
|
+
_add_mode_options(text_parser)
|
|
33
|
+
|
|
34
|
+
json_parser = subparsers.add_parser("json", help="process JSON from standard input")
|
|
35
|
+
_add_key_options(json_parser)
|
|
36
|
+
json_parser.add_argument("--namespace", default="default")
|
|
37
|
+
_add_mode_options(json_parser)
|
|
38
|
+
|
|
39
|
+
file_parser = subparsers.add_parser("file", help="process a supported file")
|
|
40
|
+
file_parser.add_argument("source", type=Path)
|
|
41
|
+
file_parser.add_argument("--output", type=Path)
|
|
42
|
+
file_parser.add_argument("--format", choices=tuple(FileFormat))
|
|
43
|
+
file_parser.add_argument("--encoding")
|
|
44
|
+
file_parser.add_argument("--overwrite", action="store_true")
|
|
45
|
+
_add_key_options(file_parser, allow_stdin=True)
|
|
46
|
+
file_parser.add_argument("--namespace", default="default")
|
|
47
|
+
_add_mode_options(file_parser)
|
|
48
|
+
|
|
49
|
+
inspect_parser = subparsers.add_parser(
|
|
50
|
+
"inspect-file", help="inspect a supported file without writing output"
|
|
51
|
+
)
|
|
52
|
+
inspect_parser.add_argument("source", type=Path)
|
|
53
|
+
inspect_parser.add_argument("--format", choices=tuple(FileFormat))
|
|
54
|
+
inspect_parser.add_argument("--encoding")
|
|
55
|
+
return parser
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _add_key_options(parser: argparse.ArgumentParser, *, allow_stdin: bool = False) -> None:
|
|
59
|
+
sources = parser.add_mutually_exclusive_group()
|
|
60
|
+
sources.add_argument("--key-env", metavar="NAME")
|
|
61
|
+
sources.add_argument("--key-file", type=Path)
|
|
62
|
+
sources.add_argument("--key-fd", type=int)
|
|
63
|
+
if allow_stdin:
|
|
64
|
+
sources.add_argument("--key-stdin", action="store_true")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _add_mode_options(parser: argparse.ArgumentParser) -> None:
|
|
68
|
+
modes = parser.add_mutually_exclusive_group()
|
|
69
|
+
modes.add_argument(
|
|
70
|
+
"--mode", choices=tuple(TransformationMode), default=TransformationMode.NUMBERED
|
|
71
|
+
)
|
|
72
|
+
modes.add_argument(
|
|
73
|
+
"--redact", action="store_const", const=TransformationMode.REDACTED, dest="mode"
|
|
74
|
+
)
|
|
75
|
+
parser.add_argument("--typed-redaction", action="store_true")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _has_key_source(arguments: argparse.Namespace) -> bool:
|
|
79
|
+
return any(
|
|
80
|
+
(
|
|
81
|
+
getattr(arguments, "key_env", None),
|
|
82
|
+
getattr(arguments, "key_file", None),
|
|
83
|
+
getattr(arguments, "key_fd", None) is not None,
|
|
84
|
+
getattr(arguments, "key_stdin", False),
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _read_key(arguments: argparse.Namespace) -> bytes:
|
|
90
|
+
encoded: str
|
|
91
|
+
if getattr(arguments, "key_env", None):
|
|
92
|
+
encoded = os.environ.get(arguments.key_env, "")
|
|
93
|
+
if not encoded:
|
|
94
|
+
raise ValueError(f"environment variable {arguments.key_env!r} is empty or unset")
|
|
95
|
+
elif getattr(arguments, "key_file", None):
|
|
96
|
+
path: Path = arguments.key_file
|
|
97
|
+
if os.name != "nt" and stat.S_IMODE(path.stat().st_mode) & 0o077:
|
|
98
|
+
raise ValueError("key file must not be accessible by group or other users")
|
|
99
|
+
encoded = path.read_text(encoding="ascii").strip()
|
|
100
|
+
elif getattr(arguments, "key_fd", None) is not None:
|
|
101
|
+
with os.fdopen(os.dup(arguments.key_fd), encoding="ascii") as stream:
|
|
102
|
+
encoded = stream.read().strip()
|
|
103
|
+
else:
|
|
104
|
+
if getattr(arguments, "text", None) == "-":
|
|
105
|
+
raise ValueError("standard input cannot provide both the key and text")
|
|
106
|
+
encoded = sys.stdin.read().strip()
|
|
107
|
+
try:
|
|
108
|
+
return base64.b64decode(encoded, validate=True)
|
|
109
|
+
except (binascii.Error, ValueError) as error:
|
|
110
|
+
raise ValueError("key must be valid base64") from error
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
114
|
+
parser = _parser()
|
|
115
|
+
arguments = parser.parse_args(argv)
|
|
116
|
+
if arguments.command == "keygen":
|
|
117
|
+
print(base64.b64encode(generate_key()).decode("ascii"))
|
|
118
|
+
return 0
|
|
119
|
+
if arguments.command == "detectors":
|
|
120
|
+
print("\n".join(detector.name for detector in DEFAULT_DETECTORS))
|
|
121
|
+
return 0
|
|
122
|
+
try:
|
|
123
|
+
if arguments.command == "inspect-file":
|
|
124
|
+
inspected = Pseudonymizer().inspect_file(
|
|
125
|
+
arguments.source,
|
|
126
|
+
format=arguments.format,
|
|
127
|
+
encoding=arguments.encoding,
|
|
128
|
+
)
|
|
129
|
+
json.dump(_inspection_payload(inspected), sys.stdout, ensure_ascii=False)
|
|
130
|
+
sys.stdout.write("\n")
|
|
131
|
+
return 0
|
|
132
|
+
key = _read_key(arguments) if _has_key_source(arguments) else None
|
|
133
|
+
engine = Pseudonymizer(
|
|
134
|
+
mode=arguments.mode,
|
|
135
|
+
key=key,
|
|
136
|
+
namespace=arguments.namespace,
|
|
137
|
+
policy=Policy.llm() if arguments.command == "json" else Policy.default(),
|
|
138
|
+
typed_redaction=arguments.typed_redaction,
|
|
139
|
+
)
|
|
140
|
+
if arguments.command == "text":
|
|
141
|
+
source = sys.stdin.read() if arguments.text == "-" else arguments.text
|
|
142
|
+
print(engine.process(source).text)
|
|
143
|
+
return 0
|
|
144
|
+
if arguments.command == "file":
|
|
145
|
+
result = engine.process_file(
|
|
146
|
+
arguments.source,
|
|
147
|
+
arguments.output,
|
|
148
|
+
format=arguments.format,
|
|
149
|
+
encoding=arguments.encoding,
|
|
150
|
+
overwrite=arguments.overwrite,
|
|
151
|
+
)
|
|
152
|
+
print(result.output)
|
|
153
|
+
return 0
|
|
154
|
+
payload = json.load(sys.stdin)
|
|
155
|
+
output: Data = engine.process_data(payload)
|
|
156
|
+
json.dump(output, sys.stdout, ensure_ascii=False)
|
|
157
|
+
sys.stdout.write("\n")
|
|
158
|
+
return 0
|
|
159
|
+
except (LookupError, OSError, ValueError, json.JSONDecodeError, PseudonymizeError) as error:
|
|
160
|
+
parser.exit(2, f"pseudonymize: error: {error}\n")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _inspection_payload(result: ProcessingResult[None]) -> dict[str, object]:
|
|
164
|
+
return {
|
|
165
|
+
"detections": [_detection_payload(report) for report in result.detections],
|
|
166
|
+
"statistics": {
|
|
167
|
+
"blocks_processed": result.statistics.blocks_processed,
|
|
168
|
+
"detections_found": result.statistics.detections_found,
|
|
169
|
+
"replacements_applied": result.statistics.replacements_applied,
|
|
170
|
+
"backend_invocations": result.statistics.backend_invocations,
|
|
171
|
+
"local_block_calls": result.statistics.local_block_calls,
|
|
172
|
+
"remote_block_calls": result.statistics.remote_block_calls,
|
|
173
|
+
},
|
|
174
|
+
"warnings": [
|
|
175
|
+
{"code": warning.code, "message": warning.message, "block_id": warning.block_id}
|
|
176
|
+
for warning in result.warnings
|
|
177
|
+
],
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _detection_payload(report: DetectionReport) -> dict[str, object]:
|
|
182
|
+
return {
|
|
183
|
+
"entity_type": report.entity_type.value,
|
|
184
|
+
"block_id": report.block_id,
|
|
185
|
+
"location": _location_payload(report),
|
|
186
|
+
"start": report.start,
|
|
187
|
+
"end": report.end,
|
|
188
|
+
"confidence": report.confidence,
|
|
189
|
+
"backend": report.backend,
|
|
190
|
+
"detector": report.detector,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _location_payload(report: DetectionReport) -> dict[str, object]:
|
|
195
|
+
location = report.location
|
|
196
|
+
if isinstance(location, TextOffsetLocation):
|
|
197
|
+
return {"kind": "text_offset", "start": location.start, "end": location.end}
|
|
198
|
+
if isinstance(location, JSONPathLocation):
|
|
199
|
+
return {"kind": "json_path", "path": location.path}
|
|
200
|
+
if isinstance(location, CSVCellLocation):
|
|
201
|
+
return {"kind": "csv_cell", "row": location.row, "column": location.column}
|
|
202
|
+
raise TypeError("unsupported report location")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
if __name__ == "__main__":
|
|
206
|
+
raise SystemExit(main())
|
|
@@ -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,27 @@
|
|
|
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:]+)"
|
|
9
|
+
r"(?![\w:]|\.[\w:])"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class IpAddressDetector:
|
|
15
|
+
name: str = "ip_address"
|
|
16
|
+
|
|
17
|
+
def detect(self, text: str) -> list[Detection]:
|
|
18
|
+
detections: list[Detection] = []
|
|
19
|
+
for match in _IP_CANDIDATE.finditer(text):
|
|
20
|
+
try:
|
|
21
|
+
ipaddress.ip_address(match.group())
|
|
22
|
+
except ValueError:
|
|
23
|
+
continue
|
|
24
|
+
detections.append(
|
|
25
|
+
Detection(EntityType.IP_ADDRESS, match.start(), match.end(), 0.99, self.name)
|
|
26
|
+
)
|
|
27
|
+
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
|
+
]
|