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.
- proofstep_core/__init__.py +59 -0
- proofstep_core/aggregate.py +140 -0
- proofstep_core/calibration.py +849 -0
- proofstep_core/calibration_runner.py +471 -0
- proofstep_core/compare.py +205 -0
- proofstep_core/dataset.py +174 -0
- proofstep_core/evaluators/__init__.py +47 -0
- proofstep_core/evaluators/deterministic.py +404 -0
- proofstep_core/evaluators/judge.py +335 -0
- proofstep_core/evaluators/operational.py +127 -0
- proofstep_core/evaluators/statistical.py +434 -0
- proofstep_core/gates.py +545 -0
- proofstep_core/paths.py +98 -0
- proofstep_core/py.typed +0 -0
- proofstep_core/redaction.py +263 -0
- proofstep_core/runner.py +482 -0
- proofstep_core/sampling.py +200 -0
- proofstep_core/significance.py +477 -0
- proofstep_core/stats.py +142 -0
- proofstep_core/suite.py +277 -0
- proofstep_core/testing.py +118 -0
- proofstep_core/types.py +156 -0
- proofstep_core/versioning.py +63 -0
- proofstep_core-0.1.0.dist-info/METADATA +39 -0
- proofstep_core-0.1.0.dist-info/RECORD +26 -0
- proofstep_core-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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
|