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.
Files changed (68) hide show
  1. sqlguard_ninja/__init__.py +35 -0
  2. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/01_destructive.yaml +169 -0
  3. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/02_multi_statement_and_formatting.yaml +155 -0
  4. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/03_table_and_column_access.yaml +205 -0
  5. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/04_privacy.yaml +245 -0
  6. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/05_tenant_isolation.yaml +167 -0
  7. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/06_cte_nested_unicode.yaml +168 -0
  8. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/07_legitimate_lookalike_cost_dialect.yaml +243 -0
  9. sqlguard_ninja/_data/benchmarks/datasets/v1/generated/grant_matrix.yaml +573 -0
  10. sqlguard_ninja/_data/benchmarks/datasets/v1/generated/label_matrix.yaml +403 -0
  11. sqlguard_ninja/_data/benchmarks/datasets/v1/generated/tenant_matrix.yaml +684 -0
  12. sqlguard_ninja/_data/benchmarks/datasets/v1/generated/transforms.yaml +927 -0
  13. sqlguard_ninja/_data/benchmarks/datasets/v1/manifest.yaml +15 -0
  14. sqlguard_ninja/_data/benchmarks/policies/retail_saas.yaml +259 -0
  15. sqlguard_ninja/_version.py +1 -0
  16. sqlguard_ninja/benchmarking/__init__.py +1 -0
  17. sqlguard_ninja/benchmarking/adapters.py +303 -0
  18. sqlguard_ninja/benchmarking/builtin.py +39 -0
  19. sqlguard_ninja/benchmarking/compare.py +168 -0
  20. sqlguard_ninja/benchmarking/dataset.py +265 -0
  21. sqlguard_ninja/benchmarking/fixtures.py +274 -0
  22. sqlguard_ninja/benchmarking/metrics.py +114 -0
  23. sqlguard_ninja/benchmarking/runner.py +126 -0
  24. sqlguard_ninja/benchmarking/translate.py +64 -0
  25. sqlguard_ninja/benchmarking/workers/_protocol.py +53 -0
  26. sqlguard_ninja/benchmarking/workers/agent_sql_guard_worker.py +40 -0
  27. sqlguard_ninja/benchmarking/workers/krish_sqlguard_worker.py +25 -0
  28. sqlguard_ninja/benchmarking/workers/sql_guardrail_worker.py +48 -0
  29. sqlguard_ninja/cli/__init__.py +1 -0
  30. sqlguard_ninja/cli/config.py +76 -0
  31. sqlguard_ninja/cli/main.py +433 -0
  32. sqlguard_ninja/cli/templates.py +91 -0
  33. sqlguard_ninja/core/__init__.py +1 -0
  34. sqlguard_ninja/core/analysis.py +581 -0
  35. sqlguard_ninja/core/findings.py +133 -0
  36. sqlguard_ninja/core/guard.py +256 -0
  37. sqlguard_ninja/core/rules/__init__.py +1 -0
  38. sqlguard_ninja/core/rules/access.py +127 -0
  39. sqlguard_ninja/core/rules/base.py +40 -0
  40. sqlguard_ninja/core/rules/catalog.py +61 -0
  41. sqlguard_ninja/core/rules/functions.py +197 -0
  42. sqlguard_ninja/core/rules/statements.py +99 -0
  43. sqlguard_ninja/core/rules/structure.py +72 -0
  44. sqlguard_ninja/core/rules/tenant.py +196 -0
  45. sqlguard_ninja/integrations/__init__.py +43 -0
  46. sqlguard_ninja/integrations/audit.py +109 -0
  47. sqlguard_ninja/integrations/checkpoint.py +342 -0
  48. sqlguard_ninja/integrations/executors.py +64 -0
  49. sqlguard_ninja/integrations/fastapi.py +89 -0
  50. sqlguard_ninja/integrations/langchain.py +67 -0
  51. sqlguard_ninja/integrations/langgraph.py +87 -0
  52. sqlguard_ninja/policy/__init__.py +39 -0
  53. sqlguard_ninja/policy/model.py +370 -0
  54. sqlguard_ninja/privacy/__init__.py +9 -0
  55. sqlguard_ninja/privacy/rules.py +286 -0
  56. sqlguard_ninja/privacy/suggest.py +46 -0
  57. sqlguard_ninja/py.typed +0 -0
  58. sqlguard_ninja/query_cost/__init__.py +1 -0
  59. sqlguard_ninja/query_cost/postgres.py +226 -0
  60. sqlguard_ninja/query_cost/static.py +241 -0
  61. sqlguard_ninja/reporting/__init__.py +1 -0
  62. sqlguard_ninja/reporting/markdown.py +137 -0
  63. sqlguard_ninja-0.1.0.dist-info/METADATA +370 -0
  64. sqlguard_ninja-0.1.0.dist-info/RECORD +68 -0
  65. sqlguard_ninja-0.1.0.dist-info/WHEEL +4 -0
  66. sqlguard_ninja-0.1.0.dist-info/entry_points.txt +2 -0
  67. sqlguard_ninja-0.1.0.dist-info/licenses/LICENSE +202 -0
  68. 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)