Setting the file. One moment.
Safe Query · Aurora Dsql · aws/agent-toolkit-for-aws · Skills Docs
ContentsBack to the top of the page 10
Setup DevOps Agent
33
AWS Deployment
This file
Number 63.48
Position 48 of 49
Type Python
Size 10 KB
Lines 271 scripts/ safe_query.py
Python · 271 lines · 10 KB
16
)
17 # Pass `sql` to your driver: cur.execute(sql), conn.Query(ctx, sql), etc.
18 # Or `psql -c "$sql"` after composing in a python3 subshell — see the
19 # bash-deliverables block in references/input-validation.md.
20
21 sql = build(
22 "INSERT INTO entities (entity_id, tenant_id, name) "
23 "VALUES ({eid}, {tid}, {name})",
24 eid=regex(new_id, UUID),
25 tid=regex(tenant, TENANT_SLUG),
26 name=literal(user_supplied_name), # free text — dollar-quoted
27 )
28
29 Design rules:
30 - Raw strings passed to build() raise UnsafeSQLError. That is the point.
31 - Format validation does NOT prove authorization; authorize separately.
32 - When using a Postgres driver in application code, prefer the driver's
33 native parameter binding. Reach for safe_query whenever you must build
34 a raw SQL string (dynamic identifiers, shell-driven pipelines, etc.).
35 """
36
37 import re
38 import secrets
39 import string
40 from typing import AbstractSet, Any, Callable, Dict, Pattern
41
42 TENANT_SLUG : Pattern[ str ] = re.compile( r " [ a-z0-9- ] {1,64} " )
43 UUID : Pattern[ str ] = re.compile(
44 r " [ 0-9a-f ] {8} - [ 0-9a-f ] {4} - [ 0-9a-f ] {4} - [ 0-9a-f ] {4} - [ 0-9a-f ] {12} " ,
45 re. IGNORECASE ,
46 )
47 INT : Pattern[ str ] = re.compile( r "- ? [ 0-9 ] {1,19} " )
48 ISO_DATE : Pattern[ str ] = re.compile( r " \d {4} - ( 0 [ 1-9 ] | 1 [ 0-2 ]) - ( 0 [ 1-9 ] | [ 12 ]\d | 3 [ 01 ]) " )
49 _IDENT : Pattern[ str ] = re.compile( r " [ a-z_ ][ a-z0-9_ ] {0,62} " , re. IGNORECASE )
50
51
52 class UnsafeSQLError ( ValueError ):
53 """A value failed validation. Never catch and fall back — fix the caller."""
54
55
56 class Safe :
57 """A value that has passed validation and is safe to interpolate.
58
59 `build()` accepts only Safe instances. This is how the module prevents
60 `build("... {x} ...", x=user_input)` from ever working.
61 """
62
63 __slots__ = ( "_sql" ,)
64
65 def __init__ (self, sql: str ) -> None :
66 self ._sql = sql
67
68 def __str__ (self) -> str :
69 return self ._sql
70
71
72 def allow (value: Any, allowed: AbstractSet[ str ], * , label: str = "value" ) -> Safe:
73 """Allowlist-validate and emit as a single-quoted string literal."""
74 if value not in allowed:
75 raise UnsafeSQLError( f " { label } not in allowlist: { value !r} " )
76 # Allowlisted values originate from developer-controlled sets; the escape
77 # is belt-and-braces in case someone puts a quote in the set.
78 return Safe( "'" + str (value).replace( "'" , "''" ) + "'" )
79
80
81 def keyword (value: str , allowed: AbstractSet[ str ], * , label: str = "keyword" ) -> Safe:
82 """Allowlist-validate a SQL keyword and emit it unquoted.
83
84 Use for ASC/DESC, AND/OR, or other places where a string literal would be
85 syntactically wrong.
86 """
87 if value not in allowed:
88 raise UnsafeSQLError( f " { label } not in allowlist: { value !r} " )
89 return Safe(value)
90
91
92 def regex (value: Any, pattern: Pattern[ str ], * , label: str = "value" ) -> Safe:
93 """Regex-validate with re.fullmatch and emit as a single-quoted literal.
94
95 Rejects values containing a single quote or backslash. `regex()` is for
96 strict-format values (UUIDs, slugs, dates) that never legitimately need
97 embedded quotes or backslashes; free text belongs in `literal()`, which
98 dollar-quotes and sidesteps escaping entirely.
99 """
100 if not isinstance (value, str ) or not pattern.fullmatch(value):
101 raise UnsafeSQLError( f " { label } failed pattern { pattern.pattern !r} : { value !r} " )
102 if "'" in value:
103 raise UnsafeSQLError(
104 f " { label } contains a single quote; use literal() for free text: { value !r} "
105 )
106 if " \\ " in value:
107 raise UnsafeSQLError(
108 f " { label } contains a backslash; use literal() for values "
109 f "needing special characters: { value !r} "
110 )
111 return Safe( "'" + value + "'" )
112
113
114 def ident (name: str ) -> Safe:
115 """Validate a SQL identifier (table or column) and emit it double-quoted."""
116 if not isinstance (name, str ) or not _IDENT .fullmatch(name):
117 raise UnsafeSQLError( f "invalid identifier: { name !r} " )
118 return Safe( '"' + name + '"' )
119
120
121 def integer (value: Any) -> Safe:
122 """Validate an integer. Accepts int or numeric string; rejects bool."""
123 if isinstance (value, bool ):
124 raise UnsafeSQLError( f "expected int, got bool: { value !r} " )
125 if isinstance (value, int ):
126 return Safe( str (value))
127 if isinstance (value, str ) and INT .fullmatch(value):
128 return Safe(value)
129 raise UnsafeSQLError( f "invalid integer: { value !r} " )
130
131
132 def literal (value: str ) -> Safe:
133 """Emit free text as a PostgreSQL dollar-quoted literal.
134
135 Picks a random tag until it does not appear inside `value`, which sidesteps
136 quote-escaping entirely. Use for descriptions, names, comments — values
137 without a strict format.
138 """
139 if not isinstance (value, str ):
140 raise UnsafeSQLError( f "expected str, got { type (value). __name__ } " )
141 for _ in range ( 8 ):
142 tag = "dq_" + secrets.token_hex( 4 )
143 boundary = f "$ { tag } $"
144 if boundary not in value:
145 return Safe( f " { boundary }{ value }{ boundary } " )
146 # Eight 32-bit-random tag collisions implies adversarial input.
147 raise UnsafeSQLError( "could not generate a unique dollar-quote tag" )
148
149
150 def build (template: str , ** parts: Safe) -> str :
151 """Substitute validated parts into a SQL template.
152
153 Template uses `{name}` placeholders (str.format syntax). Every placeholder
154 MUST map to a Safe value; raw strings raise UnsafeSQLError so the
155 `build("... {t} ...", t=user_input)` anti-pattern fails loudly.
156
157 Also rejects template/kwargs mismatch: a missing key would otherwise raise
158 `KeyError` (invisible to callers catching `UnsafeSQLError`), and an extra
159 key would be silently ignored — dropping, for example, a tenant filter
160 from the query.
161 """
162 for key, value in parts.items():
163 if not isinstance (value, Safe):
164 raise UnsafeSQLError(
165 f " { key !r} must be a Safe value from allow/regex/ident/"
166 f "keyword/integer/literal; got { type (value). __name__ } "
167 )
168 expected: set[ str ] = set ()
169 for _, fname, fspec, conv in string.Formatter().parse(template):
170 if fname is None :
171 continue
172 if fname == "" or fname.isdigit():
173 raise UnsafeSQLError(
174 f "template contains a positional placeholder {{{ fname or '' }}} ; "
175 f "use named placeholders like {{ name }} "
176 )
177 if conv:
178 raise UnsafeSQLError(
179 f "placeholder {{{ fname } ! { conv }}} uses a conversion flag; "
180 f "Safe values must be interpolated without conversion"
181 )
182 if fspec:
183 raise UnsafeSQLError(
184 f "placeholder {{{ fname } : { fspec }}} uses a format spec; "
185 f "Safe values must be interpolated without formatting"
186 )
187 expected.add(fname)
188 provided = set (parts.keys())
189 if expected != provided:
190 missing = expected - provided
191 extra = provided - expected
192 raise UnsafeSQLError(
193 f "template/kwargs mismatch: missing { sorted (missing) } , " f "extra { sorted (extra) } "
194 )
195 try :
196 return template.format( ** {k: str (v) for k, v in parts.items()})
197 except ( KeyError , IndexError ) as exc:
198 raise UnsafeSQLError(
199 f "template references a key not in kwargs " f "(possibly in a format spec): { exc } "
200 ) from exc
201
202
203 def _selftest () -> None :
204 """Smoke-test every validator and build()."""
205
206 def _check (condition: bool , msg: str ) -> None :
207 if not condition:
208 raise RuntimeError (msg)
209
210 def _expect_unsafe (fn: str , * args: Any, ** kwargs: Any) -> None :
211 """Call a validator/build by name and verify it raises UnsafeSQLError."""
212 registry: Dict[ str , Callable[ ... , Any]] = {
213 "allow" : allow,
214 "keyword" : keyword,
215 "regex" : regex,
216 "ident" : ident,
217 "integer" : integer,
218 "literal" : literal,
219 "build" : build,
220 }
221 target = registry[fn]
222 try :
223 target( * args, ** kwargs)
224 raise RuntimeError ( f "expected UnsafeSQLError from { fn } " )
225 except UnsafeSQLError:
226 pass
227
228 # Happy paths
229 _check( str (allow( "tenant-1" , { "tenant-1" })) == "'tenant-1'" , "allow" )
230 _check( str (keyword( "ASC" , { "ASC" , "DESC" })) == "ASC" , "keyword" )
231 _check( str (regex( "a-1" , TENANT_SLUG )) == "'a-1'" , "regex" )
232 _check( str (ident( "entities" )) == '"entities"' , "ident" )
233 _check( str (integer( 42 )) == "42" , "integer" )
234 _check( str (integer( "-7" )) == "-7" , "integer neg" )
235 lit = str (literal( "o'reilly" ))
236 _check(lit.startswith( "$dq_" ) and "o'reilly" in lit, "literal" )
237
238 sql = build(
239 "SELECT * FROM {t} WHERE tenant_id = {tid} " ,
240 t = ident( "entities" ),
241 tid = regex( "acme" , TENANT_SLUG ),
242 )
243 _check(sql == "SELECT * FROM \" entities \" WHERE tenant_id = 'acme'" , "build" )
244 _check( str (regex( "abc" , TENANT_SLUG , label = "tenant" )) == "'abc'" , "regex label" )
245
246 # Rejections
247 _permissive = re.compile( r " . + " )
248 _expect_unsafe( "allow" , "evil" , { "tenant-1" })
249 _expect_unsafe( "keyword" , "DROP" , { "ASC" , "DESC" })
250 _expect_unsafe( "regex" , "'; DROP TABLE t; --" , TENANT_SLUG )
251 _expect_unsafe( "ident" , 'x" OR 1=1 --' )
252 _expect_unsafe( "integer" , "1; DROP" )
253 _expect_unsafe( "integer" , True )
254 _expect_unsafe( "literal" , 123 )
255 _expect_unsafe( "build" , "SELECT {x} " , x = "raw string" )
256 _expect_unsafe( "regex" , "x' OR 1=1 --" , _permissive)
257 _expect_unsafe( "regex" , "it's" , _permissive)
258 _expect_unsafe( "regex" , "'" , _permissive)
259 _expect_unsafe( "regex" , "abc \\ " , _permissive)
260 _expect_unsafe( "build" , "SELECT {x} " , x = ident( "col" ), y = ident( "extra" ))
261 _expect_unsafe( "build" , "SELECT {x} FROM {y} " , x = ident( "col" ))
262 _expect_unsafe( "build" , "SELECT {x!r} " , x = ident( "col" ))
263 _expect_unsafe( "build" , "SELECT {x:>30} " , x = ident( "col" ))
264 _expect_unsafe( "build" , "SELECT {} " , x = ident( "col" ))
265 _expect_unsafe( "build" , "SELECT {0} " , x = ident( "col" ))
266
267 print ( "safe_query self-test passed" )
268
269
270 if __name__ == "__main__" :
271 _selftest()