sqlguard-ninja 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.
- sqlguard_ninja/__init__.py +35 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/cases/01_destructive.yaml +169 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/cases/02_multi_statement_and_formatting.yaml +155 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/cases/03_table_and_column_access.yaml +205 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/cases/04_privacy.yaml +245 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/cases/05_tenant_isolation.yaml +167 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/cases/06_cte_nested_unicode.yaml +168 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/cases/07_legitimate_lookalike_cost_dialect.yaml +243 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/generated/grant_matrix.yaml +573 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/generated/label_matrix.yaml +403 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/generated/tenant_matrix.yaml +684 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/generated/transforms.yaml +927 -0
- sqlguard_ninja/_data/benchmarks/datasets/v1/manifest.yaml +15 -0
- sqlguard_ninja/_data/benchmarks/policies/retail_saas.yaml +259 -0
- sqlguard_ninja/_version.py +1 -0
- sqlguard_ninja/benchmarking/__init__.py +1 -0
- sqlguard_ninja/benchmarking/adapters.py +303 -0
- sqlguard_ninja/benchmarking/builtin.py +39 -0
- sqlguard_ninja/benchmarking/compare.py +168 -0
- sqlguard_ninja/benchmarking/dataset.py +265 -0
- sqlguard_ninja/benchmarking/fixtures.py +274 -0
- sqlguard_ninja/benchmarking/metrics.py +114 -0
- sqlguard_ninja/benchmarking/runner.py +126 -0
- sqlguard_ninja/benchmarking/translate.py +64 -0
- sqlguard_ninja/benchmarking/workers/_protocol.py +53 -0
- sqlguard_ninja/benchmarking/workers/agent_sql_guard_worker.py +40 -0
- sqlguard_ninja/benchmarking/workers/krish_sqlguard_worker.py +25 -0
- sqlguard_ninja/benchmarking/workers/sql_guardrail_worker.py +48 -0
- sqlguard_ninja/cli/__init__.py +1 -0
- sqlguard_ninja/cli/config.py +76 -0
- sqlguard_ninja/cli/main.py +433 -0
- sqlguard_ninja/cli/templates.py +91 -0
- sqlguard_ninja/core/__init__.py +1 -0
- sqlguard_ninja/core/analysis.py +581 -0
- sqlguard_ninja/core/findings.py +133 -0
- sqlguard_ninja/core/guard.py +256 -0
- sqlguard_ninja/core/rules/__init__.py +1 -0
- sqlguard_ninja/core/rules/access.py +127 -0
- sqlguard_ninja/core/rules/base.py +40 -0
- sqlguard_ninja/core/rules/catalog.py +61 -0
- sqlguard_ninja/core/rules/functions.py +197 -0
- sqlguard_ninja/core/rules/statements.py +99 -0
- sqlguard_ninja/core/rules/structure.py +72 -0
- sqlguard_ninja/core/rules/tenant.py +196 -0
- sqlguard_ninja/integrations/__init__.py +43 -0
- sqlguard_ninja/integrations/audit.py +109 -0
- sqlguard_ninja/integrations/checkpoint.py +342 -0
- sqlguard_ninja/integrations/executors.py +64 -0
- sqlguard_ninja/integrations/fastapi.py +89 -0
- sqlguard_ninja/integrations/langchain.py +67 -0
- sqlguard_ninja/integrations/langgraph.py +87 -0
- sqlguard_ninja/policy/__init__.py +39 -0
- sqlguard_ninja/policy/model.py +370 -0
- sqlguard_ninja/privacy/__init__.py +9 -0
- sqlguard_ninja/privacy/rules.py +286 -0
- sqlguard_ninja/privacy/suggest.py +46 -0
- sqlguard_ninja/py.typed +0 -0
- sqlguard_ninja/query_cost/__init__.py +1 -0
- sqlguard_ninja/query_cost/postgres.py +226 -0
- sqlguard_ninja/query_cost/static.py +241 -0
- sqlguard_ninja/reporting/__init__.py +1 -0
- sqlguard_ninja/reporting/markdown.py +137 -0
- sqlguard_ninja-0.1.0.dist-info/METADATA +370 -0
- sqlguard_ninja-0.1.0.dist-info/RECORD +68 -0
- sqlguard_ninja-0.1.0.dist-info/WHEEL +4 -0
- sqlguard_ninja-0.1.0.dist-info/entry_points.txt +2 -0
- sqlguard_ninja-0.1.0.dist-info/licenses/LICENSE +202 -0
- sqlguard_ninja-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +56 -0
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
"""Parse SQL once and extract the facts every rule needs.
|
|
2
|
+
|
|
3
|
+
The analysis is deliberately *structural*: it records which base tables each scope reads,
|
|
4
|
+
which base columns each scope references (and in which clause and aggregate context), which
|
|
5
|
+
functions are called, and where the analysis was incomplete. Rules then interpret these
|
|
6
|
+
facts against a policy. Anything the analyser cannot attribute is recorded as *unresolved*
|
|
7
|
+
so rules can fail closed.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from enum import StrEnum
|
|
16
|
+
|
|
17
|
+
import sqlglot
|
|
18
|
+
from sqlglot import exp
|
|
19
|
+
from sqlglot.errors import OptimizeError, ParseError, SqlglotError, TokenError
|
|
20
|
+
from sqlglot.optimizer.qualify import qualify
|
|
21
|
+
from sqlglot.optimizer.scope import Scope, traverse_scope
|
|
22
|
+
|
|
23
|
+
from sqlguard_ninja.policy.model import Catalog
|
|
24
|
+
|
|
25
|
+
# sqlglot logs a warning whenever it falls back to exp.Command; the guard reports those
|
|
26
|
+
# as findings, so the log line is noise.
|
|
27
|
+
logging.getLogger("sqlglot").setLevel(logging.ERROR)
|
|
28
|
+
|
|
29
|
+
SUPPORTED_DIALECTS = ("postgres", "sqlite")
|
|
30
|
+
"""Dialects covered by the test-suite and benchmark. Others parse but are experimental."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Clause(StrEnum):
|
|
34
|
+
SELECT = "select"
|
|
35
|
+
WHERE = "where"
|
|
36
|
+
JOIN = "join"
|
|
37
|
+
GROUP = "group"
|
|
38
|
+
HAVING = "having"
|
|
39
|
+
ORDER = "order"
|
|
40
|
+
QUALIFY = "qualify"
|
|
41
|
+
OTHER = "other"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AggregateKind(StrEnum):
|
|
45
|
+
NONE = "none"
|
|
46
|
+
STATISTICAL = "statistical"
|
|
47
|
+
"""Reduces many values to a statistic (COUNT, SUM, AVG, ...)."""
|
|
48
|
+
VALUE_REVEALING = "value_revealing"
|
|
49
|
+
"""Returns an input value or all of them (MIN, MAX, ARRAY_AGG, window aggregates...)."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_STATISTICAL_AGGS: tuple[type[exp.Expr], ...] = tuple(
|
|
53
|
+
getattr(exp, n)
|
|
54
|
+
for n in (
|
|
55
|
+
"Count",
|
|
56
|
+
"Sum",
|
|
57
|
+
"Avg",
|
|
58
|
+
"Stddev",
|
|
59
|
+
"StddevPop",
|
|
60
|
+
"StddevSamp",
|
|
61
|
+
"Variance",
|
|
62
|
+
"VariancePop",
|
|
63
|
+
"ApproxDistinct",
|
|
64
|
+
"CountIf",
|
|
65
|
+
"Corr",
|
|
66
|
+
"CovarPop",
|
|
67
|
+
"CovarSamp",
|
|
68
|
+
)
|
|
69
|
+
if hasattr(exp, n)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
_CLAUSE_KEYS = {
|
|
73
|
+
"where": Clause.WHERE,
|
|
74
|
+
"joins": Clause.JOIN,
|
|
75
|
+
"group": Clause.GROUP,
|
|
76
|
+
"having": Clause.HAVING,
|
|
77
|
+
"order": Clause.ORDER,
|
|
78
|
+
"qualify": Clause.QUALIFY,
|
|
79
|
+
"expressions": Clause.SELECT,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
_JSON_EXTRACT_TYPES: tuple[type[exp.Expr], ...] = tuple(
|
|
83
|
+
getattr(exp, n)
|
|
84
|
+
for n in (
|
|
85
|
+
"JSONExtract",
|
|
86
|
+
"JSONExtractScalar",
|
|
87
|
+
"JSONBExtract",
|
|
88
|
+
"JSONBExtractScalar",
|
|
89
|
+
"JSONExtractArray",
|
|
90
|
+
)
|
|
91
|
+
if hasattr(exp, n)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Statement and clause node types that must never appear in a read-only query.
|
|
95
|
+
WRITE_NODE_TYPES: tuple[type[exp.Expr], ...] = tuple(
|
|
96
|
+
getattr(exp, n)
|
|
97
|
+
for n in (
|
|
98
|
+
"Insert",
|
|
99
|
+
"Update",
|
|
100
|
+
"Delete",
|
|
101
|
+
"Merge",
|
|
102
|
+
"Create",
|
|
103
|
+
"Drop",
|
|
104
|
+
"Alter",
|
|
105
|
+
"TruncateTable",
|
|
106
|
+
"Copy",
|
|
107
|
+
"Grant",
|
|
108
|
+
"Revoke",
|
|
109
|
+
"LoadData",
|
|
110
|
+
"Comment",
|
|
111
|
+
"Analyze",
|
|
112
|
+
"Cache",
|
|
113
|
+
"Uncache",
|
|
114
|
+
"Refresh",
|
|
115
|
+
"Kill",
|
|
116
|
+
)
|
|
117
|
+
if hasattr(exp, n)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
SESSION_NODE_TYPES: tuple[type[exp.Expr], ...] = tuple(
|
|
121
|
+
getattr(exp, n)
|
|
122
|
+
for n in (
|
|
123
|
+
"Set",
|
|
124
|
+
"Pragma",
|
|
125
|
+
"Attach",
|
|
126
|
+
"Detach",
|
|
127
|
+
"Use",
|
|
128
|
+
"Transaction",
|
|
129
|
+
"Commit",
|
|
130
|
+
"Rollback",
|
|
131
|
+
"Execute",
|
|
132
|
+
"Declare",
|
|
133
|
+
"Describe",
|
|
134
|
+
"Show",
|
|
135
|
+
"Summarize",
|
|
136
|
+
)
|
|
137
|
+
if hasattr(exp, n)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True)
|
|
142
|
+
class TableRef:
|
|
143
|
+
"""A reference to a base (catalog) table inside one scope."""
|
|
144
|
+
|
|
145
|
+
name: str
|
|
146
|
+
"""Normalised table name, without schema."""
|
|
147
|
+
schema: str | None
|
|
148
|
+
alias: str
|
|
149
|
+
scope_id: int
|
|
150
|
+
is_function: bool = False
|
|
151
|
+
function_name: str | None = None
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def qualified(self) -> str:
|
|
155
|
+
return f"{self.schema}.{self.name}" if self.schema else self.name
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class ColumnRef:
|
|
160
|
+
"""A reference to a column of a base table."""
|
|
161
|
+
|
|
162
|
+
table: str
|
|
163
|
+
column: str
|
|
164
|
+
clause: Clause
|
|
165
|
+
aggregate: AggregateKind
|
|
166
|
+
scope_id: int
|
|
167
|
+
json_path: tuple[str, ...] | None = None
|
|
168
|
+
whole_row: bool = False
|
|
169
|
+
"""True for ``SELECT t`` (entire row) or an expanded ``*``."""
|
|
170
|
+
from_star: bool = False
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True)
|
|
174
|
+
class FunctionRef:
|
|
175
|
+
names: frozenset[str]
|
|
176
|
+
"""All names the function is known by: its source name and sqlglot's canonical name."""
|
|
177
|
+
display: str
|
|
178
|
+
known: bool
|
|
179
|
+
"""False when sqlglot does not recognise the function (``exp.Anonymous``)."""
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass
|
|
183
|
+
class ScopeFacts:
|
|
184
|
+
scope_id: int
|
|
185
|
+
expression: exp.Expr
|
|
186
|
+
tables: list[TableRef] = field(default_factory=list)
|
|
187
|
+
"""Base tables read in this scope, keyed by alias through ``alias_to_table``."""
|
|
188
|
+
alias_to_table: dict[str, TableRef] = field(default_factory=dict)
|
|
189
|
+
limit: int | None = None
|
|
190
|
+
has_limit: bool = False
|
|
191
|
+
limit_unparseable: bool = False
|
|
192
|
+
depth: int = 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass
|
|
196
|
+
class QueryAnalysis:
|
|
197
|
+
sql: str
|
|
198
|
+
dialect: str
|
|
199
|
+
statements: list[exp.Expr] = field(default_factory=list)
|
|
200
|
+
root: exp.Expr | None = None
|
|
201
|
+
qualified: exp.Expr | None = None
|
|
202
|
+
parse_error: str | None = None
|
|
203
|
+
qualify_error: str | None = None
|
|
204
|
+
scopes: list[ScopeFacts] = field(default_factory=list)
|
|
205
|
+
tables: list[TableRef] = field(default_factory=list)
|
|
206
|
+
columns: list[ColumnRef] = field(default_factory=list)
|
|
207
|
+
functions: list[FunctionRef] = field(default_factory=list)
|
|
208
|
+
unresolved_columns: list[str] = field(default_factory=list)
|
|
209
|
+
unexpandable_stars: list[str] = field(default_factory=list)
|
|
210
|
+
node_count: int = 0
|
|
211
|
+
join_count: int = 0
|
|
212
|
+
max_depth: int = 0
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def ok(self) -> bool:
|
|
216
|
+
return self.parse_error is None and self.root is not None
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def base_table_names(self) -> list[str]:
|
|
220
|
+
return sorted({t.name for t in self.tables if not t.is_function})
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def parse_statements(sql: str, dialect: str) -> list[exp.Expr]:
|
|
224
|
+
"""Parse ``sql`` into statements, dropping empty ones (e.g. a trailing ``;``)."""
|
|
225
|
+
return [s for s in sqlglot.parse(sql, dialect=dialect) if s is not None]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def function_names(node: exp.Func) -> FunctionRef:
|
|
229
|
+
if isinstance(node, exp.Anonymous):
|
|
230
|
+
name = str(node.name).lower()
|
|
231
|
+
return FunctionRef(frozenset({name}), name, known=False)
|
|
232
|
+
canonical = node.sql_name().lower()
|
|
233
|
+
names = {canonical, type(node).__name__.lower()}
|
|
234
|
+
# Some functions keep their written name in the AST (e.g. CURRENT_VERSION for version()).
|
|
235
|
+
meta_name = node.meta.get("name") if node.meta else None
|
|
236
|
+
if isinstance(meta_name, str):
|
|
237
|
+
names.add(meta_name.lower())
|
|
238
|
+
return FunctionRef(frozenset(names), canonical, known=True)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _unwrap_root(statement: exp.Expr) -> exp.Expr:
|
|
242
|
+
node = statement
|
|
243
|
+
while isinstance(node, (exp.Subquery, exp.Paren)) and node.this is not None:
|
|
244
|
+
node = node.this
|
|
245
|
+
return node
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _limit_value(select: exp.Expr) -> tuple[bool, int | None, bool]:
|
|
249
|
+
"""Return (has_limit, value, unparseable) for a SELECT or set operation."""
|
|
250
|
+
limit = select.args.get("limit")
|
|
251
|
+
if limit is None:
|
|
252
|
+
fetch = select.args.get("fetch") if hasattr(select, "args") else None
|
|
253
|
+
if fetch is None:
|
|
254
|
+
return False, None, False
|
|
255
|
+
count = fetch.args.get("count")
|
|
256
|
+
limit_expr = count
|
|
257
|
+
else:
|
|
258
|
+
limit_expr = limit.expression if isinstance(limit, exp.Limit) else limit
|
|
259
|
+
if isinstance(limit_expr, exp.Literal) and not limit_expr.is_string:
|
|
260
|
+
try:
|
|
261
|
+
return True, int(str(limit_expr.this)), False
|
|
262
|
+
except ValueError:
|
|
263
|
+
return True, None, True
|
|
264
|
+
return True, None, True
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _clause_of(node: exp.Expr, scope_expr: exp.Expr) -> Clause:
|
|
268
|
+
current = node
|
|
269
|
+
while current.parent is not None and current is not scope_expr:
|
|
270
|
+
parent = current.parent
|
|
271
|
+
if parent is scope_expr:
|
|
272
|
+
return _CLAUSE_KEYS.get(current.arg_key or "", Clause.OTHER)
|
|
273
|
+
if isinstance(parent, exp.Join) and current.arg_key in ("on", "using"):
|
|
274
|
+
return Clause.JOIN
|
|
275
|
+
current = parent
|
|
276
|
+
return Clause.OTHER
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _aggregate_of(node: exp.Expr, scope_expr: exp.Expr) -> AggregateKind:
|
|
280
|
+
"""Classify the innermost aggregate around ``node`` within its scope."""
|
|
281
|
+
current = node.parent
|
|
282
|
+
while current is not None and current is not scope_expr:
|
|
283
|
+
if isinstance(current, exp.AggFunc):
|
|
284
|
+
if isinstance(current.parent, exp.Window):
|
|
285
|
+
# SUM(x) OVER (...) is computed per row and can reproduce single values.
|
|
286
|
+
return AggregateKind.VALUE_REVEALING
|
|
287
|
+
if isinstance(current, _STATISTICAL_AGGS):
|
|
288
|
+
return AggregateKind.STATISTICAL
|
|
289
|
+
return AggregateKind.VALUE_REVEALING
|
|
290
|
+
if isinstance(current, (exp.Select, exp.Subquery)):
|
|
291
|
+
break
|
|
292
|
+
current = current.parent
|
|
293
|
+
return AggregateKind.NONE
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _json_path(column: exp.Column) -> tuple[str, ...] | None:
|
|
297
|
+
"""Return the JSON path read from ``column``, or None if the whole value is read."""
|
|
298
|
+
keys: list[str] = []
|
|
299
|
+
node: exp.Expr = column
|
|
300
|
+
parent = node.parent
|
|
301
|
+
while isinstance(parent, _JSON_EXTRACT_TYPES) and parent.this is node:
|
|
302
|
+
path = parent.expression
|
|
303
|
+
if isinstance(path, exp.JSONPath):
|
|
304
|
+
for part in path.expressions:
|
|
305
|
+
if isinstance(part, exp.JSONPathKey):
|
|
306
|
+
keys.append(str(part.this).lower())
|
|
307
|
+
elif isinstance(path, exp.Literal) and path.is_string:
|
|
308
|
+
text = str(path.this).strip()
|
|
309
|
+
if text.startswith("{") and text.endswith("}"):
|
|
310
|
+
keys.extend(k.strip().strip('"').lower() for k in text[1:-1].split(",") if k)
|
|
311
|
+
elif text.startswith("$"):
|
|
312
|
+
keys.extend(k.lower() for k in text.lstrip("$").split(".") if k)
|
|
313
|
+
else:
|
|
314
|
+
keys.append(text.lower())
|
|
315
|
+
elif isinstance(path, exp.Literal):
|
|
316
|
+
pass # numeric array index: the key path is unchanged
|
|
317
|
+
else:
|
|
318
|
+
return tuple(keys) if keys else None # dynamic key: caller treats as whole value
|
|
319
|
+
node = parent
|
|
320
|
+
parent = node.parent
|
|
321
|
+
return tuple(keys) if keys else None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _table_name(table: exp.Table) -> tuple[str, str | None]:
|
|
325
|
+
# Names are already normalised per dialect by qualify(); lower-casing again would
|
|
326
|
+
# erase the case of quoted identifiers that PostgreSQL treats as distinct.
|
|
327
|
+
return table.name, (table.db or None)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _iter_scope_columns(scope: Scope) -> Iterator[exp.Column]:
|
|
331
|
+
yield from scope.find_all(exp.Column)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def analyse(sql: str, dialect: str, catalog: Catalog | None, *, max_nodes: int) -> QueryAnalysis:
|
|
335
|
+
"""Parse and analyse ``sql``. Never raises for malformed input."""
|
|
336
|
+
result = QueryAnalysis(sql=sql, dialect=dialect)
|
|
337
|
+
try:
|
|
338
|
+
result.statements = parse_statements(sql, dialect)
|
|
339
|
+
except (ParseError, TokenError) as exc:
|
|
340
|
+
result.parse_error = str(exc).splitlines()[0][:300]
|
|
341
|
+
return result
|
|
342
|
+
except SqlglotError as exc: # pragma: no cover - defensive
|
|
343
|
+
result.parse_error = f"{type(exc).__name__}: {exc}"[:300]
|
|
344
|
+
return result
|
|
345
|
+
if len(result.statements) != 1:
|
|
346
|
+
return result
|
|
347
|
+
|
|
348
|
+
root = _unwrap_root(result.statements[0])
|
|
349
|
+
result.root = root
|
|
350
|
+
result.node_count = sum(1 for _ in root.walk())
|
|
351
|
+
if result.node_count > max_nodes:
|
|
352
|
+
return result
|
|
353
|
+
if not isinstance(root, (exp.Select, exp.SetOperation)):
|
|
354
|
+
return result
|
|
355
|
+
|
|
356
|
+
# Function calls are collected from the unqualified tree so that every call is seen,
|
|
357
|
+
# including those in positions the qualifier rewrites.
|
|
358
|
+
for func in root.find_all(exp.Func):
|
|
359
|
+
result.functions.append(function_names(func))
|
|
360
|
+
|
|
361
|
+
schema: dict[str, object] = dict(catalog.sqlglot_schema()) if catalog else {}
|
|
362
|
+
try:
|
|
363
|
+
qualified = qualify(
|
|
364
|
+
root.copy(),
|
|
365
|
+
schema=schema or None,
|
|
366
|
+
dialect=dialect,
|
|
367
|
+
validate_qualify_columns=False,
|
|
368
|
+
quote_identifiers=False,
|
|
369
|
+
identify=False,
|
|
370
|
+
)
|
|
371
|
+
except (OptimizeError, SqlglotError, KeyError, ValueError) as exc:
|
|
372
|
+
result.qualify_error = f"{type(exc).__name__}: {exc}"[:300]
|
|
373
|
+
return result
|
|
374
|
+
result.qualified = qualified
|
|
375
|
+
_collect_scopes(result, qualified, catalog)
|
|
376
|
+
return result
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _collect_scopes(result: QueryAnalysis, qualified: exp.Expr, catalog: Catalog | None) -> None:
|
|
380
|
+
scopes = traverse_scope(qualified)
|
|
381
|
+
scope_ids = {id(s): i for i, s in enumerate(scopes)}
|
|
382
|
+
seen_tables: set[int] = set()
|
|
383
|
+
|
|
384
|
+
def depth_of(scope: Scope) -> int:
|
|
385
|
+
d = 0
|
|
386
|
+
parent = scope.parent
|
|
387
|
+
while parent is not None:
|
|
388
|
+
d += 1
|
|
389
|
+
parent = parent.parent
|
|
390
|
+
return d
|
|
391
|
+
|
|
392
|
+
for scope in scopes:
|
|
393
|
+
sid = scope_ids[id(scope)]
|
|
394
|
+
facts = ScopeFacts(scope_id=sid, expression=scope.expression, depth=depth_of(scope))
|
|
395
|
+
result.max_depth = max(result.max_depth, facts.depth)
|
|
396
|
+
if isinstance(scope.expression, (exp.Select, exp.SetOperation)):
|
|
397
|
+
facts.has_limit, facts.limit, facts.limit_unparseable = _limit_value(scope.expression)
|
|
398
|
+
if isinstance(scope.expression, exp.Select):
|
|
399
|
+
result.join_count += len(scope.expression.args.get("joins") or [])
|
|
400
|
+
|
|
401
|
+
for alias, source in scope.sources.items():
|
|
402
|
+
if not isinstance(source, exp.Table):
|
|
403
|
+
continue
|
|
404
|
+
seen_tables.add(id(source))
|
|
405
|
+
if isinstance(source.this, exp.Identifier) and _is_cte_name(source):
|
|
406
|
+
continue # a CTE whose body is not a SELECT (e.g. DELETE ... RETURNING)
|
|
407
|
+
if not isinstance(source.this, exp.Identifier):
|
|
408
|
+
func = source.this
|
|
409
|
+
fname = func.name.lower() if isinstance(func, exp.Func) else str(func).lower()
|
|
410
|
+
ref = TableRef(
|
|
411
|
+
name=fname,
|
|
412
|
+
schema=None,
|
|
413
|
+
alias=alias,
|
|
414
|
+
scope_id=sid,
|
|
415
|
+
is_function=True,
|
|
416
|
+
function_name=fname,
|
|
417
|
+
)
|
|
418
|
+
else:
|
|
419
|
+
name, schema_name = _table_name(source)
|
|
420
|
+
ref = TableRef(name=name, schema=schema_name, alias=alias, scope_id=sid)
|
|
421
|
+
facts.tables.append(ref)
|
|
422
|
+
facts.alias_to_table[alias] = ref
|
|
423
|
+
result.tables.append(ref)
|
|
424
|
+
result.scopes.append(facts)
|
|
425
|
+
|
|
426
|
+
# Second pass: columns (needs every scope's alias map for correlated references).
|
|
427
|
+
by_scope = {id(s): result.scopes[scope_ids[id(s)]] for s in scopes}
|
|
428
|
+
for scope in scopes:
|
|
429
|
+
facts = by_scope[id(scope)]
|
|
430
|
+
for column in _iter_scope_columns(scope):
|
|
431
|
+
_record_column(result, scope, facts, column, by_scope, catalog)
|
|
432
|
+
for node in scope.find_all(exp.TableColumn):
|
|
433
|
+
alias = node.name
|
|
434
|
+
row_ref = _resolve_alias(scope, alias, by_scope)
|
|
435
|
+
if row_ref is None:
|
|
436
|
+
continue
|
|
437
|
+
_expand_whole_row(result, row_ref, facts, node, scope, catalog, from_star=False)
|
|
438
|
+
for star_owner in _unexpanded_stars(scope):
|
|
439
|
+
star_ref = _resolve_alias(scope, star_owner, by_scope) if star_owner else None
|
|
440
|
+
label = f"{star_owner}.*" if star_owner else "*"
|
|
441
|
+
if star_ref is not None and catalog is not None and star_ref.name in catalog.tables:
|
|
442
|
+
_expand_whole_row(result, star_ref, facts, scope.expression, scope, catalog, True)
|
|
443
|
+
else:
|
|
444
|
+
result.unexpandable_stars.append(label)
|
|
445
|
+
|
|
446
|
+
# Catch-all: base tables in positions no scope claimed (fail closed by recording them).
|
|
447
|
+
for table in qualified.find_all(exp.Table):
|
|
448
|
+
if id(table) in seen_tables or not isinstance(table.this, exp.Identifier):
|
|
449
|
+
continue
|
|
450
|
+
if _is_cte_name(table):
|
|
451
|
+
continue
|
|
452
|
+
name, schema_name = _table_name(table)
|
|
453
|
+
result.tables.append(
|
|
454
|
+
TableRef(name=name, schema=schema_name, alias=table.alias_or_name, scope_id=-1)
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _is_cte_name(table: exp.Table) -> bool:
|
|
459
|
+
if table.db:
|
|
460
|
+
return False
|
|
461
|
+
node: exp.Expr | None = table.parent
|
|
462
|
+
while node is not None:
|
|
463
|
+
with_ = node.args.get("with_") or node.args.get("with")
|
|
464
|
+
if isinstance(with_, exp.With) and any(
|
|
465
|
+
cte.alias_or_name.lower() == table.name.lower() for cte in with_.expressions
|
|
466
|
+
):
|
|
467
|
+
return True
|
|
468
|
+
node = node.parent
|
|
469
|
+
return False
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _resolve_alias(scope: Scope, alias: str, by_scope: dict[int, ScopeFacts]) -> TableRef | None:
|
|
473
|
+
current: Scope | None = scope
|
|
474
|
+
while current is not None:
|
|
475
|
+
facts = by_scope.get(id(current))
|
|
476
|
+
if facts is not None and alias in facts.alias_to_table:
|
|
477
|
+
return facts.alias_to_table[alias]
|
|
478
|
+
if alias in current.sources:
|
|
479
|
+
return None # derived table / CTE: its own scope is checked separately
|
|
480
|
+
current = current.parent
|
|
481
|
+
return None
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _unexpanded_stars(scope: Scope) -> Iterator[str]:
|
|
485
|
+
if not isinstance(scope.expression, exp.Select):
|
|
486
|
+
return
|
|
487
|
+
for projection in scope.expression.expressions:
|
|
488
|
+
if isinstance(projection, exp.Star):
|
|
489
|
+
yield ""
|
|
490
|
+
elif isinstance(projection, exp.Column) and isinstance(projection.this, exp.Star):
|
|
491
|
+
yield projection.table
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _expand_whole_row(
|
|
495
|
+
result: QueryAnalysis,
|
|
496
|
+
ref: TableRef,
|
|
497
|
+
facts: ScopeFacts,
|
|
498
|
+
node: exp.Expr,
|
|
499
|
+
scope: Scope,
|
|
500
|
+
catalog: Catalog | None,
|
|
501
|
+
from_star: bool,
|
|
502
|
+
) -> None:
|
|
503
|
+
if catalog is None or ref.name not in catalog.tables:
|
|
504
|
+
result.unexpandable_stars.append(f"{ref.alias}.*")
|
|
505
|
+
return
|
|
506
|
+
clause = _clause_of(node, scope.expression) if node is not scope.expression else Clause.SELECT
|
|
507
|
+
aggregate = (
|
|
508
|
+
_aggregate_of(node, scope.expression)
|
|
509
|
+
if node is not scope.expression
|
|
510
|
+
else AggregateKind.NONE
|
|
511
|
+
)
|
|
512
|
+
for col_name in catalog.tables[ref.name].columns:
|
|
513
|
+
result.columns.append(
|
|
514
|
+
ColumnRef(
|
|
515
|
+
table=ref.name,
|
|
516
|
+
column=col_name,
|
|
517
|
+
clause=clause,
|
|
518
|
+
aggregate=aggregate,
|
|
519
|
+
scope_id=facts.scope_id,
|
|
520
|
+
whole_row=True,
|
|
521
|
+
from_star=from_star,
|
|
522
|
+
)
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _record_column(
|
|
527
|
+
result: QueryAnalysis,
|
|
528
|
+
scope: Scope,
|
|
529
|
+
facts: ScopeFacts,
|
|
530
|
+
column: exp.Column,
|
|
531
|
+
by_scope: dict[int, ScopeFacts],
|
|
532
|
+
catalog: Catalog | None,
|
|
533
|
+
) -> None:
|
|
534
|
+
if isinstance(column.this, exp.Star):
|
|
535
|
+
return # handled by star expansion
|
|
536
|
+
alias = column.table
|
|
537
|
+
name = column.name # normalised by qualify()
|
|
538
|
+
if not alias:
|
|
539
|
+
# An unqualified name the qualifier could not attribute. It may be a select-list
|
|
540
|
+
# alias (ORDER BY total); those are harmless and resolved here.
|
|
541
|
+
# Only references *outside* the select list can point at a select alias; a bare
|
|
542
|
+
# name in the select list that the qualifier left unqualified is unknown.
|
|
543
|
+
if _clause_of(column, scope.expression) is not Clause.SELECT and _is_projection_alias(
|
|
544
|
+
scope, name
|
|
545
|
+
):
|
|
546
|
+
return
|
|
547
|
+
result.unresolved_columns.append(name)
|
|
548
|
+
return
|
|
549
|
+
ref = _resolve_alias(scope, alias, by_scope)
|
|
550
|
+
if ref is None:
|
|
551
|
+
if not _alias_is_derived(scope, alias):
|
|
552
|
+
result.unresolved_columns.append(f"{alias}.{name}")
|
|
553
|
+
return
|
|
554
|
+
if ref.is_function:
|
|
555
|
+
return
|
|
556
|
+
result.columns.append(
|
|
557
|
+
ColumnRef(
|
|
558
|
+
table=ref.name,
|
|
559
|
+
column=name,
|
|
560
|
+
clause=_clause_of(column, scope.expression),
|
|
561
|
+
aggregate=_aggregate_of(column, scope.expression),
|
|
562
|
+
scope_id=facts.scope_id,
|
|
563
|
+
json_path=_json_path(column),
|
|
564
|
+
)
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _is_projection_alias(scope: Scope, name: str) -> bool:
|
|
569
|
+
expr = scope.expression
|
|
570
|
+
if not isinstance(expr, exp.Select):
|
|
571
|
+
return False
|
|
572
|
+
return any(isinstance(p, exp.Alias) and p.alias.lower() == name for p in expr.expressions)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _alias_is_derived(scope: Scope, alias: str) -> bool:
|
|
576
|
+
current: Scope | None = scope
|
|
577
|
+
while current is not None:
|
|
578
|
+
if alias in current.sources:
|
|
579
|
+
return not isinstance(current.sources[alias], exp.Table)
|
|
580
|
+
current = current.parent
|
|
581
|
+
return False
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Structured diagnostics produced by policy rules and combined into a decision."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Severity(StrEnum):
|
|
12
|
+
"""How serious a finding is. Independent of the action taken."""
|
|
13
|
+
|
|
14
|
+
INFO = "info"
|
|
15
|
+
LOW = "low"
|
|
16
|
+
MEDIUM = "medium"
|
|
17
|
+
HIGH = "high"
|
|
18
|
+
CRITICAL = "critical"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Action(StrEnum):
|
|
22
|
+
"""What a single finding asks the guard to do."""
|
|
23
|
+
|
|
24
|
+
DENY = "deny"
|
|
25
|
+
REVIEW = "review"
|
|
26
|
+
WARN = "warn"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Decision(StrEnum):
|
|
30
|
+
"""The combined verdict for one SQL string."""
|
|
31
|
+
|
|
32
|
+
ALLOW = "allow"
|
|
33
|
+
REVIEW = "review"
|
|
34
|
+
DENY = "deny"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Category(StrEnum):
|
|
38
|
+
"""Tool-agnostic finding categories, shared with the benchmark taxonomy."""
|
|
39
|
+
|
|
40
|
+
PARSE = "parse"
|
|
41
|
+
STATEMENT = "statement"
|
|
42
|
+
CATALOG = "catalog"
|
|
43
|
+
FUNCTION = "function"
|
|
44
|
+
TABLE_ACCESS = "table_access"
|
|
45
|
+
COLUMN_ACCESS = "column_access"
|
|
46
|
+
PRIVACY = "privacy"
|
|
47
|
+
TENANT = "tenant"
|
|
48
|
+
COST = "cost"
|
|
49
|
+
STRUCTURE = "structure"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Finding(BaseModel):
|
|
53
|
+
"""One observation made by a rule.
|
|
54
|
+
|
|
55
|
+
``message`` is safe to show to the caller (including an LLM agent). ``detail`` may name
|
|
56
|
+
schema objects and is meant for audit logs and operators; integrations decide whether
|
|
57
|
+
to disclose it based on ``Policy.disclosure``.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
model_config = ConfigDict(frozen=True)
|
|
61
|
+
|
|
62
|
+
rule_id: str
|
|
63
|
+
category: Category
|
|
64
|
+
severity: Severity
|
|
65
|
+
action: Action
|
|
66
|
+
message: str
|
|
67
|
+
detail: str | None = None
|
|
68
|
+
table: str | None = None
|
|
69
|
+
column: str | None = None
|
|
70
|
+
labels: tuple[str, ...] = ()
|
|
71
|
+
remediation: str | None = None
|
|
72
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_ACTION_RANK = {Action.WARN: 0, Action.REVIEW: 1, Action.DENY: 2}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def combine(findings: list[Finding]) -> Decision:
|
|
79
|
+
"""Combine findings: any deny wins, then any review, otherwise allow."""
|
|
80
|
+
worst = max((_ACTION_RANK[f.action] for f in findings), default=0)
|
|
81
|
+
if worst == 2:
|
|
82
|
+
return Decision.DENY
|
|
83
|
+
if worst == 1:
|
|
84
|
+
return Decision.REVIEW
|
|
85
|
+
return Decision.ALLOW
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ValidationResult(BaseModel):
|
|
89
|
+
"""Outcome of :meth:`sqlguard_ninja.Guard.validate`."""
|
|
90
|
+
|
|
91
|
+
model_config = ConfigDict(frozen=True)
|
|
92
|
+
|
|
93
|
+
decision: Decision
|
|
94
|
+
findings: tuple[Finding, ...]
|
|
95
|
+
sql_sha256: str
|
|
96
|
+
dialect: str
|
|
97
|
+
role: str | None
|
|
98
|
+
policy_name: str
|
|
99
|
+
policy_version: int
|
|
100
|
+
tables: tuple[str, ...] = ()
|
|
101
|
+
columns: tuple[str, ...] = ()
|
|
102
|
+
elapsed_ms: float = 0.0
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def allowed(self) -> bool:
|
|
106
|
+
"""True only for an unconditional ALLOW."""
|
|
107
|
+
return self.decision is Decision.ALLOW
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def requires_review(self) -> bool:
|
|
111
|
+
return self.decision is Decision.REVIEW
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def denied(self) -> bool:
|
|
115
|
+
return self.decision is Decision.DENY
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def blocking_findings(self) -> tuple[Finding, ...]:
|
|
119
|
+
return tuple(f for f in self.findings if f.action is not Action.WARN)
|
|
120
|
+
|
|
121
|
+
def explain(self, *, include_detail: bool = False) -> str:
|
|
122
|
+
"""Human-readable explanation, one line per finding."""
|
|
123
|
+
if not self.findings:
|
|
124
|
+
return f"{self.decision.value.upper()}: no policy findings."
|
|
125
|
+
lines = [f"{self.decision.value.upper()}:"]
|
|
126
|
+
for f in self.findings:
|
|
127
|
+
line = f" [{f.action.value}] {f.rule_id}: {f.message}"
|
|
128
|
+
if include_detail and f.detail:
|
|
129
|
+
line += f" ({f.detail})"
|
|
130
|
+
if f.remediation:
|
|
131
|
+
line += f" Suggestion: {f.remediation}"
|
|
132
|
+
lines.append(line)
|
|
133
|
+
return "\n".join(lines)
|