ciphergate-cli 0.7.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.
- ciphergate/__init__.py +95 -0
- ciphergate/__main__.py +6 -0
- ciphergate/cli.py +832 -0
- ciphergate/config.py +50 -0
- ciphergate/constants.py +274 -0
- ciphergate/detectors/__init__.py +5 -0
- ciphergate/detectors/base.py +53 -0
- ciphergate/detectors/custom_regex.py +37 -0
- ciphergate/detectors/dates.py +92 -0
- ciphergate/detectors/email.py +56 -0
- ciphergate/detectors/field_names.py +220 -0
- ciphergate/detectors/ip_address.py +85 -0
- ciphergate/detectors/network.py +42 -0
- ciphergate/detectors/payment_card.py +77 -0
- ciphergate/detectors/phone.py +67 -0
- ciphergate/detectors/presidio_ner.py +112 -0
- ciphergate/detectors/regex_safety.py +40 -0
- ciphergate/detectors/registry.py +194 -0
- ciphergate/detectors/secrets.py +130 -0
- ciphergate/errors.py +119 -0
- ciphergate/models/__init__.py +1 -0
- ciphergate/models/context.py +69 -0
- ciphergate/models/finding.py +108 -0
- ciphergate/models/policy.py +402 -0
- ciphergate/models/report.py +115 -0
- ciphergate/pipeline/__init__.py +1 -0
- ciphergate/pipeline/atomic_output.py +143 -0
- ciphergate/pipeline/batch.py +533 -0
- ciphergate/pipeline/inspector.py +282 -0
- ciphergate/pipeline/paths.py +98 -0
- ciphergate/pipeline/planner.py +192 -0
- ciphergate/pipeline/rescan.py +120 -0
- ciphergate/pipeline/sanitizer.py +392 -0
- ciphergate/pipeline/stdio.py +173 -0
- ciphergate/pipeline/streams.py +152 -0
- ciphergate/policy/__init__.py +26 -0
- ciphergate/policy/context.py +203 -0
- ciphergate/policy/engine.py +194 -0
- ciphergate/policy/loader.py +116 -0
- ciphergate/policy/precedence.py +149 -0
- ciphergate/policy/validator.py +632 -0
- ciphergate/py.typed +0 -0
- ciphergate/readers/__init__.py +113 -0
- ciphergate/readers/accesslog_reader.py +244 -0
- ciphergate/readers/base.py +131 -0
- ciphergate/readers/csv_reader.py +241 -0
- ciphergate/readers/json_reader.py +184 -0
- ciphergate/readers/jsonl_reader.py +97 -0
- ciphergate/readers/jsonlog_reader.py +178 -0
- ciphergate/readers/line_structure.py +125 -0
- ciphergate/readers/logfmt_reader.py +100 -0
- ciphergate/readers/stacktrace_reader.py +103 -0
- ciphergate/readers/syslog_reader.py +309 -0
- ciphergate/readers/text_reader.py +59 -0
- ciphergate/reporting/__init__.py +1 -0
- ciphergate/reporting/audit.py +85 -0
- ciphergate/reporting/safe_text.py +71 -0
- ciphergate/reporting/serializer.py +44 -0
- ciphergate/reporting/terminal.py +147 -0
- ciphergate/templates/starter_policy.yml +77 -0
- ciphergate/transforms/__init__.py +6 -0
- ciphergate/transforms/date_shift.py +79 -0
- ciphergate/transforms/generalize.py +107 -0
- ciphergate/transforms/keys.py +177 -0
- ciphergate/transforms/pseudonymize.py +113 -0
- ciphergate/transforms/redact.py +9 -0
- ciphergate/transforms/registry.py +201 -0
- ciphergate/transforms/replace.py +16 -0
- ciphergate_cli-0.7.0.dist-info/METADATA +531 -0
- ciphergate_cli-0.7.0.dist-info/RECORD +74 -0
- ciphergate_cli-0.7.0.dist-info/WHEEL +4 -0
- ciphergate_cli-0.7.0.dist-info/entry_points.txt +2 -0
- ciphergate_cli-0.7.0.dist-info/licenses/LICENSE +202 -0
- ciphergate_cli-0.7.0.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Key material lookup for keyed transforms.
|
|
2
|
+
|
|
3
|
+
* Keys come only from the environment: the variable named by the policy (``key_env``), or a
|
|
4
|
+
file whose path is in ``<key_env>_FILE`` (the Docker/Kubernetes secrets convention). Never CLI
|
|
5
|
+
flags, never policy files. Setting both is refused as ambiguous.
|
|
6
|
+
* Key files must be regular files of at most ``MAX_KEY_FILE_BYTES``. On POSIX they must also be
|
|
7
|
+
owned by the current user and not readable or writable by group/others (``0400``/``0600``);
|
|
8
|
+
symlinks are followed (Kubernetes mounts secrets through them) and the target is checked.
|
|
9
|
+
Windows ACLs are not inspected: a warning says so. One trailing newline is removed.
|
|
10
|
+
* Encoding: ``hex:<hex>`` or ``base64:<b64>`` are decoded; anything else is used as raw UTF-8 bytes.
|
|
11
|
+
* Minimum effective length: 32 bytes after decoding, and at least 8 distinct byte values.
|
|
12
|
+
* There is no fallback key. A missing/invalid key raises ``MissingKeyError`` naming only
|
|
13
|
+
the variable.
|
|
14
|
+
* Key bytes are never included in ``repr``, logs, reports or exception messages.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import base64
|
|
20
|
+
import binascii
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import stat
|
|
24
|
+
import sys
|
|
25
|
+
from collections.abc import Mapping
|
|
26
|
+
from enum import StrEnum
|
|
27
|
+
|
|
28
|
+
from ciphergate.constants import MIN_KEY_BYTES
|
|
29
|
+
from ciphergate.errors import MissingKeyError
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("ciphergate")
|
|
32
|
+
|
|
33
|
+
FILE_SUFFIX = "_FILE"
|
|
34
|
+
MAX_KEY_FILE_BYTES = 4096
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class KeyStatus(StrEnum):
|
|
38
|
+
AVAILABLE = "available"
|
|
39
|
+
MISSING = "missing"
|
|
40
|
+
TOO_SHORT = "too_short"
|
|
41
|
+
INVALID_ENCODING = "invalid_encoding"
|
|
42
|
+
LOW_DIVERSITY = "low_diversity"
|
|
43
|
+
AMBIGUOUS_SOURCE = "ambiguous_source"
|
|
44
|
+
FILE_UNREADABLE = "file_unreadable"
|
|
45
|
+
FILE_NOT_REGULAR = "file_not_regular"
|
|
46
|
+
FILE_TOO_LARGE = "file_too_large"
|
|
47
|
+
FILE_INSECURE = "file_permissions_insecure"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_FILE_STATUSES = {
|
|
51
|
+
KeyStatus.AMBIGUOUS_SOURCE,
|
|
52
|
+
KeyStatus.FILE_UNREADABLE,
|
|
53
|
+
KeyStatus.FILE_NOT_REGULAR,
|
|
54
|
+
KeyStatus.FILE_TOO_LARGE,
|
|
55
|
+
KeyStatus.FILE_INSECURE,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _decode(raw: str) -> bytes | None:
|
|
60
|
+
if raw.startswith("hex:"):
|
|
61
|
+
try:
|
|
62
|
+
return bytes.fromhex(raw[4:].strip())
|
|
63
|
+
except ValueError:
|
|
64
|
+
return None
|
|
65
|
+
if raw.startswith("base64:"):
|
|
66
|
+
try:
|
|
67
|
+
return base64.b64decode(raw[7:].strip(), validate=True)
|
|
68
|
+
except (binascii.Error, ValueError):
|
|
69
|
+
return None
|
|
70
|
+
return raw.encode("utf-8")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _read_key_file(path: str) -> tuple[str | None, KeyStatus | None]:
|
|
74
|
+
"""Return (raw key text, None) or (None, failure status). Never raises for file problems."""
|
|
75
|
+
try:
|
|
76
|
+
st = os.stat(path)
|
|
77
|
+
except OSError:
|
|
78
|
+
return None, KeyStatus.FILE_UNREADABLE
|
|
79
|
+
if not stat.S_ISREG(st.st_mode):
|
|
80
|
+
return None, KeyStatus.FILE_NOT_REGULAR
|
|
81
|
+
if st.st_size > MAX_KEY_FILE_BYTES:
|
|
82
|
+
return None, KeyStatus.FILE_TOO_LARGE
|
|
83
|
+
if sys.platform != "win32" and (st.st_mode & 0o077 or st.st_uid != os.geteuid()):
|
|
84
|
+
return None, KeyStatus.FILE_INSECURE
|
|
85
|
+
try:
|
|
86
|
+
with open(path, "rb") as fh:
|
|
87
|
+
data = fh.read(MAX_KEY_FILE_BYTES + 1)
|
|
88
|
+
except OSError:
|
|
89
|
+
return None, KeyStatus.FILE_UNREADABLE
|
|
90
|
+
if len(data) > MAX_KEY_FILE_BYTES:
|
|
91
|
+
return None, KeyStatus.FILE_TOO_LARGE
|
|
92
|
+
try:
|
|
93
|
+
text = data.decode("utf-8")
|
|
94
|
+
except UnicodeDecodeError:
|
|
95
|
+
return None, KeyStatus.INVALID_ENCODING
|
|
96
|
+
if text.endswith("\r\n"):
|
|
97
|
+
text = text[:-2]
|
|
98
|
+
elif text.endswith("\n"):
|
|
99
|
+
text = text[:-1]
|
|
100
|
+
if sys.platform == "win32":
|
|
101
|
+
logger.warning(
|
|
102
|
+
"key file permissions are not verified on Windows; restrict its ACL to your account"
|
|
103
|
+
)
|
|
104
|
+
return text, None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class KeyProvider:
|
|
108
|
+
def __init__(self, environ: Mapping[str, str] | None = None) -> None:
|
|
109
|
+
self._environ = environ if environ is not None else os.environ
|
|
110
|
+
self._cache: dict[str, bytes] = {}
|
|
111
|
+
self._raw: dict[str, tuple[str | None, KeyStatus | None, str]] = {}
|
|
112
|
+
|
|
113
|
+
def __repr__(self) -> str:
|
|
114
|
+
return "KeyProvider(<keys not shown>)"
|
|
115
|
+
|
|
116
|
+
def _lookup(self, name: str) -> tuple[str | None, KeyStatus | None, str]:
|
|
117
|
+
"""(raw key text, failure status, source) — the file is read at most once per run."""
|
|
118
|
+
if name not in self._raw:
|
|
119
|
+
value = self._environ.get(name) or None
|
|
120
|
+
file_path = self._environ.get(name + FILE_SUFFIX) or None
|
|
121
|
+
if value is not None and file_path is not None:
|
|
122
|
+
self._raw[name] = (None, KeyStatus.AMBIGUOUS_SOURCE, "ambiguous")
|
|
123
|
+
elif file_path is not None:
|
|
124
|
+
text, failure = _read_key_file(file_path)
|
|
125
|
+
self._raw[name] = (text, failure, "file")
|
|
126
|
+
else:
|
|
127
|
+
self._raw[name] = (value, None, "env")
|
|
128
|
+
return self._raw[name]
|
|
129
|
+
|
|
130
|
+
def source(self, name: str) -> str:
|
|
131
|
+
"""Where the key comes from: ``env``, ``file`` or ``ambiguous`` (never the path)."""
|
|
132
|
+
return self._lookup(name)[2]
|
|
133
|
+
|
|
134
|
+
def status(self, name: str) -> KeyStatus:
|
|
135
|
+
raw, failure, _ = self._lookup(name)
|
|
136
|
+
if failure is not None:
|
|
137
|
+
return failure
|
|
138
|
+
if raw is None or raw == "":
|
|
139
|
+
return KeyStatus.MISSING
|
|
140
|
+
key = _decode(raw)
|
|
141
|
+
if key is None:
|
|
142
|
+
return KeyStatus.INVALID_ENCODING
|
|
143
|
+
if len(key) < MIN_KEY_BYTES:
|
|
144
|
+
return KeyStatus.TOO_SHORT
|
|
145
|
+
if len(set(key)) < 8:
|
|
146
|
+
return KeyStatus.LOW_DIVERSITY
|
|
147
|
+
return KeyStatus.AVAILABLE
|
|
148
|
+
|
|
149
|
+
def get(self, name: str) -> bytes:
|
|
150
|
+
if name in self._cache:
|
|
151
|
+
return self._cache[name]
|
|
152
|
+
status = self.status(name)
|
|
153
|
+
if status != KeyStatus.AVAILABLE:
|
|
154
|
+
where = (
|
|
155
|
+
f"key file {name}{FILE_SUFFIX}"
|
|
156
|
+
if self.source(name) == "file"
|
|
157
|
+
else f"key environment variable {name}"
|
|
158
|
+
)
|
|
159
|
+
if status == KeyStatus.AMBIGUOUS_SOURCE:
|
|
160
|
+
message = f"both {name} and {name}{FILE_SUFFIX} are set; set only one"
|
|
161
|
+
else:
|
|
162
|
+
message = (
|
|
163
|
+
f"{where} is {status.value.replace('_', ' ')} "
|
|
164
|
+
f"(need at least {MIN_KEY_BYTES} bytes; see docs/security-and-privacy.md)"
|
|
165
|
+
)
|
|
166
|
+
raise MissingKeyError(
|
|
167
|
+
message,
|
|
168
|
+
code="key_missing"
|
|
169
|
+
if status == KeyStatus.MISSING
|
|
170
|
+
else ("key_file_invalid" if status in _FILE_STATUSES else "key_invalid"),
|
|
171
|
+
)
|
|
172
|
+
raw = self._lookup(name)[0]
|
|
173
|
+
key = _decode(raw) if raw is not None else None
|
|
174
|
+
if key is None: # pragma: no cover - status() already validated the encoding
|
|
175
|
+
raise MissingKeyError(f"key {name} is invalid", code="key_invalid")
|
|
176
|
+
self._cache[name] = key
|
|
177
|
+
return key
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Keyed, domain-separated, versioned pseudonyms (HMAC-SHA-256). Never plain hashes.
|
|
2
|
+
|
|
3
|
+
Token format: ``<prefix>pz1_<32 lowercase base32 chars>`` (160 bits of the HMAC output).
|
|
4
|
+
|
|
5
|
+
Derivation (version ``pz1``)::
|
|
6
|
+
|
|
7
|
+
subkey = HMAC-SHA256(key, "ciphergate:pz1:subkey")
|
|
8
|
+
domain = "ciphergate:pz1:<namespace>:<scope label>"
|
|
9
|
+
message = len(domain) || domain || type_tag || len(canonical) || canonical (4-byte lengths)
|
|
10
|
+
token = base32(HMAC-SHA256(subkey, message))[:32]
|
|
11
|
+
|
|
12
|
+
Scope labels: ``field:<path with array indices replaced by *>``, ``category:<category>`` or
|
|
13
|
+
``entity:<name>``. The namespace defaults to the policy name. Canonical values are UTF-8 with a type
|
|
14
|
+
tag (``s`` string, ``i`` integer, ``f`` float, ``b`` boolean) so ``"123"`` and ``123`` differ.
|
|
15
|
+
Strings are NFC-normalised by default (``casefold_nfc`` also case-folds; ``none`` disables).
|
|
16
|
+
|
|
17
|
+
This is pseudonymization, not anonymization: equal inputs under the same key/scope link together.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
import hashlib
|
|
24
|
+
import hmac
|
|
25
|
+
import re
|
|
26
|
+
import struct
|
|
27
|
+
import unicodedata
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from ciphergate.constants import PSEUDONYM_VERSION
|
|
31
|
+
from ciphergate.errors import TransformError
|
|
32
|
+
from ciphergate.models.policy import PseudonymizeParams
|
|
33
|
+
|
|
34
|
+
_INDEX_SEGMENT = re.compile(r"/\d+(?=/|$)")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def generic_field_path(path: str) -> str:
|
|
38
|
+
return _INDEX_SEGMENT.sub("/*", path) or "text"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def canonical_value(value: Any, normalize: str) -> tuple[bytes, bytes]:
|
|
42
|
+
if isinstance(value, bool):
|
|
43
|
+
return b"b", b"1" if value else b"0"
|
|
44
|
+
if isinstance(value, int):
|
|
45
|
+
return b"i", str(value).encode()
|
|
46
|
+
if isinstance(value, float):
|
|
47
|
+
return b"f", repr(value).encode()
|
|
48
|
+
if isinstance(value, str):
|
|
49
|
+
text = value
|
|
50
|
+
if normalize in ("nfc", "casefold_nfc"):
|
|
51
|
+
text = unicodedata.normalize("NFC", text)
|
|
52
|
+
if normalize == "casefold_nfc":
|
|
53
|
+
text = text.casefold()
|
|
54
|
+
return b"s", text.encode("utf-8")
|
|
55
|
+
raise TransformError(
|
|
56
|
+
"pseudonymize supports strings, numbers and booleans only", code="unsupported_value_type"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _lp(data: bytes) -> bytes:
|
|
61
|
+
return struct.pack(">I", len(data)) + data
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def derive_token(key: bytes, domain: str, value: Any, normalize: str) -> tuple[str, bytes]:
|
|
65
|
+
subkey = hmac.new(
|
|
66
|
+
key, f"ciphergate:{PSEUDONYM_VERSION}:subkey".encode(), hashlib.sha256
|
|
67
|
+
).digest()
|
|
68
|
+
tag, canonical = canonical_value(value, normalize)
|
|
69
|
+
message = _lp(domain.encode("utf-8")) + tag + _lp(canonical)
|
|
70
|
+
digest = hmac.new(subkey, message, hashlib.sha256).digest()
|
|
71
|
+
token = base64.b32encode(digest).decode("ascii").lower().rstrip("=")[:32]
|
|
72
|
+
return token, digest
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class CollisionTracker:
|
|
76
|
+
"""Detects two different inputs producing the same token within a run (no values stored)."""
|
|
77
|
+
|
|
78
|
+
def __init__(self) -> None:
|
|
79
|
+
self._seen: dict[str, bytes] = {}
|
|
80
|
+
|
|
81
|
+
def check(self, token: str, digest: bytes) -> None:
|
|
82
|
+
previous = self._seen.setdefault(token, digest)
|
|
83
|
+
if previous != digest:
|
|
84
|
+
raise TransformError(
|
|
85
|
+
"pseudonym collision detected; refusing to continue", code="pseudonym_collision"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def scope_label(params: PseudonymizeParams, category: str, field_path: str) -> str:
|
|
90
|
+
if params.scope == "field":
|
|
91
|
+
return f"field:{generic_field_path(field_path)}"
|
|
92
|
+
if params.scope == "category":
|
|
93
|
+
return f"category:{category}"
|
|
94
|
+
return f"entity:{params.entity}"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def pseudonymize(
|
|
98
|
+
value: Any,
|
|
99
|
+
params: PseudonymizeParams,
|
|
100
|
+
*,
|
|
101
|
+
key: bytes,
|
|
102
|
+
namespace: str,
|
|
103
|
+
category: str,
|
|
104
|
+
field_path: str,
|
|
105
|
+
collisions: CollisionTracker | None = None,
|
|
106
|
+
) -> str:
|
|
107
|
+
domain = f"ciphergate:{PSEUDONYM_VERSION}:{params.namespace or namespace}:" + scope_label(
|
|
108
|
+
params, category, field_path
|
|
109
|
+
)
|
|
110
|
+
token, digest = derive_token(key, domain, value, params.normalize)
|
|
111
|
+
if collisions is not None:
|
|
112
|
+
collisions.check(f"{domain}|{token}", digest)
|
|
113
|
+
return f"{params.prefix}{PSEUDONYM_VERSION}_{token}"
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Constant-marker redaction. The marker names the category at most; never any source data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ciphergate.models.policy import RedactParams
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def redact(params: RedactParams, category: str) -> str:
|
|
9
|
+
return params.marker.replace("{category}", category)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Applies approved decisions to one value. Created per run (no global mutable state).
|
|
2
|
+
|
|
3
|
+
Combination rules for several decisions on the same value:
|
|
4
|
+
|
|
5
|
+
1. any ``drop_record`` -> the record is dropped; else any ``drop_field`` -> the field is dropped;
|
|
6
|
+
2. whole-value decisions (field-name/classification findings) — the most protective transform
|
|
7
|
+
(by ``ACTION_RANK``) replaces the entire value;
|
|
8
|
+
3. otherwise span decisions (pattern findings) — overlapping spans are merged, each merged span
|
|
9
|
+
takes its most protective action, and spans are rewritten right-to-left so surrounding debug
|
|
10
|
+
context is preserved.
|
|
11
|
+
|
|
12
|
+
``block`` and ``review`` never reach this module: the planner withholds output before pass two.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any, Protocol
|
|
20
|
+
|
|
21
|
+
from ciphergate.constants import ACTION_RANK, Action
|
|
22
|
+
from ciphergate.errors import InternalError, TransformError
|
|
23
|
+
from ciphergate.models.finding import Finding, value_type_name
|
|
24
|
+
from ciphergate.models.policy import (
|
|
25
|
+
ActionParams,
|
|
26
|
+
DateShiftParams,
|
|
27
|
+
GeneralizeParams,
|
|
28
|
+
PseudonymizeParams,
|
|
29
|
+
RedactParams,
|
|
30
|
+
ReplaceParams,
|
|
31
|
+
)
|
|
32
|
+
from ciphergate.policy.engine import Decision
|
|
33
|
+
from ciphergate.policy.validator import Policy
|
|
34
|
+
from ciphergate.transforms.date_shift import date_shift, derive_offset_days
|
|
35
|
+
from ciphergate.transforms.generalize import generalize
|
|
36
|
+
from ciphergate.transforms.keys import KeyProvider
|
|
37
|
+
from ciphergate.transforms.pseudonymize import CollisionTracker, pseudonymize
|
|
38
|
+
from ciphergate.transforms.redact import redact
|
|
39
|
+
from ciphergate.transforms.replace import replace
|
|
40
|
+
|
|
41
|
+
EntityLookup = Callable[[str], Any]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SpanCodec(Protocol):
|
|
45
|
+
"""Maps a raw span to the value a transform sees, and a replacement back to raw text."""
|
|
46
|
+
|
|
47
|
+
def decode(self, start: int, end: int, raw: str) -> Any: ...
|
|
48
|
+
|
|
49
|
+
def encode(self, start: int, end: int, replacement: Any) -> str: ...
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class ValueOutcome:
|
|
54
|
+
value: Any
|
|
55
|
+
changed: bool = False
|
|
56
|
+
type_changed: bool = False
|
|
57
|
+
drop_field: bool = False
|
|
58
|
+
drop_record: bool = False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Transformer:
|
|
62
|
+
def __init__(self, policy: Policy, keys: KeyProvider) -> None:
|
|
63
|
+
self.namespace = policy.name
|
|
64
|
+
self.keys = keys
|
|
65
|
+
self.collisions = CollisionTracker()
|
|
66
|
+
|
|
67
|
+
def apply_action(
|
|
68
|
+
self,
|
|
69
|
+
action: Action,
|
|
70
|
+
params: ActionParams,
|
|
71
|
+
value: Any,
|
|
72
|
+
*,
|
|
73
|
+
category: str,
|
|
74
|
+
field_path: str,
|
|
75
|
+
in_span: bool,
|
|
76
|
+
entity_lookup: EntityLookup | None,
|
|
77
|
+
) -> Any:
|
|
78
|
+
if action == Action.ALLOW:
|
|
79
|
+
return value
|
|
80
|
+
if action == Action.REDACT and isinstance(params, RedactParams):
|
|
81
|
+
return redact(params, category)
|
|
82
|
+
if action == Action.REPLACE and isinstance(params, ReplaceParams):
|
|
83
|
+
return replace(params, in_span=in_span)
|
|
84
|
+
if action == Action.PSEUDONYMIZE and isinstance(params, PseudonymizeParams):
|
|
85
|
+
if value is None:
|
|
86
|
+
return None
|
|
87
|
+
return pseudonymize(
|
|
88
|
+
value,
|
|
89
|
+
params,
|
|
90
|
+
key=self.keys.get(params.key_env),
|
|
91
|
+
namespace=self.namespace,
|
|
92
|
+
category=category,
|
|
93
|
+
field_path=field_path,
|
|
94
|
+
collisions=self.collisions,
|
|
95
|
+
)
|
|
96
|
+
if action == Action.GENERALIZE and isinstance(params, GeneralizeParams):
|
|
97
|
+
return generalize(value, params)
|
|
98
|
+
if action == Action.DATE_SHIFT and isinstance(params, DateShiftParams):
|
|
99
|
+
if params.scope == "entity":
|
|
100
|
+
if entity_lookup is None or params.entity_field is None:
|
|
101
|
+
raise TransformError(
|
|
102
|
+
"date_shift scope 'entity' needs structured input",
|
|
103
|
+
code="date_shift_entity_unavailable",
|
|
104
|
+
)
|
|
105
|
+
entity = entity_lookup(params.entity_field)
|
|
106
|
+
if entity is None or entity == "":
|
|
107
|
+
raise TransformError(
|
|
108
|
+
"date_shift entity_field is missing in a record",
|
|
109
|
+
code="date_shift_entity_missing",
|
|
110
|
+
)
|
|
111
|
+
else:
|
|
112
|
+
entity = "dataset"
|
|
113
|
+
offset = derive_offset_days(
|
|
114
|
+
self.keys.get(params.key_env),
|
|
115
|
+
params.namespace or self.namespace,
|
|
116
|
+
entity,
|
|
117
|
+
params.max_days,
|
|
118
|
+
)
|
|
119
|
+
return date_shift(value, params, offset_days=offset)
|
|
120
|
+
raise TransformError(
|
|
121
|
+
f"action '{action}' cannot transform a value", code="unsupported_transform"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def transform_value(
|
|
125
|
+
self,
|
|
126
|
+
value: Any,
|
|
127
|
+
evaluated: list[tuple[Finding, Decision]],
|
|
128
|
+
*,
|
|
129
|
+
field_path: str,
|
|
130
|
+
entity_lookup: EntityLookup | None = None,
|
|
131
|
+
span_codec: SpanCodec | None = None,
|
|
132
|
+
) -> ValueOutcome:
|
|
133
|
+
if not evaluated:
|
|
134
|
+
return ValueOutcome(value)
|
|
135
|
+
actions = {d.action for _, d in evaluated}
|
|
136
|
+
if Action.BLOCK in actions or Action.REVIEW in actions:
|
|
137
|
+
raise InternalError("blocked/review decisions must not reach the transformer")
|
|
138
|
+
if Action.DROP_RECORD in actions:
|
|
139
|
+
return ValueOutcome(value, changed=True, drop_record=True)
|
|
140
|
+
if Action.DROP_FIELD in actions:
|
|
141
|
+
return ValueOutcome(value, changed=True, drop_field=True)
|
|
142
|
+
|
|
143
|
+
whole = [(f, d) for f, d in evaluated if f.whole_value and d.action != Action.ALLOW]
|
|
144
|
+
if whole:
|
|
145
|
+
finding, decision = max(
|
|
146
|
+
whole, key=lambda fd: (ACTION_RANK[fd[1].action], -fd[0].location.record_index)
|
|
147
|
+
)
|
|
148
|
+
new = self.apply_action(
|
|
149
|
+
decision.action,
|
|
150
|
+
decision.params,
|
|
151
|
+
value,
|
|
152
|
+
category=finding.category,
|
|
153
|
+
field_path=field_path,
|
|
154
|
+
in_span=False,
|
|
155
|
+
entity_lookup=entity_lookup,
|
|
156
|
+
)
|
|
157
|
+
return ValueOutcome(
|
|
158
|
+
new,
|
|
159
|
+
changed=new != value or type(new) is not type(value),
|
|
160
|
+
type_changed=value_type_name(new) != value_type_name(value),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
spans = sorted(
|
|
164
|
+
(
|
|
165
|
+
(f.location.span, f, d)
|
|
166
|
+
for f, d in evaluated
|
|
167
|
+
if f.location.span is not None and d.action != Action.ALLOW
|
|
168
|
+
),
|
|
169
|
+
key=lambda item: (item[0][0], item[0][1]),
|
|
170
|
+
)
|
|
171
|
+
if not spans:
|
|
172
|
+
return ValueOutcome(value)
|
|
173
|
+
text = value if isinstance(value, str) else ("" if value is None else str(value))
|
|
174
|
+
groups: list[list[Any]] = [] # [start, end, finding, decision]
|
|
175
|
+
for span, finding, decision in spans:
|
|
176
|
+
if span is None:
|
|
177
|
+
continue
|
|
178
|
+
if groups and span[0] < groups[-1][1]:
|
|
179
|
+
group = groups[-1]
|
|
180
|
+
group[1] = max(group[1], span[1])
|
|
181
|
+
if ACTION_RANK[decision.action] > ACTION_RANK[group[3].action]:
|
|
182
|
+
group[2], group[3] = finding, decision
|
|
183
|
+
else:
|
|
184
|
+
groups.append([span[0], span[1], finding, decision])
|
|
185
|
+
out = text
|
|
186
|
+
for start, end, finding, decision in reversed(groups):
|
|
187
|
+
raw = out[start:end]
|
|
188
|
+
replacement = self.apply_action(
|
|
189
|
+
decision.action,
|
|
190
|
+
decision.params,
|
|
191
|
+
span_codec.decode(start, end, raw) if span_codec else raw,
|
|
192
|
+
category=finding.category,
|
|
193
|
+
field_path=finding.location.field_path or field_path,
|
|
194
|
+
in_span=True,
|
|
195
|
+
entity_lookup=entity_lookup,
|
|
196
|
+
)
|
|
197
|
+
encoded = span_codec.encode(start, end, replacement) if span_codec else str(replacement)
|
|
198
|
+
out = out[:start] + encoded + out[end:]
|
|
199
|
+
return ValueOutcome(
|
|
200
|
+
out, changed=out != text, type_changed=value_type_name(out) != value_type_name(value)
|
|
201
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Replacement with a configured synthetic constant (validated at policy load time)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ciphergate.models.policy import ReplaceParams
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def replace(params: ReplaceParams, *, in_span: bool) -> str | int | float | bool | None:
|
|
9
|
+
"""Inside free text (a span) the constant is inserted as text (``None`` -> ``null``)."""
|
|
10
|
+
if in_span:
|
|
11
|
+
if params.value is None:
|
|
12
|
+
return "null"
|
|
13
|
+
if isinstance(params.value, bool):
|
|
14
|
+
return "true" if params.value else "false"
|
|
15
|
+
return str(params.value)
|
|
16
|
+
return params.value
|