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,489 @@
|
|
|
1
|
+
"""Text canonicalization shared by the input-side pattern banks.
|
|
2
|
+
|
|
3
|
+
A pattern bank sees exactly the bytes it is handed. "Ignore all previous
|
|
4
|
+
instructions" is blocked at 0.85; the same sentence with a zero-width space
|
|
5
|
+
inside one word, or with the Cyrillic о instead of the Latin one, or written
|
|
6
|
+
1gn0re, scored 0.00 — the regex engine is looking at different characters and
|
|
7
|
+
is entirely correct to say so. That gap is not a missing pattern, it is a
|
|
8
|
+
missing step, and every pattern added without it is one substitution away from
|
|
9
|
+
being bypassed.
|
|
10
|
+
|
|
11
|
+
So the policies do not scan a string. They scan a small set of *views* of it:
|
|
12
|
+
|
|
13
|
+
raw what arrived, untouched
|
|
14
|
+
normalized NFKD, invisible characters dropped, combining marks dropped,
|
|
15
|
+
look-alike letters folded to ASCII, letter-spacing collapsed
|
|
16
|
+
deobfuscated the above, plus leetspeak undone and separator punctuation
|
|
17
|
+
treated as spaces
|
|
18
|
+
decoded whatever a base64, hex or percent-encoded run in the text
|
|
19
|
+
turned out to be, normalized in turn
|
|
20
|
+
|
|
21
|
+
Only views that differ from one already in the list are produced, so plain
|
|
22
|
+
English text costs exactly one scan, the same as before. The cost is paid by
|
|
23
|
+
text that has been fiddled with, which is the text worth paying for.
|
|
24
|
+
|
|
25
|
+
Two deliberate limits, because a reader should know what this does not do:
|
|
26
|
+
translation is not one of the transforms (an attack written in French is still
|
|
27
|
+
invisible to an English pattern bank), and de-leeting is confined to tokens
|
|
28
|
+
that already mix letters with digits, so "refund 9999" is never read as
|
|
29
|
+
"refund gggg".
|
|
30
|
+
|
|
31
|
+
Pure stdlib on purpose: this runs inside the browser demo, where the only
|
|
32
|
+
Python available is what Pyodide ships. Percent-decoding is done by hand rather
|
|
33
|
+
than with urllib.parse, because tools/policy_audit.py counts any reference to
|
|
34
|
+
urllib as a module that could reach the network, and that check is worth more
|
|
35
|
+
than the four lines it costs here.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import base64
|
|
41
|
+
import binascii
|
|
42
|
+
import re
|
|
43
|
+
import unicodedata
|
|
44
|
+
from collections.abc import Callable
|
|
45
|
+
from dataclasses import dataclass, field
|
|
46
|
+
from functools import partial
|
|
47
|
+
|
|
48
|
+
__all__ = ["Prepared", "Scan", "View", "canonical", "prepare", "scan"]
|
|
49
|
+
|
|
50
|
+
# Format characters (Cf) cover zero-width spaces, joiners, the bidi overrides
|
|
51
|
+
# and the BOM. Soft hyphen is Cf too. Listed rather than category-tested where
|
|
52
|
+
# the category would also catch something worth keeping.
|
|
53
|
+
_INVISIBLE = re.compile(r"[͏ᅟᅠ឴឵⠀ㅤᅠ]")
|
|
54
|
+
|
|
55
|
+
# Look-alikes that survive NFKD: NFKD decomposes mathematical, fullwidth and
|
|
56
|
+
# circled Latin, but Cyrillic and Greek letters are not Latin with decorations,
|
|
57
|
+
# they are separate letters that happen to be drawn the same.
|
|
58
|
+
_CONFUSABLES = str.maketrans(
|
|
59
|
+
{
|
|
60
|
+
# Cyrillic lowercase
|
|
61
|
+
"а": "a",
|
|
62
|
+
"б": "b",
|
|
63
|
+
"в": "b",
|
|
64
|
+
"г": "r",
|
|
65
|
+
"е": "e",
|
|
66
|
+
"ѐ": "e",
|
|
67
|
+
"ё": "e",
|
|
68
|
+
"з": "3",
|
|
69
|
+
"и": "u",
|
|
70
|
+
"й": "u",
|
|
71
|
+
"к": "k",
|
|
72
|
+
"м": "m",
|
|
73
|
+
"н": "h",
|
|
74
|
+
"о": "o",
|
|
75
|
+
"п": "n",
|
|
76
|
+
"р": "p",
|
|
77
|
+
"с": "c",
|
|
78
|
+
"т": "t",
|
|
79
|
+
"у": "y",
|
|
80
|
+
"х": "x",
|
|
81
|
+
"ч": "4",
|
|
82
|
+
"ѕ": "s",
|
|
83
|
+
"і": "i",
|
|
84
|
+
"ј": "j",
|
|
85
|
+
"ԁ": "d",
|
|
86
|
+
"һ": "h",
|
|
87
|
+
"ӏ": "l",
|
|
88
|
+
"ԛ": "q",
|
|
89
|
+
"ԝ": "w",
|
|
90
|
+
"ь": "b",
|
|
91
|
+
"я": "r",
|
|
92
|
+
# Cyrillic uppercase
|
|
93
|
+
"А": "A",
|
|
94
|
+
"В": "B",
|
|
95
|
+
"Е": "E",
|
|
96
|
+
"З": "3",
|
|
97
|
+
"К": "K",
|
|
98
|
+
"М": "M",
|
|
99
|
+
"Н": "H",
|
|
100
|
+
"О": "O",
|
|
101
|
+
"Р": "P",
|
|
102
|
+
"С": "C",
|
|
103
|
+
"Т": "T",
|
|
104
|
+
"У": "Y",
|
|
105
|
+
"Х": "X",
|
|
106
|
+
"Ѕ": "S",
|
|
107
|
+
"І": "I",
|
|
108
|
+
"Ј": "J",
|
|
109
|
+
"Ԁ": "D",
|
|
110
|
+
"Һ": "H",
|
|
111
|
+
"Ԛ": "Q",
|
|
112
|
+
"Ԝ": "W",
|
|
113
|
+
# Greek
|
|
114
|
+
"α": "a",
|
|
115
|
+
"β": "b",
|
|
116
|
+
"γ": "y",
|
|
117
|
+
"ε": "e",
|
|
118
|
+
"ζ": "z",
|
|
119
|
+
"η": "n",
|
|
120
|
+
"ι": "i",
|
|
121
|
+
"κ": "k",
|
|
122
|
+
"μ": "u",
|
|
123
|
+
"ν": "v",
|
|
124
|
+
"ο": "o",
|
|
125
|
+
"ρ": "p",
|
|
126
|
+
"σ": "o",
|
|
127
|
+
"τ": "t",
|
|
128
|
+
"υ": "u",
|
|
129
|
+
"χ": "x",
|
|
130
|
+
"ω": "w",
|
|
131
|
+
"Α": "A",
|
|
132
|
+
"Β": "B",
|
|
133
|
+
"Ε": "E",
|
|
134
|
+
"Ζ": "Z",
|
|
135
|
+
"Η": "H",
|
|
136
|
+
"Ι": "I",
|
|
137
|
+
"Κ": "K",
|
|
138
|
+
"Μ": "M",
|
|
139
|
+
"Ν": "N",
|
|
140
|
+
"Ο": "O",
|
|
141
|
+
"Ρ": "P",
|
|
142
|
+
"Τ": "T",
|
|
143
|
+
"Υ": "Y",
|
|
144
|
+
"Χ": "X",
|
|
145
|
+
# Latin letters that are not the usual ones
|
|
146
|
+
"ı": "i",
|
|
147
|
+
"ł": "l",
|
|
148
|
+
"ø": "o",
|
|
149
|
+
"œ": "oe",
|
|
150
|
+
"æ": "ae",
|
|
151
|
+
"ſ": "s",
|
|
152
|
+
# Armenian and Georgian shapes that get used for this
|
|
153
|
+
"օ": "o",
|
|
154
|
+
"ո": "n",
|
|
155
|
+
"ս": "s",
|
|
156
|
+
"զ": "q",
|
|
157
|
+
"ա": "w",
|
|
158
|
+
# Punctuation look-alikes that matter to the patterns
|
|
159
|
+
"‚": ",",
|
|
160
|
+
"„": ",",
|
|
161
|
+
"‘": "'",
|
|
162
|
+
"’": "'",
|
|
163
|
+
"‛": "'",
|
|
164
|
+
"“": '"',
|
|
165
|
+
"”": '"',
|
|
166
|
+
"‟": '"',
|
|
167
|
+
"‐": "-",
|
|
168
|
+
"‑": "-",
|
|
169
|
+
"‒": "-",
|
|
170
|
+
"–": "-",
|
|
171
|
+
"—": "-",
|
|
172
|
+
"―": "-",
|
|
173
|
+
"․": ".",
|
|
174
|
+
"‥": "..",
|
|
175
|
+
"…": "...",
|
|
176
|
+
"⁄": "/",
|
|
177
|
+
"∕": "/",
|
|
178
|
+
"︰": ":",
|
|
179
|
+
}
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Four or more single letters in a row, each followed by a separator: the
|
|
183
|
+
# "I g n o r e" and "i-g-n-o-r-e" shapes. Anchored on both sides so it cannot
|
|
184
|
+
# eat the first letter of a real word.
|
|
185
|
+
_PAD_PUNCT = r"\-._*|~·•‧"
|
|
186
|
+
_SEPARATORS = r" \t" + _PAD_PUNCT
|
|
187
|
+
_SPACED_OUT = re.compile(
|
|
188
|
+
rf"(?<![^\W\d_])(?:[^\W\d_][{_SEPARATORS}]{{1,3}}){{3,}}[^\W\d_](?![^\W\d_])"
|
|
189
|
+
)
|
|
190
|
+
_SEP_RUN = re.compile(rf"[{_SEPARATORS}]{{2,}}")
|
|
191
|
+
_SEP_ONE = re.compile(rf"[{_SEPARATORS}]")
|
|
192
|
+
_PUNCT_PAD = re.compile(rf"[{_PAD_PUNCT}]")
|
|
193
|
+
|
|
194
|
+
_LEET_BASE = {
|
|
195
|
+
"4": "a",
|
|
196
|
+
"3": "e",
|
|
197
|
+
"0": "o",
|
|
198
|
+
"5": "s",
|
|
199
|
+
"7": "t",
|
|
200
|
+
"8": "b",
|
|
201
|
+
"9": "g",
|
|
202
|
+
"$": "s",
|
|
203
|
+
"@": "a",
|
|
204
|
+
"+": "t",
|
|
205
|
+
"(": "c",
|
|
206
|
+
"€": "e",
|
|
207
|
+
"£": "l",
|
|
208
|
+
}
|
|
209
|
+
# 1, ! and | are drawn like both i and l, so each token gets read both ways and
|
|
210
|
+
# the more word-like reading wins: "1gn0re" is ignore rather than lgnore, and
|
|
211
|
+
# "a11" is all rather than aii. Guessing one way for the whole string gets one
|
|
212
|
+
# of those two wrong every time.
|
|
213
|
+
_LEET_I = str.maketrans({**_LEET_BASE, "1": "i", "!": "i", "|": "i"})
|
|
214
|
+
_LEET_L = str.maketrans({**_LEET_BASE, "1": "l", "!": "l", "|": "l"})
|
|
215
|
+
# Sequences English essentially does not produce. Used only to choose between
|
|
216
|
+
# two readings of the same token, never to reject text.
|
|
217
|
+
# Word-initial l before a consonant ("lgnore"), a doubled i ("aii"), and an i
|
|
218
|
+
# wedged between two vowels ("keyiogger") are the three shapes that separate a
|
|
219
|
+
# wrong reading from a right one often enough to be worth testing for. The
|
|
220
|
+
# middle vowel rule is written to leave "previous" alone, where the i follows a
|
|
221
|
+
# consonant.
|
|
222
|
+
_UNLIKELY = re.compile(r"ii|\bl(?=[bcdfgjklmnpqrstvwxz])|\bi(?=[lr])|[aeouy]i[aeouy]")
|
|
223
|
+
_LONE_ONE = re.compile(r"\b[1|]\b")
|
|
224
|
+
# A token is a de-leeting candidate only if it already contains a letter. That
|
|
225
|
+
# is what keeps "$9999", "order 4471" and "E11.9" out of it — numbers that are
|
|
226
|
+
# only numbers stay numbers.
|
|
227
|
+
_LEET_TOKEN = re.compile(r"\b(?=[\w$@!|+]*[a-zA-Z])[\w$@!|+]*[0134578$@!|+][\w$@!|+]*\b")
|
|
228
|
+
# Punctuation wedged between letters, as in "ignore.all.previous".
|
|
229
|
+
_WEDGED = re.compile(r"(?<=[^\W\d_])[._\-*|/~+](?=[^\W\d_])")
|
|
230
|
+
|
|
231
|
+
_B64_RUN = re.compile(r"[A-Za-z0-9+/]{16,}={0,2}")
|
|
232
|
+
_HEX_RUN = re.compile(r"(?:[0-9a-fA-F]{2}[\s:-]?){12,}")
|
|
233
|
+
_PERCENT_RUN = re.compile(r"(?:%[0-9a-fA-F]{2}){6,}")
|
|
234
|
+
|
|
235
|
+
MAX_DECODED_PAYLOADS = 3
|
|
236
|
+
MAX_DECODED_CHARS = 4096
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass(frozen=True)
|
|
240
|
+
class View:
|
|
241
|
+
"""One rendering of the input, and the name the metadata reports it under."""
|
|
242
|
+
|
|
243
|
+
name: str
|
|
244
|
+
text: str
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@dataclass(frozen=True)
|
|
248
|
+
class Prepared:
|
|
249
|
+
"""Every view worth scanning, plus what was done to produce them.
|
|
250
|
+
|
|
251
|
+
`markers` is the honest part of the report: it names the obfuscation that
|
|
252
|
+
was actually present, so a policy can say "caught after folding look-alike
|
|
253
|
+
characters" instead of leaving a reader to guess why a score appeared.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
views: list[View]
|
|
257
|
+
markers: list[str]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _strip_invisibles(text: str) -> tuple[str, bool]:
|
|
261
|
+
out = _INVISIBLE.sub("", text)
|
|
262
|
+
kept = []
|
|
263
|
+
removed = out != text
|
|
264
|
+
for ch in out:
|
|
265
|
+
category = unicodedata.category(ch)
|
|
266
|
+
if category in ("Cf", "Mn", "Me"): # format characters and combining marks
|
|
267
|
+
removed = True
|
|
268
|
+
continue
|
|
269
|
+
kept.append(ch)
|
|
270
|
+
return "".join(kept), removed
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _collapse_spacing(text: str) -> tuple[str, bool]:
|
|
274
|
+
"""Join "I g n o r e" back into a word, keeping the gaps between words.
|
|
275
|
+
|
|
276
|
+
Which character was the padding depends on what else is in the run. When
|
|
277
|
+
the padding is punctuation ("i-g-n-o-r-e a-l-l"), whitespace is still doing
|
|
278
|
+
its usual job and survives as one space. When the padding is whitespace
|
|
279
|
+
itself ("i g n o r e a l l"), a single space is padding and a run of two
|
|
280
|
+
or more is the word boundary. Collapsing every separator either way would
|
|
281
|
+
produce "ignoreallprevious", which matches nothing either.
|
|
282
|
+
"""
|
|
283
|
+
changed = False
|
|
284
|
+
|
|
285
|
+
def _join(match: re.Match[str]) -> str:
|
|
286
|
+
nonlocal changed
|
|
287
|
+
changed = True
|
|
288
|
+
chunk = match.group(0)
|
|
289
|
+
if _PUNCT_PAD.search(chunk):
|
|
290
|
+
return re.sub(r"\s+", " ", _PUNCT_PAD.sub("", chunk))
|
|
291
|
+
return _SEP_ONE.sub("", _SEP_RUN.sub("\x00", chunk)).replace("\x00", " ")
|
|
292
|
+
|
|
293
|
+
return _SPACED_OUT.sub(_join, text), changed
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def canonical(text: str) -> str:
|
|
297
|
+
"""The view every pattern bank should have been looking at all along."""
|
|
298
|
+
return _canonical(text)[0]
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _canonical(text: str) -> tuple[str, list[str]]:
|
|
302
|
+
markers: list[str] = []
|
|
303
|
+
out = unicodedata.normalize("NFKD", text)
|
|
304
|
+
if out != text:
|
|
305
|
+
markers.append("compatibility_forms")
|
|
306
|
+
|
|
307
|
+
out, stripped = _strip_invisibles(out)
|
|
308
|
+
if stripped:
|
|
309
|
+
markers.append("invisible_characters")
|
|
310
|
+
|
|
311
|
+
folded = out.translate(_CONFUSABLES)
|
|
312
|
+
if folded != out:
|
|
313
|
+
markers.append("look_alike_letters")
|
|
314
|
+
out = folded
|
|
315
|
+
|
|
316
|
+
out, spaced = _collapse_spacing(out)
|
|
317
|
+
if spaced:
|
|
318
|
+
markers.append("letter_spacing")
|
|
319
|
+
|
|
320
|
+
return out, markers
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _rank(word: str) -> int:
|
|
324
|
+
"""How many sequences English essentially does not produce this word has."""
|
|
325
|
+
return len(_UNLIKELY.findall(word.lower()))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _read_token(match: re.Match[str], prefer_likely: bool = True) -> str:
|
|
329
|
+
"""One of a token's two leet readings: the more word-like one, or the other.
|
|
330
|
+
|
|
331
|
+
"a11" is all, not aii, and "1gn0re" is ignore, not lgnore — a single global
|
|
332
|
+
guess gets one of those wrong whichever way it is made. The losing reading
|
|
333
|
+
is not thrown away either: "key10gger" looks equally plausible both ways,
|
|
334
|
+
and the one that matters is keylogger.
|
|
335
|
+
"""
|
|
336
|
+
token = match.group(0)
|
|
337
|
+
as_i = token.translate(_LEET_I)
|
|
338
|
+
as_l = token.translate(_LEET_L)
|
|
339
|
+
if as_i == as_l:
|
|
340
|
+
return as_i
|
|
341
|
+
# Ties go to i, the commoner substitution, so the runner-up is well defined.
|
|
342
|
+
ranked = sorted((as_i, as_l), key=lambda word: (_rank(word), word == as_l))
|
|
343
|
+
return ranked[0] if prefer_likely else ranked[1]
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _deobfuscate(text: str) -> tuple[list[str], list[str]]:
|
|
347
|
+
"""Undo leetspeak and punctuation wedged between letters.
|
|
348
|
+
|
|
349
|
+
Returns one reading for text with no ambiguous characters in it, and two
|
|
350
|
+
when the ambiguity is real. Scanning both costs one extra pass over text
|
|
351
|
+
that has already announced itself as obfuscated.
|
|
352
|
+
"""
|
|
353
|
+
markers: list[str] = []
|
|
354
|
+
readings: list[str] = []
|
|
355
|
+
|
|
356
|
+
for prefer_likely in (True, False):
|
|
357
|
+
out = _LEET_TOKEN.sub(partial(_read_token, prefer_likely=prefer_likely), text)
|
|
358
|
+
if out != text:
|
|
359
|
+
if "leetspeak" not in markers:
|
|
360
|
+
markers.append("leetspeak")
|
|
361
|
+
# A lone "1" is a number in ordinary text and a letter in
|
|
362
|
+
# leetspeak, so it is only re-read once some other token has given
|
|
363
|
+
# the text away. Without this, "h0w d0 1 bu1ld" keeps its pronoun
|
|
364
|
+
# as a digit.
|
|
365
|
+
out = _LONE_ONE.sub("i", out)
|
|
366
|
+
|
|
367
|
+
unwedged = _WEDGED.sub(" ", out)
|
|
368
|
+
if unwedged != out and "wedged_punctuation" not in markers:
|
|
369
|
+
markers.append("wedged_punctuation")
|
|
370
|
+
if unwedged not in readings:
|
|
371
|
+
readings.append(unwedged)
|
|
372
|
+
|
|
373
|
+
return readings, markers
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _readable(raw: bytes) -> str | None:
|
|
377
|
+
"""Decoded bytes are only interesting if they came out as prose."""
|
|
378
|
+
try:
|
|
379
|
+
text = raw.decode("utf-8")
|
|
380
|
+
except UnicodeDecodeError:
|
|
381
|
+
return None
|
|
382
|
+
if len(text) < 12:
|
|
383
|
+
return None
|
|
384
|
+
if sum(ch.isprintable() or ch in "\n\t" for ch in text) / len(text) < 0.95:
|
|
385
|
+
return None
|
|
386
|
+
if sum(ch.isalpha() for ch in text) / len(text) < 0.5:
|
|
387
|
+
return None
|
|
388
|
+
return text[:MAX_DECODED_CHARS]
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _decode_payloads(text: str) -> list[str]:
|
|
392
|
+
"""Anything encoded in the prompt, decoded, so it gets scanned as well.
|
|
393
|
+
|
|
394
|
+
A prompt that says "decode this and do what it says" is carrying its
|
|
395
|
+
payload in plain sight. Runs that decode to bytes rather than to prose are
|
|
396
|
+
dropped, which is what keeps an API key or a hash from being read as an
|
|
397
|
+
instruction.
|
|
398
|
+
"""
|
|
399
|
+
found: list[str] = []
|
|
400
|
+
|
|
401
|
+
for match in _B64_RUN.finditer(text):
|
|
402
|
+
run = match.group(0).rstrip("=")
|
|
403
|
+
try:
|
|
404
|
+
decoded = base64.b64decode(run + "=" * (-len(run) % 4), validate=True)
|
|
405
|
+
except (binascii.Error, ValueError):
|
|
406
|
+
continue
|
|
407
|
+
readable = _readable(decoded)
|
|
408
|
+
if readable:
|
|
409
|
+
found.append(readable)
|
|
410
|
+
|
|
411
|
+
for match in _HEX_RUN.finditer(text):
|
|
412
|
+
digits = re.sub(r"[\s:-]", "", match.group(0))
|
|
413
|
+
if len(digits) % 2:
|
|
414
|
+
digits = digits[:-1]
|
|
415
|
+
try:
|
|
416
|
+
readable = _readable(bytes.fromhex(digits))
|
|
417
|
+
except ValueError:
|
|
418
|
+
continue
|
|
419
|
+
if readable:
|
|
420
|
+
found.append(readable)
|
|
421
|
+
|
|
422
|
+
for match in _PERCENT_RUN.finditer(text):
|
|
423
|
+
raw = bytes(int(pair, 16) for pair in match.group(0).split("%") if pair)
|
|
424
|
+
readable = _readable(raw)
|
|
425
|
+
if readable:
|
|
426
|
+
found.append(readable)
|
|
427
|
+
|
|
428
|
+
return found[:MAX_DECODED_PAYLOADS]
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def prepare(text: str) -> Prepared:
|
|
432
|
+
"""Build the views a pattern bank should scan, cheapest first.
|
|
433
|
+
|
|
434
|
+
Views that came out identical to one already in the list are dropped, so
|
|
435
|
+
ordinary text produces exactly one view and costs exactly one pass.
|
|
436
|
+
"""
|
|
437
|
+
views = [View("raw", text)]
|
|
438
|
+
seen = {text}
|
|
439
|
+
markers: list[str] = []
|
|
440
|
+
|
|
441
|
+
normalized, canon_markers = _canonical(text)
|
|
442
|
+
markers.extend(canon_markers)
|
|
443
|
+
if normalized not in seen:
|
|
444
|
+
views.append(View("normalized", normalized))
|
|
445
|
+
seen.add(normalized)
|
|
446
|
+
|
|
447
|
+
readings, deob_markers = _deobfuscate(normalized)
|
|
448
|
+
markers.extend(deob_markers)
|
|
449
|
+
for reading in readings:
|
|
450
|
+
if reading not in seen:
|
|
451
|
+
views.append(View("deobfuscated", reading))
|
|
452
|
+
seen.add(reading)
|
|
453
|
+
|
|
454
|
+
for payload in _decode_payloads(normalized):
|
|
455
|
+
decoded, _ = _canonical(payload)
|
|
456
|
+
if decoded not in seen:
|
|
457
|
+
views.append(View("decoded", decoded))
|
|
458
|
+
seen.add(decoded)
|
|
459
|
+
if "encoded_payload" not in markers:
|
|
460
|
+
markers.append("encoded_payload")
|
|
461
|
+
|
|
462
|
+
return Prepared(views=views, markers=markers)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
@dataclass(frozen=True)
|
|
466
|
+
class Scan:
|
|
467
|
+
"""What a pattern bank found, and which view of the text it found it in."""
|
|
468
|
+
|
|
469
|
+
risk: float
|
|
470
|
+
signals: dict[str, float] = field(default_factory=dict)
|
|
471
|
+
#: "raw" unless an obfuscated view scored higher than the text as it arrived.
|
|
472
|
+
view: str = "raw"
|
|
473
|
+
#: Obfuscation present in the input, whether or not it changed the score.
|
|
474
|
+
markers: list[str] = field(default_factory=list)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def scan(text: str, scorer: Callable[[str], tuple[float, dict[str, float]]]) -> Scan:
|
|
478
|
+
"""Run *scorer* over every view of *text* and keep the strongest result.
|
|
479
|
+
|
|
480
|
+
Ties go to the earliest view, so a prompt that scores the same before and
|
|
481
|
+
after normalization is reported as what it plainly is.
|
|
482
|
+
"""
|
|
483
|
+
prepared = prepare(text)
|
|
484
|
+
best = Scan(risk=0.0, signals={}, view="raw", markers=prepared.markers)
|
|
485
|
+
for view in prepared.views:
|
|
486
|
+
risk, signals = scorer(view.text)
|
|
487
|
+
if risk > best.risk:
|
|
488
|
+
best = Scan(risk=risk, signals=signals, view=view.name, markers=prepared.markers)
|
|
489
|
+
return best
|