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.
Files changed (74) hide show
  1. ciphergate/__init__.py +95 -0
  2. ciphergate/__main__.py +6 -0
  3. ciphergate/cli.py +832 -0
  4. ciphergate/config.py +50 -0
  5. ciphergate/constants.py +274 -0
  6. ciphergate/detectors/__init__.py +5 -0
  7. ciphergate/detectors/base.py +53 -0
  8. ciphergate/detectors/custom_regex.py +37 -0
  9. ciphergate/detectors/dates.py +92 -0
  10. ciphergate/detectors/email.py +56 -0
  11. ciphergate/detectors/field_names.py +220 -0
  12. ciphergate/detectors/ip_address.py +85 -0
  13. ciphergate/detectors/network.py +42 -0
  14. ciphergate/detectors/payment_card.py +77 -0
  15. ciphergate/detectors/phone.py +67 -0
  16. ciphergate/detectors/presidio_ner.py +112 -0
  17. ciphergate/detectors/regex_safety.py +40 -0
  18. ciphergate/detectors/registry.py +194 -0
  19. ciphergate/detectors/secrets.py +130 -0
  20. ciphergate/errors.py +119 -0
  21. ciphergate/models/__init__.py +1 -0
  22. ciphergate/models/context.py +69 -0
  23. ciphergate/models/finding.py +108 -0
  24. ciphergate/models/policy.py +402 -0
  25. ciphergate/models/report.py +115 -0
  26. ciphergate/pipeline/__init__.py +1 -0
  27. ciphergate/pipeline/atomic_output.py +143 -0
  28. ciphergate/pipeline/batch.py +533 -0
  29. ciphergate/pipeline/inspector.py +282 -0
  30. ciphergate/pipeline/paths.py +98 -0
  31. ciphergate/pipeline/planner.py +192 -0
  32. ciphergate/pipeline/rescan.py +120 -0
  33. ciphergate/pipeline/sanitizer.py +392 -0
  34. ciphergate/pipeline/stdio.py +173 -0
  35. ciphergate/pipeline/streams.py +152 -0
  36. ciphergate/policy/__init__.py +26 -0
  37. ciphergate/policy/context.py +203 -0
  38. ciphergate/policy/engine.py +194 -0
  39. ciphergate/policy/loader.py +116 -0
  40. ciphergate/policy/precedence.py +149 -0
  41. ciphergate/policy/validator.py +632 -0
  42. ciphergate/py.typed +0 -0
  43. ciphergate/readers/__init__.py +113 -0
  44. ciphergate/readers/accesslog_reader.py +244 -0
  45. ciphergate/readers/base.py +131 -0
  46. ciphergate/readers/csv_reader.py +241 -0
  47. ciphergate/readers/json_reader.py +184 -0
  48. ciphergate/readers/jsonl_reader.py +97 -0
  49. ciphergate/readers/jsonlog_reader.py +178 -0
  50. ciphergate/readers/line_structure.py +125 -0
  51. ciphergate/readers/logfmt_reader.py +100 -0
  52. ciphergate/readers/stacktrace_reader.py +103 -0
  53. ciphergate/readers/syslog_reader.py +309 -0
  54. ciphergate/readers/text_reader.py +59 -0
  55. ciphergate/reporting/__init__.py +1 -0
  56. ciphergate/reporting/audit.py +85 -0
  57. ciphergate/reporting/safe_text.py +71 -0
  58. ciphergate/reporting/serializer.py +44 -0
  59. ciphergate/reporting/terminal.py +147 -0
  60. ciphergate/templates/starter_policy.yml +77 -0
  61. ciphergate/transforms/__init__.py +6 -0
  62. ciphergate/transforms/date_shift.py +79 -0
  63. ciphergate/transforms/generalize.py +107 -0
  64. ciphergate/transforms/keys.py +177 -0
  65. ciphergate/transforms/pseudonymize.py +113 -0
  66. ciphergate/transforms/redact.py +9 -0
  67. ciphergate/transforms/registry.py +201 -0
  68. ciphergate/transforms/replace.py +16 -0
  69. ciphergate_cli-0.7.0.dist-info/METADATA +531 -0
  70. ciphergate_cli-0.7.0.dist-info/RECORD +74 -0
  71. ciphergate_cli-0.7.0.dist-info/WHEEL +4 -0
  72. ciphergate_cli-0.7.0.dist-info/entry_points.txt +2 -0
  73. ciphergate_cli-0.7.0.dist-info/licenses/LICENSE +202 -0
  74. 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