openscript-sdk 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.
- openscript_sdk/__init__.py +217 -0
- openscript_sdk/contracts/__init__.py +82 -0
- openscript_sdk/contracts/interceptor.py +98 -0
- openscript_sdk/contracts/server_types.py +87 -0
- openscript_sdk/contracts/session_graph.py +123 -0
- openscript_sdk/contracts/types.py +76 -0
- openscript_sdk/events/__init__.py +0 -0
- openscript_sdk/events/approvals.py +309 -0
- openscript_sdk/events/models.py +26 -0
- openscript_sdk/events/store.py +79 -0
- openscript_sdk/events/writer.py +100 -0
- openscript_sdk/hooks/claude_code.py +479 -0
- openscript_sdk/hooks/claude_code_example.yaml +62 -0
- openscript_sdk/hooks/claude_code_strict_example.yaml +73 -0
- openscript_sdk/hooks/config.py +144 -0
- openscript_sdk/hooks/settings_example.json +28 -0
- openscript_sdk/hooks/settings_strict_example.json +28 -0
- openscript_sdk/integrations/langchain.py +32 -0
- openscript_sdk/integrations/langgraph.py +32 -0
- openscript_sdk/interceptors/base.py +15 -0
- openscript_sdk/interceptors/event_writer.py +131 -0
- openscript_sdk/interceptors/pii.py +168 -0
- openscript_sdk/interceptors/threat.py +674 -0
- openscript_sdk/logging.py +94 -0
- openscript_sdk/middleware/middleware.py +543 -0
- openscript_sdk/middleware/tool.py +247 -0
- openscript_sdk/observability/__init__.py +11 -0
- openscript_sdk/observability/metrics.py +105 -0
- openscript_sdk/observability/risk.py +54 -0
- openscript_sdk/observability/tracing.py +67 -0
- openscript_sdk/policies/compliance.py +261 -0
- openscript_sdk/policies/config.py +244 -0
- openscript_sdk/policies/harmful_request.py +617 -0
- openscript_sdk/policies/normalize.py +489 -0
- openscript_sdk/policies/output_schema.py +353 -0
- openscript_sdk/policies/policies_example.yaml +36 -0
- openscript_sdk/policies/redaction.py +359 -0
- openscript_sdk/policies/secrets.py +239 -0
- openscript_sdk/policies/semantic.py +348 -0
- openscript_sdk/policies/semantic_head.py +9 -0
- openscript_sdk/policies/session_taint.py +399 -0
- openscript_sdk/policies/taint_store.py +111 -0
- openscript_sdk/policies/tool_firewall.py +434 -0
- openscript_sdk/policies/tools_example.yaml +81 -0
- openscript_sdk/policies/toxicity.py +247 -0
- openscript_sdk/py.typed +0 -0
- openscript_sdk/server/__init__.py +1 -0
- openscript_sdk/server/app.py +153 -0
- openscript_sdk/server/auth.py +19 -0
- openscript_sdk/server/guard.py +126 -0
- openscript_sdk/server/guard_example.yaml +55 -0
- openscript_sdk/server/routers/__init__.py +0 -0
- openscript_sdk/server/routers/approvals.py +69 -0
- openscript_sdk/server/routers/guard.py +231 -0
- openscript_sdk/server/routers/sessions.py +80 -0
- openscript_sdk/server/routers/sse.py +69 -0
- openscript_sdk/server/routers/threat.py +119 -0
- openscript_sdk/server/static/dashboard/index.html +209 -0
- openscript_sdk/server/static/dashboard/session.html +339 -0
- openscript_sdk-0.1.0.dist-info/METADATA +225 -0
- openscript_sdk-0.1.0.dist-info/RECORD +65 -0
- openscript_sdk-0.1.0.dist-info/WHEEL +5 -0
- openscript_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- openscript_sdk-0.1.0.dist-info/licenses/LICENSE +190 -0
- openscript_sdk-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Finding and redacting secrets and PII, with nothing but the standard library.
|
|
2
|
+
|
|
3
|
+
SecretsPolicy and PIIPolicy redact through these, and so can anything that
|
|
4
|
+
holds a string and no pipeline: a hook that rewrites a tool's output before
|
|
5
|
+
the model reads it, for one. Importing this module costs only the regexes it
|
|
6
|
+
compiles, which is why it imports nothing outside the standard library.
|
|
7
|
+
|
|
8
|
+
redact_secrets("key AKIAIOSFODNN7EXAMPLE") # ("key AKIA**********", ["aws_access_key"])
|
|
9
|
+
redact_data({"stdout": text}, pii=True) # every string inside, both kinds
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Secrets. Each entry: (compiled_regex, label, mask).
|
|
21
|
+
# mask=True keeps a short identifying prefix and stars the rest (e.g.
|
|
22
|
+
# "sk-a**********"); mask=False replaces the whole match with
|
|
23
|
+
# "[REDACTED:<label>]" (multi-line blocks and URLs, where a prefix is useless).
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class _SecretPattern:
|
|
29
|
+
regex: re.Pattern[str]
|
|
30
|
+
label: str
|
|
31
|
+
mask: bool
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _s(pattern: str, label: str, mask: bool = True, flags: int = 0) -> _SecretPattern:
|
|
35
|
+
return _SecretPattern(re.compile(pattern, flags), label, mask)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_MASK_PREFIX_LEN = 4
|
|
39
|
+
_MASK = "**********"
|
|
40
|
+
|
|
41
|
+
_SECRET_PATTERNS: list[_SecretPattern] = [
|
|
42
|
+
# PEM blocks first so their contents aren't partially matched by other patterns
|
|
43
|
+
_s(
|
|
44
|
+
r"-----BEGIN [A-Z ]*PRIVATE KEY( BLOCK)?-----.*?-----END [A-Z ]*PRIVATE KEY( BLOCK)?-----",
|
|
45
|
+
"private_key",
|
|
46
|
+
mask=False,
|
|
47
|
+
flags=re.DOTALL,
|
|
48
|
+
),
|
|
49
|
+
# AWS access key IDs (AKIA = long-term, ASIA = temporary/STS)
|
|
50
|
+
_s(r"\b(AKIA|ASIA)[A-Z0-9]{16}\b", "aws_access_key"),
|
|
51
|
+
# AWS secret access keys — only when introduced by a recognizable key name,
|
|
52
|
+
# since the raw value (40 base64ish chars) is too generic on its own
|
|
53
|
+
_s(
|
|
54
|
+
r"\baws_?secret(_?access)?_?key['\"]?\s*[:=]\s*['\"]?[A-Za-z0-9/+=]{40}\b",
|
|
55
|
+
"aws_secret_key",
|
|
56
|
+
flags=re.IGNORECASE,
|
|
57
|
+
),
|
|
58
|
+
# GitHub tokens: classic (ghp_/gho_/ghu_/ghs_/ghr_) and fine-grained PATs
|
|
59
|
+
_s(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b", "github_token"),
|
|
60
|
+
_s(r"\bgithub_pat_[A-Za-z0-9_]{22,}\b", "github_token"),
|
|
61
|
+
# Slack tokens: bot/app/personal/refresh/workspace
|
|
62
|
+
_s(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b", "slack_token"),
|
|
63
|
+
# JWTs: three dot-separated base64url segments, header always starts with eyJ
|
|
64
|
+
_s(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b", "jwt"),
|
|
65
|
+
# Generic sk- API keys (OpenAI/Anthropic/Stripe style)
|
|
66
|
+
_s(r"\bsk-[A-Za-z0-9_-]{20,}\b", "api_key"),
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
# Internal URLs / private network addresses (road.txt §10). Leaking these in
|
|
70
|
+
# output is the primary concern, but they are scanned in input too.
|
|
71
|
+
_URL_TAIL = r"(?::\d+)?(?:/[^\s\"'<>]*)?"
|
|
72
|
+
|
|
73
|
+
_INTERNAL_URL_PATTERNS: list[_SecretPattern] = [
|
|
74
|
+
# Internal hostname suffixes: *.internal, *.corp, *.local, *.lan, *.intranet
|
|
75
|
+
_s(
|
|
76
|
+
r"\b(?:https?://)?(?:[a-z0-9-]+\.)+(?:internal|corp|local|lan|intranet)\b" + _URL_TAIL,
|
|
77
|
+
"internal_url",
|
|
78
|
+
mask=False,
|
|
79
|
+
flags=re.IGNORECASE,
|
|
80
|
+
),
|
|
81
|
+
_s(
|
|
82
|
+
r"\b(?:https?://)?localhost\b" + _URL_TAIL,
|
|
83
|
+
"internal_url",
|
|
84
|
+
mask=False,
|
|
85
|
+
flags=re.IGNORECASE,
|
|
86
|
+
),
|
|
87
|
+
# RFC 1918 IPv4 ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
|
|
88
|
+
_s(r"\b(?:https?://)?10(?:\.\d{1,3}){3}\b" + _URL_TAIL, "internal_url", mask=False),
|
|
89
|
+
_s(
|
|
90
|
+
r"\b(?:https?://)?172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}\b" + _URL_TAIL,
|
|
91
|
+
"internal_url",
|
|
92
|
+
mask=False,
|
|
93
|
+
),
|
|
94
|
+
_s(r"\b(?:https?://)?192\.168(?:\.\d{1,3}){2}\b" + _URL_TAIL, "internal_url", mask=False),
|
|
95
|
+
# IPv6 loopback (::1), bracketed or bare, not inside a longer address
|
|
96
|
+
_s(
|
|
97
|
+
r"(?:https?://)?\[::1\]" + _URL_TAIL + r"|(?<![0-9A-Fa-f:.])::1(?![0-9A-Fa-f:.])",
|
|
98
|
+
"internal_url",
|
|
99
|
+
mask=False,
|
|
100
|
+
),
|
|
101
|
+
# IPv6 link-local: fe80::/10
|
|
102
|
+
_s(
|
|
103
|
+
r"\bfe80:(?::[0-9A-Fa-f]{0,4}){1,7}(?:%[0-9A-Za-z]+)?\b",
|
|
104
|
+
"internal_url",
|
|
105
|
+
mask=False,
|
|
106
|
+
flags=re.IGNORECASE,
|
|
107
|
+
),
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
_ALL_PATTERNS: list[_SecretPattern] = _SECRET_PATTERNS + _INTERNAL_URL_PATTERNS
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def find_secrets(text: str) -> list[tuple[str, str]]:
|
|
114
|
+
"""Return list of (matched_text, label) pairs found in *text*.
|
|
115
|
+
|
|
116
|
+
Standalone helper — callable outside a policy pipeline. Scans both
|
|
117
|
+
secret and internal-URL pattern banks, with no allowlist applied.
|
|
118
|
+
"""
|
|
119
|
+
found: list[tuple[str, str]] = []
|
|
120
|
+
for pat in _ALL_PATTERNS:
|
|
121
|
+
for m in pat.regex.finditer(text):
|
|
122
|
+
found.append((m.group(), pat.label))
|
|
123
|
+
return found
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _mask_match(match: str) -> str:
|
|
127
|
+
return match[:_MASK_PREFIX_LEN] + _MASK
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _redact_secret_text(
|
|
131
|
+
text: str,
|
|
132
|
+
patterns: list[_SecretPattern],
|
|
133
|
+
allowlist: Sequence[str] = (),
|
|
134
|
+
final: bool = True,
|
|
135
|
+
) -> tuple[str, list[str]]:
|
|
136
|
+
"""Replace matches of *patterns* with masked/redacted forms.
|
|
137
|
+
|
|
138
|
+
Matches containing an allowlisted substring (case-insensitive) are
|
|
139
|
+
left untouched and unreported. final=False (guarded streaming) defers
|
|
140
|
+
matches ending exactly at the end of *text* — they may extend with the
|
|
141
|
+
next chunk, and acting early would leak the extension as plain text.
|
|
142
|
+
Returns (redacted_text, found_labels).
|
|
143
|
+
"""
|
|
144
|
+
found_labels: list[str] = []
|
|
145
|
+
allow_lower = [a.lower() for a in allowlist]
|
|
146
|
+
|
|
147
|
+
for pat in patterns:
|
|
148
|
+
current_len = len(text)
|
|
149
|
+
|
|
150
|
+
def _replace(
|
|
151
|
+
m: re.Match[str], pat: _SecretPattern = pat, current_len: int = current_len
|
|
152
|
+
) -> str:
|
|
153
|
+
matched = m.group()
|
|
154
|
+
if not final and m.end() == current_len:
|
|
155
|
+
return matched # tentative — could extend with the next chunk
|
|
156
|
+
if any(a in matched.lower() for a in allow_lower):
|
|
157
|
+
return matched
|
|
158
|
+
found_labels.append(pat.label)
|
|
159
|
+
if pat.mask:
|
|
160
|
+
return _mask_match(matched)
|
|
161
|
+
return f"[REDACTED:{pat.label}]"
|
|
162
|
+
|
|
163
|
+
text = pat.regex.sub(_replace, text)
|
|
164
|
+
|
|
165
|
+
return text, list(dict.fromkeys(found_labels)) # deduplicate, preserve order
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _walk_redact_secrets(
|
|
169
|
+
obj: Any,
|
|
170
|
+
patterns: list[_SecretPattern],
|
|
171
|
+
allowlist: Sequence[str] = (),
|
|
172
|
+
) -> tuple[Any, list[str]]:
|
|
173
|
+
"""Recursively redact pattern matches from strings inside dicts/lists."""
|
|
174
|
+
all_labels: list[str] = []
|
|
175
|
+
if isinstance(obj, str):
|
|
176
|
+
redacted, labels = _redact_secret_text(obj, patterns, allowlist)
|
|
177
|
+
all_labels.extend(labels)
|
|
178
|
+
return redacted, all_labels
|
|
179
|
+
if isinstance(obj, dict):
|
|
180
|
+
result = {}
|
|
181
|
+
for k, v in obj.items():
|
|
182
|
+
result[k], labels = _walk_redact_secrets(v, patterns, allowlist)
|
|
183
|
+
all_labels.extend(labels)
|
|
184
|
+
return result, all_labels
|
|
185
|
+
if isinstance(obj, list):
|
|
186
|
+
result_list = []
|
|
187
|
+
for item in obj:
|
|
188
|
+
redacted_item, labels = _walk_redact_secrets(item, patterns, allowlist)
|
|
189
|
+
all_labels.extend(labels)
|
|
190
|
+
result_list.append(redacted_item)
|
|
191
|
+
return result_list, all_labels
|
|
192
|
+
return obj, []
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
# PII. Every match becomes "[REDACTED:<label>]".
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass(frozen=True)
|
|
201
|
+
class _PIIPattern:
|
|
202
|
+
regex: re.Pattern[str]
|
|
203
|
+
label: str
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _p(pattern: str, label: str) -> _PIIPattern:
|
|
207
|
+
return _PIIPattern(re.compile(pattern, re.IGNORECASE), label)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
_PII_PATTERNS: list[_PIIPattern] = [
|
|
211
|
+
_p(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b", "email"),
|
|
212
|
+
_p(r"\b(\+?1[\s\-.]?)?\(?\d{3}\)?[\s\-.]?\d{3}[\s\-.]?\d{4}\b", "phone"),
|
|
213
|
+
_p(r"\b\d{3}-\d{2}-\d{4}\b", "ssn"),
|
|
214
|
+
# API / bearer tokens — must appear before generic patterns
|
|
215
|
+
_p(r"\b(sk-|Bearer\s+|token[=:\s]+|api[_\-]?key[=:\s]+)[A-Za-z0-9\-_]{20,}\b", "api_key"),
|
|
216
|
+
# IPv4 addresses
|
|
217
|
+
_p(
|
|
218
|
+
r"\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b", "ip_address"
|
|
219
|
+
),
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
# Credit card: 13-16 digits optionally separated by spaces or dashes.
|
|
223
|
+
# Validated with Luhn check to reduce false positives.
|
|
224
|
+
_CC_RE = re.compile(r"\b(?:\d[ \-]?){13,16}\b")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _luhn(number: str) -> bool:
|
|
228
|
+
digits = [int(d) for d in number if d.isdigit()]
|
|
229
|
+
digits.reverse()
|
|
230
|
+
total = sum(
|
|
231
|
+
d if i % 2 == 0 else (d * 2 - 9 if d * 2 > 9 else d * 2) for i, d in enumerate(digits)
|
|
232
|
+
)
|
|
233
|
+
return total % 10 == 0
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _find_pii(text: str) -> list[tuple[str, str]]:
|
|
237
|
+
"""Return list of (matched_text, label) pairs found in text."""
|
|
238
|
+
found: list[tuple[str, str]] = []
|
|
239
|
+
for pat in _PII_PATTERNS:
|
|
240
|
+
for m in pat.regex.finditer(text):
|
|
241
|
+
found.append((m.group(), pat.label))
|
|
242
|
+
for m in _CC_RE.finditer(text):
|
|
243
|
+
raw = m.group()
|
|
244
|
+
digits_only = re.sub(r"[ \-]", "", raw)
|
|
245
|
+
if len(digits_only) >= 13 and _luhn(digits_only):
|
|
246
|
+
found.append((raw, "credit_card"))
|
|
247
|
+
return found
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _redact_pii_text(text: str, final: bool = True) -> tuple[str, list[str]]:
|
|
251
|
+
"""Replace PII matches with [REDACTED:<label>] tokens.
|
|
252
|
+
|
|
253
|
+
final=False (guarded streaming) defers matches ending exactly at the end
|
|
254
|
+
of *text* — they may extend with the next chunk, and acting early would
|
|
255
|
+
leak the extension as plain text. Returns (redacted_text, found_labels).
|
|
256
|
+
"""
|
|
257
|
+
found_labels: list[str] = []
|
|
258
|
+
|
|
259
|
+
# Credit cards first (before phone regex can partially match)
|
|
260
|
+
cc_len = len(text)
|
|
261
|
+
|
|
262
|
+
def _cc_replace(m: re.Match[str]) -> str:
|
|
263
|
+
raw = m.group()
|
|
264
|
+
if not final and m.end() == cc_len:
|
|
265
|
+
return raw # tentative — could extend with the next chunk
|
|
266
|
+
digits_only = re.sub(r"[ \-]", "", raw)
|
|
267
|
+
if len(digits_only) >= 13 and _luhn(digits_only):
|
|
268
|
+
found_labels.append("credit_card")
|
|
269
|
+
return "[REDACTED:credit_card]"
|
|
270
|
+
return raw
|
|
271
|
+
|
|
272
|
+
text = _CC_RE.sub(_cc_replace, text)
|
|
273
|
+
|
|
274
|
+
for pat in _PII_PATTERNS:
|
|
275
|
+
current_len = len(text)
|
|
276
|
+
|
|
277
|
+
def _replace(
|
|
278
|
+
m: re.Match[str], label: str = pat.label, current_len: int = current_len
|
|
279
|
+
) -> str:
|
|
280
|
+
if not final and m.end() == current_len:
|
|
281
|
+
return m.group() # tentative — could extend with the next chunk
|
|
282
|
+
found_labels.append(label)
|
|
283
|
+
return f"[REDACTED:{label}]"
|
|
284
|
+
|
|
285
|
+
text = pat.regex.sub(_replace, text)
|
|
286
|
+
|
|
287
|
+
return text, list(dict.fromkeys(found_labels)) # deduplicate, preserve order
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _walk_redact_pii(obj: Any) -> tuple[Any, list[str]]:
|
|
291
|
+
"""Recursively redact PII from strings inside dicts/lists."""
|
|
292
|
+
all_labels: list[str] = []
|
|
293
|
+
if isinstance(obj, str):
|
|
294
|
+
redacted, labels = _redact_pii_text(obj)
|
|
295
|
+
all_labels.extend(labels)
|
|
296
|
+
return redacted, all_labels
|
|
297
|
+
if isinstance(obj, dict):
|
|
298
|
+
result = {}
|
|
299
|
+
for k, v in obj.items():
|
|
300
|
+
result[k], labels = _walk_redact_pii(v)
|
|
301
|
+
all_labels.extend(labels)
|
|
302
|
+
return result, all_labels
|
|
303
|
+
if isinstance(obj, list):
|
|
304
|
+
result_list = []
|
|
305
|
+
for item in obj:
|
|
306
|
+
redacted_item, labels = _walk_redact_pii(item)
|
|
307
|
+
all_labels.extend(labels)
|
|
308
|
+
result_list.append(redacted_item)
|
|
309
|
+
return result_list, all_labels
|
|
310
|
+
return obj, []
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ---------------------------------------------------------------------------
|
|
314
|
+
# The public helpers
|
|
315
|
+
# ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def find_pii(text: str) -> list[tuple[str, str]]:
|
|
319
|
+
"""(matched_text, label) for each piece of PII in *text*: emails, phone
|
|
320
|
+
numbers, SSNs, API and bearer tokens, IPv4 addresses, and card numbers
|
|
321
|
+
that pass the Luhn check."""
|
|
322
|
+
return _find_pii(text)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def redact_secrets(
|
|
326
|
+
text: str, *, allowlist: Sequence[str] = (), internal_urls: bool = False
|
|
327
|
+
) -> tuple[str, list[str]]:
|
|
328
|
+
"""*text* with every secret masked as SecretsPolicy masks it, and the labels found.
|
|
329
|
+
|
|
330
|
+
Internal URLs and private addresses are left alone unless *internal_urls*:
|
|
331
|
+
localhost and *.local turn up in ordinary code and output all the time.
|
|
332
|
+
A match containing an *allowlist* entry (case-insensitive) is left as it is.
|
|
333
|
+
"""
|
|
334
|
+
patterns = _ALL_PATTERNS if internal_urls else _SECRET_PATTERNS
|
|
335
|
+
return _redact_secret_text(text, patterns, allowlist)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def redact_pii(text: str) -> tuple[str, list[str]]:
|
|
339
|
+
"""*text* with every piece of PII replaced by "[REDACTED:<label>]", and the labels found."""
|
|
340
|
+
return _redact_pii_text(text)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def redact_data(
|
|
344
|
+
obj: Any, *, secrets: bool = True, pii: bool = False, internal_urls: bool = False
|
|
345
|
+
) -> tuple[Any, list[str]]:
|
|
346
|
+
"""Every string inside *obj* (dicts and lists, to any depth) redacted, and the labels found.
|
|
347
|
+
|
|
348
|
+
The shape is kept: keys, list lengths and non-string values come back as
|
|
349
|
+
they went in, so a structured result is still the same structure.
|
|
350
|
+
"""
|
|
351
|
+
labels: list[str] = []
|
|
352
|
+
if secrets:
|
|
353
|
+
patterns = _ALL_PATTERNS if internal_urls else _SECRET_PATTERNS
|
|
354
|
+
obj, found = _walk_redact_secrets(obj, patterns)
|
|
355
|
+
labels += found
|
|
356
|
+
if pii:
|
|
357
|
+
obj, found = _walk_redact_pii(obj)
|
|
358
|
+
labels += found
|
|
359
|
+
return obj, list(dict.fromkeys(labels))
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
import structlog
|
|
9
|
+
|
|
10
|
+
from openscript_sdk.contracts.interceptor import BasePolicy, GuardScan, StreamGuard
|
|
11
|
+
from openscript_sdk.contracts.server_types import Event, EventType
|
|
12
|
+
from openscript_sdk.contracts.types import ActionContext, FailureMode, InterceptorDecision
|
|
13
|
+
from openscript_sdk.interceptors.pii import PIIMode
|
|
14
|
+
from openscript_sdk.policies.redaction import _INTERNAL_URL_PATTERNS, _SECRET_PATTERNS
|
|
15
|
+
from openscript_sdk.policies.redaction import _redact_secret_text as _redact_text
|
|
16
|
+
from openscript_sdk.policies.redaction import _walk_redact_secrets as _walk_redact
|
|
17
|
+
from openscript_sdk.policies.redaction import find_secrets as find_secrets
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from openscript_sdk.events.writer import EventWriter
|
|
21
|
+
|
|
22
|
+
logger = structlog.get_logger(__name__)
|
|
23
|
+
|
|
24
|
+
# Guarded streaming: longest realistic bounded secret (fat JWTs run long;
|
|
25
|
+
# everything else is well under 100 chars). PEM blocks are unbounded and are
|
|
26
|
+
# handled by their header instead.
|
|
27
|
+
_GUARD_MAX_MATCH_LEN = 512
|
|
28
|
+
_PEM_HEADER_RE = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY( BLOCK)?-----")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class InternalURLMode(StrEnum):
|
|
32
|
+
"""How SecretsPolicy treats internal URL / private address findings.
|
|
33
|
+
|
|
34
|
+
Internal hostnames like localhost and *.local appear constantly in
|
|
35
|
+
legitimate agent output (code snippets, dev instructions), so the
|
|
36
|
+
default only annotates metadata instead of mutating or blocking.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
ANNOTATE = "annotate"
|
|
40
|
+
REDACT = "redact"
|
|
41
|
+
DENY = "deny"
|
|
42
|
+
OFF = "off"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _SecretsStreamGuard:
|
|
46
|
+
"""Incremental scanner backing SecretsPolicy in guarded streams."""
|
|
47
|
+
|
|
48
|
+
name = "SecretsPolicy"
|
|
49
|
+
max_match_len = _GUARD_MAX_MATCH_LEN
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
mode: PIIMode,
|
|
54
|
+
internal_url_mode: InternalURLMode,
|
|
55
|
+
allowlist: Sequence[str],
|
|
56
|
+
) -> None:
|
|
57
|
+
self._mode = mode
|
|
58
|
+
self._internal_url_mode = internal_url_mode
|
|
59
|
+
self._allowlist = allowlist
|
|
60
|
+
|
|
61
|
+
def scan(self, text: str, final: bool = False) -> GuardScan:
|
|
62
|
+
# A private-key header aborts outright, even in redact mode: the
|
|
63
|
+
# block is unbounded, so chunkwise redaction can't contain it.
|
|
64
|
+
if _PEM_HEADER_RE.search(text):
|
|
65
|
+
return GuardScan(
|
|
66
|
+
text=text,
|
|
67
|
+
labels=["private_key"],
|
|
68
|
+
risk=0.9,
|
|
69
|
+
deny=True,
|
|
70
|
+
reason="private key block in streamed output",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
labels: list[str] = []
|
|
74
|
+
risk = 0.0
|
|
75
|
+
deny = False
|
|
76
|
+
|
|
77
|
+
redacted, secret_labels = _redact_text(text, _SECRET_PATTERNS, final=final)
|
|
78
|
+
if secret_labels:
|
|
79
|
+
labels.extend(secret_labels)
|
|
80
|
+
risk = 0.9
|
|
81
|
+
if self._mode == PIIMode.DENY:
|
|
82
|
+
deny = True
|
|
83
|
+
else:
|
|
84
|
+
text = redacted
|
|
85
|
+
|
|
86
|
+
if self._internal_url_mode != InternalURLMode.OFF:
|
|
87
|
+
redacted, internal_labels = _redact_text(
|
|
88
|
+
text, _INTERNAL_URL_PATTERNS, self._allowlist, final=final
|
|
89
|
+
)
|
|
90
|
+
if internal_labels:
|
|
91
|
+
labels.extend(internal_labels)
|
|
92
|
+
risk = max(risk, 0.5)
|
|
93
|
+
if self._internal_url_mode == InternalURLMode.DENY:
|
|
94
|
+
deny = True
|
|
95
|
+
elif self._internal_url_mode == InternalURLMode.REDACT:
|
|
96
|
+
text = redacted
|
|
97
|
+
|
|
98
|
+
labels = list(dict.fromkeys(labels))
|
|
99
|
+
reason = f"Secrets detected in stream: {', '.join(sorted(set(labels)))}" if deny else ""
|
|
100
|
+
return GuardScan(text=text, labels=labels, risk=risk, deny=deny, reason=reason)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class SecretsPolicy(BasePolicy):
|
|
104
|
+
"""Scans input and output for secrets and internal URLs; redacts or blocks.
|
|
105
|
+
|
|
106
|
+
Detects AWS keys, GitHub/Slack tokens, JWTs, private-key blocks, generic
|
|
107
|
+
sk- API keys, and internal URLs (*.internal/*.corp/*.local/*.lan/*.intranet
|
|
108
|
+
hostnames, localhost, RFC 1918 IPv4 ranges, IPv6 loopback and link-local).
|
|
109
|
+
|
|
110
|
+
Runs in both before_action (input scan) and after_action (output scan) —
|
|
111
|
+
internal URLs leaking in responses is the primary concern, but secrets
|
|
112
|
+
pasted into input should not reach the agent either.
|
|
113
|
+
|
|
114
|
+
Secrets follow *mode* (redact/deny). Internal URLs follow the separate
|
|
115
|
+
*internal_url_mode* toggle, defaulting to ANNOTATE (metadata only, no
|
|
116
|
+
mutation or blocking) because localhost and *.local appear routinely in
|
|
117
|
+
legitimate output; matches containing an *internal_url_allowlist* entry
|
|
118
|
+
(case-insensitive substring, e.g. "docs.local") are ignored entirely.
|
|
119
|
+
|
|
120
|
+
Results accumulate in context.metadata["secrets"] using the standardized
|
|
121
|
+
shape (risk keeps the max across input and output phases; a leaked
|
|
122
|
+
credential scores 0.9, an internal-URL-only finding 0.5):
|
|
123
|
+
{"risk": float, "category": "secrets", "found": [labels...], "redacted": bool}
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
failure_mode: FailureMode = FailureMode.FAIL_OPEN
|
|
127
|
+
|
|
128
|
+
def __init__(
|
|
129
|
+
self,
|
|
130
|
+
mode: PIIMode | str = PIIMode.REDACT,
|
|
131
|
+
writer: EventWriter | None = None,
|
|
132
|
+
internal_url_mode: InternalURLMode | str = InternalURLMode.ANNOTATE,
|
|
133
|
+
internal_url_allowlist: Sequence[str] = (),
|
|
134
|
+
) -> None:
|
|
135
|
+
self._mode = PIIMode(mode)
|
|
136
|
+
self._internal_url_mode = InternalURLMode(internal_url_mode)
|
|
137
|
+
self._internal_url_allowlist = tuple(internal_url_allowlist)
|
|
138
|
+
self._writer = writer
|
|
139
|
+
self._sequence_counters: dict[str, int] = {}
|
|
140
|
+
|
|
141
|
+
async def before_action(self, context: ActionContext) -> ActionContext:
|
|
142
|
+
context.input_data = await self._scan(context, context.input_data, phase="input")
|
|
143
|
+
return context
|
|
144
|
+
|
|
145
|
+
async def after_action(self, context: ActionContext) -> ActionContext:
|
|
146
|
+
context.output_data = await self._scan(context, context.output_data, phase="output")
|
|
147
|
+
return context
|
|
148
|
+
|
|
149
|
+
def stream_guard(self) -> StreamGuard:
|
|
150
|
+
return _SecretsStreamGuard(
|
|
151
|
+
self._mode, self._internal_url_mode, self._internal_url_allowlist
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
async def _scan(
|
|
155
|
+
self, context: ActionContext, data: dict[str, Any], phase: str
|
|
156
|
+
) -> dict[str, Any]:
|
|
157
|
+
meta = context.metadata.setdefault(
|
|
158
|
+
"secrets", {"risk": 0.0, "category": "secrets", "found": [], "redacted": False}
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
result = data
|
|
162
|
+
redacted = False
|
|
163
|
+
deny_labels: list[str] = []
|
|
164
|
+
|
|
165
|
+
secret_redacted, secret_labels = _walk_redact(data, _SECRET_PATTERNS)
|
|
166
|
+
if secret_labels:
|
|
167
|
+
if self._mode == PIIMode.DENY:
|
|
168
|
+
deny_labels.extend(secret_labels)
|
|
169
|
+
else:
|
|
170
|
+
result = secret_redacted
|
|
171
|
+
redacted = True
|
|
172
|
+
|
|
173
|
+
internal_labels: list[str] = []
|
|
174
|
+
if self._internal_url_mode != InternalURLMode.OFF:
|
|
175
|
+
internal_redacted, internal_labels = _walk_redact(
|
|
176
|
+
result, _INTERNAL_URL_PATTERNS, self._internal_url_allowlist
|
|
177
|
+
)
|
|
178
|
+
if internal_labels:
|
|
179
|
+
if self._internal_url_mode == InternalURLMode.DENY:
|
|
180
|
+
deny_labels.extend(internal_labels)
|
|
181
|
+
elif self._internal_url_mode == InternalURLMode.REDACT:
|
|
182
|
+
result = internal_redacted
|
|
183
|
+
redacted = True
|
|
184
|
+
# ANNOTATE: metadata/event only, data untouched
|
|
185
|
+
|
|
186
|
+
found_labels = list(dict.fromkeys([*secret_labels, *internal_labels]))
|
|
187
|
+
if not found_labels:
|
|
188
|
+
return data
|
|
189
|
+
|
|
190
|
+
meta["found"] = list(dict.fromkeys([*meta["found"], *found_labels]))
|
|
191
|
+
meta["risk"] = max(meta["risk"], 0.9 if secret_labels else 0.5)
|
|
192
|
+
|
|
193
|
+
logger.warning(
|
|
194
|
+
"secrets_detected",
|
|
195
|
+
session_id=context.session_id,
|
|
196
|
+
agent_id=context.agent_id,
|
|
197
|
+
types=found_labels,
|
|
198
|
+
phase=phase,
|
|
199
|
+
mode=self._mode.value,
|
|
200
|
+
internal_url_mode=self._internal_url_mode.value,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
if deny_labels:
|
|
204
|
+
context.decision = InterceptorDecision.DENY
|
|
205
|
+
context.decision_reason = (
|
|
206
|
+
f"Secrets detected in {phase}: {', '.join(sorted(set(deny_labels)))}"
|
|
207
|
+
)
|
|
208
|
+
result = data # deny preserves data unmutated
|
|
209
|
+
elif redacted:
|
|
210
|
+
meta["redacted"] = True
|
|
211
|
+
|
|
212
|
+
if self._writer is not None:
|
|
213
|
+
await self._emit_secrets_event(context, found_labels, phase)
|
|
214
|
+
|
|
215
|
+
return result
|
|
216
|
+
|
|
217
|
+
async def _emit_secrets_event(
|
|
218
|
+
self, context: ActionContext, found_labels: list[str], phase: str
|
|
219
|
+
) -> None:
|
|
220
|
+
seq = self._next_sequence(context.session_id)
|
|
221
|
+
event = Event(
|
|
222
|
+
session_id=context.session_id,
|
|
223
|
+
agent_id=context.agent_id,
|
|
224
|
+
event_type=EventType.POLICY_EVALUATED,
|
|
225
|
+
payload={
|
|
226
|
+
"policy": "secrets_check",
|
|
227
|
+
"secret_types": sorted(set(found_labels)),
|
|
228
|
+
"phase": phase,
|
|
229
|
+
"mode": self._mode.value,
|
|
230
|
+
"decision": context.decision.value,
|
|
231
|
+
},
|
|
232
|
+
sequence_num=seq,
|
|
233
|
+
)
|
|
234
|
+
await self._writer.write(event) # type: ignore[union-attr]
|
|
235
|
+
|
|
236
|
+
def _next_sequence(self, session_id: str) -> int:
|
|
237
|
+
count = self._sequence_counters.get(session_id, 0) + 1
|
|
238
|
+
self._sequence_counters[session_id] = count
|
|
239
|
+
return count
|