proofstep-core 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.
@@ -0,0 +1,263 @@
1
+ """Redaction, applied in-process before anything is exported.
2
+
3
+ This is the strongest privacy guarantee available: redacted data never leaves the
4
+ host process, so it cannot leak from our storage, our backups, or our logs. The
5
+ server-side pass is defence in depth, not the primary control (docs/SECURITY.md §4).
6
+
7
+ Secrets are redacted in **every** capture mode, including `full`. There is no
8
+ configuration in which Proofstep intentionally stores an API key.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ import re
15
+ from collections.abc import Callable, Iterable
16
+ from typing import Any
17
+
18
+ from proofstep_types import CaptureMode
19
+
20
+ Redactor = Callable[[str, Any], Any]
21
+
22
+ KEEP = object()
23
+ """Sentinel: this redactor has no opinion; try the next one."""
24
+
25
+ MAX_DEPTH = 12
26
+ MAX_ITEMS = 1000
27
+
28
+ # Substring match on the key, case-insensitive. Deliberately broad: a false
29
+ # positive costs one obscured field, a false negative costs a credential.
30
+ SECRET_KEY_PARTS = (
31
+ "authorization",
32
+ "api_key",
33
+ "apikey",
34
+ "access_token",
35
+ "refresh_token",
36
+ "id_token",
37
+ "token",
38
+ "secret",
39
+ "password",
40
+ "passwd",
41
+ "passphrase",
42
+ "cookie",
43
+ "session_id",
44
+ "sessionid",
45
+ "private_key",
46
+ "client_secret",
47
+ "credential",
48
+ "auth",
49
+ )
50
+
51
+ PII_KEY_PARTS = ("ssn", "social_security", "credit_card", "card_number", "cvv", "iban")
52
+
53
+ # Value patterns. Ordered most to least specific.
54
+ SECRET_VALUE_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
55
+ ("jwt", re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b")),
56
+ ("pem", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*KEY-----")),
57
+ ("openai_key", re.compile(r"\bsk-[A-Za-z0-9_-]{16,}\b")),
58
+ ("anthropic_key", re.compile(r"\bsk-ant-[A-Za-z0-9_-]{16,}\b")),
59
+ ("github_token", re.compile(r"\b(?:ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{16,}\b")),
60
+ ("aws_access_key", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b")),
61
+ ("google_key", re.compile(r"\bAIza[0-9A-Za-z_-]{20,}\b")),
62
+ ("slack_token", re.compile(r"\bxox[abposr]-[A-Za-z0-9-]{10,}\b")),
63
+ ("stripe_key", re.compile(r"\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}\b")),
64
+ ("proofstep_key", re.compile(r"\bef_[a-z]+_[A-Za-z0-9]{4,}_[A-Za-z0-9]{16,}\b")),
65
+ ("bearer", re.compile(r"\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*", re.IGNORECASE)),
66
+ )
67
+
68
+ PII_VALUE_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
69
+ ("email", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")),
70
+ ("card", re.compile(r"\b(?:\d[ -]*?){13,19}\b")),
71
+ )
72
+
73
+ ENTROPY_MIN_LENGTH = 32
74
+ ENTROPY_THRESHOLD = 4.0
75
+ _ENTROPY_CHARSET = re.compile(r"^[A-Za-z0-9+/=_-]+$")
76
+
77
+
78
+ def mask(reason: str) -> str:
79
+ return f"[REDACTED:{reason}]"
80
+
81
+
82
+ def shannon_entropy(value: str) -> float:
83
+ if not value:
84
+ return 0.0
85
+ counts: dict[str, int] = {}
86
+ for char in value:
87
+ counts[char] = counts.get(char, 0) + 1
88
+ length = len(value)
89
+ return -sum((n / length) * math.log2(n / length) for n in counts.values())
90
+
91
+
92
+ def looks_like_a_secret(value: str) -> bool:
93
+ """High-entropy blob heuristic for credentials no pattern matches.
94
+
95
+ Only fires on long, dense, base64/hex-ish strings. Prose does not reach 4.0
96
+ bits per character, so ordinary model output is not caught by this.
97
+ """
98
+ if len(value) < ENTROPY_MIN_LENGTH or not _ENTROPY_CHARSET.match(value):
99
+ return False
100
+ return shannon_entropy(value) >= ENTROPY_THRESHOLD
101
+
102
+
103
+ # --------------------------------------------------------------------- redactors
104
+
105
+
106
+ def secret_keys() -> Redactor:
107
+ def redactor(path: str, _value: Any) -> Any:
108
+ leaf = path.rsplit(".", 1)[-1].lower()
109
+ if any(part in leaf for part in SECRET_KEY_PARTS):
110
+ return mask("secret_key")
111
+ return KEEP
112
+
113
+ return redactor
114
+
115
+
116
+ def secret_values() -> Redactor:
117
+ def redactor(_path: str, value: Any) -> Any:
118
+ if not isinstance(value, str):
119
+ return KEEP
120
+ for name, pattern in SECRET_VALUE_PATTERNS:
121
+ if pattern.search(value):
122
+ # Substitute rather than drop the whole field: an error message
123
+ # containing a token is still useful once the token is gone.
124
+ return pattern.sub(mask(name), value)
125
+ if looks_like_a_secret(value):
126
+ return mask("high_entropy")
127
+ # A credential no pattern knows about is often pasted *into* a sentence
128
+ # ("the token is <blob>"), where a whole-field entropy check never fires.
129
+ # Scan word by word so an unknown key shape is still caught in context.
130
+ return _redact_high_entropy_words(value)
131
+
132
+ return redactor
133
+
134
+
135
+ def _redact_high_entropy_words(value: str) -> Any:
136
+ if len(value) < ENTROPY_MIN_LENGTH:
137
+ return KEEP
138
+ words = value.split()
139
+ if len(words) < 2:
140
+ return KEEP # already handled by the whole-field check
141
+ replaced = [mask("high_entropy") if looks_like_a_secret(w) else w for w in words]
142
+ return " ".join(replaced) if replaced != words else KEEP
143
+
144
+
145
+ def pii() -> Redactor:
146
+ def redactor(path: str, value: Any) -> Any:
147
+ leaf = path.rsplit(".", 1)[-1].lower()
148
+ if any(part in leaf for part in PII_KEY_PARTS):
149
+ return mask("pii_key")
150
+ if isinstance(value, str):
151
+ for name, pattern in PII_VALUE_PATTERNS:
152
+ if pattern.search(value):
153
+ return pattern.sub(mask(name), value)
154
+ return KEEP
155
+
156
+ return redactor
157
+
158
+
159
+ def keys(names: Iterable[str]) -> Redactor:
160
+ wanted = {n.lower() for n in names}
161
+
162
+ def redactor(path: str, _value: Any) -> Any:
163
+ leaf = path.rsplit(".", 1)[-1].lower()
164
+ return mask("configured") if leaf in wanted else KEEP
165
+
166
+ return redactor
167
+
168
+
169
+ def regex(pattern: str, *, replacement: str = "[REDACTED:custom]") -> Redactor:
170
+ compiled = re.compile(pattern)
171
+
172
+ def redactor(_path: str, value: Any) -> Any:
173
+ if isinstance(value, str) and compiled.search(value):
174
+ return compiled.sub(replacement, value)
175
+ return KEEP
176
+
177
+ return redactor
178
+
179
+
180
+ def default() -> list[Redactor]:
181
+ """Secrets only. PII redaction is opt-in because it has false positives.
182
+
183
+ An email address in an SDR tool's payload is the *subject* of the workload, not
184
+ an accident, and blanket-redacting it would make traces useless. Secrets have no
185
+ such excuse.
186
+ """
187
+ return [secret_keys(), secret_values()]
188
+
189
+
190
+ # ---------------------------------------------------------------------- pipeline
191
+
192
+
193
+ class RedactionPipeline:
194
+ def __init__(
195
+ self,
196
+ redactors: list[Redactor] | None = None,
197
+ *,
198
+ capture_mode: CaptureMode = CaptureMode.REDACTED,
199
+ max_field_bytes: int = 256 * 1024,
200
+ ) -> None:
201
+ self.capture_mode = capture_mode
202
+ self.max_field_bytes = max_field_bytes
203
+ # Even in `full` mode the secret redactors run. There is no configuration in
204
+ # which we intentionally store a credential.
205
+ self.redactors = (
206
+ [secret_keys(), secret_values()]
207
+ if capture_mode is CaptureMode.FULL
208
+ else (redactors if redactors is not None else default())
209
+ )
210
+ self.count = 0
211
+ self.truncated = 0
212
+
213
+ def apply(self, value: Any, *, path: str = "") -> Any:
214
+ self.count = 0
215
+ self.truncated = 0
216
+ if not self.capture_mode.stores_payloads:
217
+ return None
218
+ return self._walk(value, path, 0)
219
+
220
+ def _walk(self, value: Any, path: str, depth: int) -> Any:
221
+ if depth > MAX_DEPTH:
222
+ return "[TRUNCATED:depth]"
223
+
224
+ if isinstance(value, dict):
225
+ out: dict[str, Any] = {}
226
+ for i, (key, item) in enumerate(value.items()):
227
+ if i >= MAX_ITEMS:
228
+ out["_truncated"] = f"{len(value) - MAX_ITEMS} more keys"
229
+ break
230
+ child = f"{path}.{key}" if path else str(key)
231
+ out[str(key)] = self._walk(item, child, depth + 1)
232
+ return out
233
+
234
+ if isinstance(value, list | tuple):
235
+ items = list(value)[:MAX_ITEMS]
236
+ walked = [self._walk(v, f"{path}[{i}]", depth + 1) for i, v in enumerate(items)]
237
+ if len(value) > MAX_ITEMS:
238
+ walked.append(f"[TRUNCATED:{len(value) - MAX_ITEMS} more items]")
239
+ return walked
240
+
241
+ return self._leaf(value, path)
242
+
243
+ def _leaf(self, value: Any, path: str) -> Any:
244
+ for redactor in self.redactors:
245
+ result = redactor(path, value)
246
+ if result is KEEP:
247
+ continue
248
+ if result != value:
249
+ self.count += 1
250
+ value = result
251
+ if isinstance(value, str) and value.startswith("[REDACTED:"):
252
+ return value
253
+
254
+ if isinstance(value, str) and len(value.encode("utf-8", "ignore")) > self.max_field_bytes:
255
+ self.truncated += 1
256
+ # Record the loss rather than silently shortening: a truncated payload
257
+ # that looks complete is how people debug the wrong thing for an hour.
258
+ return {
259
+ "_truncated": True,
260
+ "_original_bytes": len(value.encode("utf-8", "ignore")),
261
+ "_preview": value[:512],
262
+ }
263
+ return value