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,46 @@
1
+ """Name-based classification *suggestions* for humans to review.
2
+
3
+ The guard never uses these heuristics to enforce anything: column names are unreliable
4
+ (``notes`` may hold health data, ``email`` may hold a hash). They exist to speed up writing a
5
+ first policy, which a data owner must then confirm.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass
12
+
13
+ from sqlguard_ninja.policy.model import Catalog
14
+
15
+ _PATTERNS: list[tuple[str, re.Pattern[str]]] = [
16
+ ("payment_card", re.compile(r"(card_?(number|no|num)|pan|cc_?num)")),
17
+ ("financial", re.compile(r"(iban|bank|account_?number|salary|wage|income)")),
18
+ ("government_id", re.compile(r"(ssn|national_?id|passport|tax_?id|nin\b)")),
19
+ ("contact", re.compile(r"(e_?mail|phone|mobile|address|postcode|zip)")),
20
+ ("personal_identifier", re.compile(r"(full_?name|first_?name|last_?name|birth|dob)")),
21
+ ("special_category", re.compile(r"(health|medical|diagnos|religio|ethnic|biometric)")),
22
+ ("credentials", re.compile(r"(password|passwd|secret|token|api_?key)")),
23
+ ]
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Suggestion:
28
+ table: str
29
+ column: str
30
+ label: str
31
+ matched: str
32
+
33
+
34
+ def suggest_labels(catalog: Catalog) -> list[Suggestion]:
35
+ """Suggest labels for unlabelled columns whose names match common patterns."""
36
+ out: list[Suggestion] = []
37
+ for tname, table in catalog.tables.items():
38
+ for cname, col in table.columns.items():
39
+ if col.labels:
40
+ continue
41
+ for label, pattern in _PATTERNS:
42
+ m = pattern.search(cname)
43
+ if m:
44
+ out.append(Suggestion(tname, cname, label, m.group(0)))
45
+ break
46
+ return out
File without changes
@@ -0,0 +1 @@
1
+ """query_cost package."""
@@ -0,0 +1,226 @@
1
+ """Optional PostgreSQL execution-plan inspection.
2
+
3
+ Safety rules (see ADR-007):
4
+
5
+ * Only ``EXPLAIN (FORMAT JSON)`` is run — never ``EXPLAIN ANALYZE``, which executes the query.
6
+ * A statement is inspected only after :class:`~sqlguard_ninja.Guard` ALLOWed it. Planning can
7
+ still evaluate immutable functions during constant folding, which is why the guard's
8
+ function policy must run first.
9
+ * The EXPLAIN runs in a ``READ ONLY`` transaction with ``statement_timeout`` set locally,
10
+ and the transaction is always rolled back.
11
+ * Connect with a low-privilege role. Never inspect untrusted SQL with privileged credentials.
12
+
13
+ Planner estimates are not guarantees of runtime or resource use.
14
+
15
+ Requires ``pip install "sqlguard-ninja[postgres]"``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from collections.abc import Callable, Iterator, Mapping
22
+ from dataclasses import dataclass, field
23
+ from typing import Any
24
+
25
+ from sqlguard_ninja.core.analysis import analyse
26
+ from sqlguard_ninja.core.findings import Action, Category, Decision, Finding, Severity
27
+ from sqlguard_ninja.core.guard import Guard
28
+ from sqlguard_ninja.query_cost.static import StaticCostReport, analyse_static_cost
29
+
30
+
31
+ class PlanInspectionRefused(PermissionError):
32
+ """The statement was not allowed by the guard, so it is not sent to the planner."""
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class PlanNodeSummary:
37
+ node_type: str
38
+ relation: str | None
39
+ plan_rows: float
40
+ total_cost: float
41
+
42
+
43
+ @dataclass
44
+ class PlanReport:
45
+ total_cost: float
46
+ plan_rows: float
47
+ nodes: list[PlanNodeSummary]
48
+ findings: list[Finding] = field(default_factory=list)
49
+ static: StaticCostReport | None = None
50
+ raw_plan: Any = None
51
+
52
+ @property
53
+ def decision(self) -> Decision:
54
+ actions = {f.action for f in self.findings}
55
+ if Action.DENY in actions:
56
+ return Decision.DENY
57
+ if Action.REVIEW in actions:
58
+ return Decision.REVIEW
59
+ return Decision.ALLOW
60
+
61
+ def as_dict(self) -> dict[str, Any]:
62
+ return {
63
+ "decision": self.decision.value,
64
+ "planner": {"total_cost": self.total_cost, "plan_rows": self.plan_rows},
65
+ "static": self.static.as_dict() if self.static else None,
66
+ "nodes": [n.__dict__ for n in self.nodes],
67
+ "findings": [f.model_dump(mode="json") for f in self.findings],
68
+ }
69
+
70
+
71
+ def _walk(plan: Mapping[str, Any]) -> Iterator[Mapping[str, Any]]:
72
+ yield plan
73
+ for child in plan.get("Plans", ()) or ():
74
+ yield from _walk(child)
75
+
76
+
77
+ def summarise_plan(explain_json: Any) -> tuple[float, float, list[PlanNodeSummary]]:
78
+ """Extract total cost, estimated rows and per-node summaries from EXPLAIN JSON output."""
79
+ doc = explain_json[0] if isinstance(explain_json, list) else explain_json
80
+ root = doc["Plan"]
81
+ nodes = [
82
+ PlanNodeSummary(
83
+ node_type=str(n.get("Node Type")),
84
+ relation=n.get("Relation Name"),
85
+ plan_rows=float(n.get("Plan Rows", 0)),
86
+ total_cost=float(n.get("Total Cost", 0)),
87
+ )
88
+ for n in _walk(root)
89
+ ]
90
+ return float(root.get("Total Cost", 0)), float(root.get("Plan Rows", 0)), nodes
91
+
92
+
93
+ def plan_findings(
94
+ total_cost: float,
95
+ plan_rows: float,
96
+ nodes: list[PlanNodeSummary],
97
+ *,
98
+ max_total_cost: float | None,
99
+ max_plan_rows: float | None,
100
+ large_table_rows: int,
101
+ ) -> list[Finding]:
102
+ findings: list[Finding] = []
103
+ if max_total_cost is not None and total_cost > max_total_cost:
104
+ findings.append(
105
+ Finding(
106
+ rule_id="cost.planner_total_cost",
107
+ category=Category.COST,
108
+ severity=Severity.HIGH,
109
+ action=Action.REVIEW,
110
+ message=f"Planner cost estimate {total_cost:,.0f} exceeds the limit "
111
+ f"{max_total_cost:,.0f}. Estimates are not guarantees.",
112
+ metadata={"total_cost": total_cost},
113
+ )
114
+ )
115
+ if max_plan_rows is not None and plan_rows > max_plan_rows:
116
+ findings.append(
117
+ Finding(
118
+ rule_id="cost.planner_rows",
119
+ category=Category.COST,
120
+ severity=Severity.MEDIUM,
121
+ action=Action.REVIEW,
122
+ message=f"Planner expects {plan_rows:,.0f} result rows; the limit is "
123
+ f"{max_plan_rows:,.0f}.",
124
+ metadata={"plan_rows": plan_rows},
125
+ )
126
+ )
127
+ for node in nodes:
128
+ if node.node_type == "Seq Scan" and node.plan_rows >= large_table_rows:
129
+ findings.append(
130
+ Finding(
131
+ rule_id="cost.planner_seq_scan",
132
+ category=Category.COST,
133
+ severity=Severity.LOW,
134
+ action=Action.WARN,
135
+ message=f"Sequential scan of {node.relation} (~{node.plan_rows:,.0f} rows).",
136
+ table=node.relation,
137
+ )
138
+ )
139
+ if node.node_type == "Nested Loop" and node.plan_rows >= large_table_rows:
140
+ findings.append(
141
+ Finding(
142
+ rule_id="cost.planner_nested_loop",
143
+ category=Category.COST,
144
+ severity=Severity.MEDIUM,
145
+ action=Action.WARN,
146
+ message=f"Nested loop producing ~{node.plan_rows:,.0f} rows.",
147
+ )
148
+ )
149
+ return findings
150
+
151
+
152
+ ConnectionFactory = Callable[[], Any]
153
+
154
+
155
+ class PostgresPlanInspector:
156
+ """Run guarded, non-executing ``EXPLAIN`` and compare planner and static estimates."""
157
+
158
+ def __init__(
159
+ self,
160
+ guard: Guard,
161
+ connect: ConnectionFactory,
162
+ *,
163
+ statement_timeout_ms: int = 2000,
164
+ ) -> None:
165
+ self.guard = guard
166
+ self.connect = connect
167
+ self.statement_timeout_ms = statement_timeout_ms
168
+
169
+ @classmethod
170
+ def from_conninfo(
171
+ cls, guard: Guard, conninfo: str, **kwargs: Any
172
+ ) -> PostgresPlanInspector: # pragma: no cover - thin wrapper
173
+ import psycopg # noqa: PLC0415 - optional dependency
174
+
175
+ return cls(guard, lambda: psycopg.connect(conninfo), **kwargs)
176
+
177
+ def inspect(
178
+ self,
179
+ sql: str,
180
+ *,
181
+ role: str | None,
182
+ context: Mapping[str, Any] | None = None,
183
+ params: Mapping[str, Any] | None = None,
184
+ ) -> PlanReport:
185
+ """Validate, then EXPLAIN ``sql``. ``params`` are bound client-side by psycopg."""
186
+ result = self.guard.validate(sql, role=role, dialect="postgres", context=context)
187
+ if result.decision is not Decision.ALLOW:
188
+ raise PlanInspectionRefused(
189
+ f"guard decision is {result.decision.value}; only allowed statements are explained"
190
+ )
191
+ policy = self.guard.policy
192
+ analysis = analyse(sql, "postgres", policy.catalog, max_nodes=policy.limits.max_ast_nodes)
193
+ static = analyse_static_cost(analysis, policy)
194
+ explain_json = self._explain(sql, params or {})
195
+ total, rows, nodes = summarise_plan(explain_json)
196
+ findings = plan_findings(
197
+ total,
198
+ rows,
199
+ nodes,
200
+ max_total_cost=policy.cost.max_plan_total_cost,
201
+ max_plan_rows=policy.cost.max_plan_rows,
202
+ large_table_rows=policy.cost.large_table_rows,
203
+ )
204
+ return PlanReport(total, rows, nodes, findings, static, explain_json)
205
+
206
+ def _explain(self, sql: str, params: Mapping[str, Any]) -> Any:
207
+ import psycopg # noqa: PLC0415 - optional dependency
208
+
209
+ conn = self.connect()
210
+ try:
211
+ with conn.transaction(force_rollback=True):
212
+ conn.execute("SET TRANSACTION READ ONLY")
213
+ conn.execute(
214
+ "SELECT set_config('statement_timeout', %s, true)",
215
+ (str(self.statement_timeout_ms),),
216
+ )
217
+ # Client-side binding: EXPLAIN cannot take server-side parameters.
218
+ cur = psycopg.ClientCursor(conn)
219
+ cur.execute("EXPLAIN (FORMAT JSON) " + sql, dict(params) or None)
220
+ row = cur.fetchone()
221
+ finally:
222
+ conn.close()
223
+ if row is None: # pragma: no cover - EXPLAIN always returns one row
224
+ raise RuntimeError("EXPLAIN returned no rows")
225
+ value = row[0]
226
+ return json.loads(value) if isinstance(value, str) else value
@@ -0,0 +1,241 @@
1
+ """Static, explainable query-cost heuristics.
2
+
3
+ The score is a sum of documented factor weights, capped at 100. It is a *risk indicator* for
4
+ gating and review, not a prediction of runtime. Row estimates come from the policy catalog
5
+ (``estimated_rows``); without them, size-dependent factors are skipped and reported as such.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterator
11
+ from dataclasses import dataclass, field
12
+
13
+ from sqlglot import exp
14
+
15
+ from sqlguard_ninja.core.analysis import QueryAnalysis, ScopeFacts
16
+ from sqlguard_ninja.core.findings import Action, Category, Finding, Severity
17
+ from sqlguard_ninja.core.rules.base import RuleContext
18
+ from sqlguard_ninja.policy.model import Policy
19
+
20
+ HUGE_LITERAL = 1_000_000
21
+
22
+ _GENERATOR_FUNCS = {"generate_series", "range", "sequence"}
23
+ _BLOWUP_FUNCS = {"repeat", "lpad", "rpad", "replicate", "space"}
24
+ _REGEX_TYPES: tuple[type[exp.Expr], ...] = tuple(
25
+ getattr(exp, n) for n in ("RegexpLike", "RegexpILike", "RegexpExtract") if hasattr(exp, n)
26
+ )
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class CostFactor:
31
+ code: str
32
+ points: int
33
+ explanation: str
34
+
35
+
36
+ @dataclass
37
+ class StaticCostReport:
38
+ score: int
39
+ factors: list[CostFactor] = field(default_factory=list)
40
+ unknown_row_estimates: list[str] = field(default_factory=list)
41
+
42
+ def as_dict(self) -> dict[str, object]:
43
+ return {
44
+ "score": self.score,
45
+ "factors": [f.__dict__ for f in self.factors],
46
+ "unknown_row_estimates": self.unknown_row_estimates,
47
+ }
48
+
49
+
50
+ def _int_literal(node: exp.Expr | None) -> int | None:
51
+ while isinstance(node, (exp.Paren, exp.Cast)):
52
+ node = node.this
53
+ if isinstance(node, exp.Literal) and not node.is_string:
54
+ try:
55
+ return int(float(str(node.this)))
56
+ except ValueError:
57
+ return None
58
+ return None
59
+
60
+
61
+ def _linked(select: exp.Select, left: str, right: str) -> bool:
62
+ """True if WHERE contains an equality between columns of the two aliases."""
63
+ where = select.args.get("where")
64
+ if where is None:
65
+ return False
66
+ for eq in where.find_all(exp.EQ):
67
+ tables = {c.table for c in eq.find_all(exp.Column)}
68
+ if left in tables and right in tables:
69
+ return True
70
+ return False
71
+
72
+
73
+ def analyse_static_cost(analysis: QueryAnalysis, policy: Policy) -> StaticCostReport:
74
+ factors: list[CostFactor] = []
75
+ unknown: set[str] = set()
76
+ large = policy.cost.large_table_rows
77
+ catalog = policy.catalog
78
+
79
+ def rows(name: str) -> int | None:
80
+ t = catalog.tables.get(name)
81
+ if t is None or t.estimated_rows is None:
82
+ unknown.add(name)
83
+ return None
84
+ return t.estimated_rows
85
+
86
+ root_has_limit = bool(analysis.scopes) and analysis.scopes[-1].has_limit
87
+ for facts in analysis.scopes:
88
+ factors.extend(_scope_factors(facts, rows, large, root_has_limit))
89
+
90
+ if analysis.join_count > 3:
91
+ factors.append(
92
+ CostFactor(
93
+ "many_joins",
94
+ 5 * (analysis.join_count - 3),
95
+ f"{analysis.join_count} joins; each join beyond three adds planning and "
96
+ "execution cost.",
97
+ )
98
+ )
99
+ if analysis.max_depth > 2:
100
+ factors.append(
101
+ CostFactor(
102
+ "deep_nesting",
103
+ 5 * (analysis.max_depth - 2),
104
+ f"subqueries nested {analysis.max_depth} levels deep",
105
+ )
106
+ )
107
+ root = analysis.qualified
108
+ if root is not None:
109
+ factors.extend(_expression_factors(root))
110
+ score = min(100, sum(f.points for f in factors))
111
+ return StaticCostReport(score=score, factors=factors, unknown_row_estimates=sorted(unknown))
112
+
113
+
114
+ def _scope_factors(
115
+ facts: ScopeFacts, rows: object, large: int, root_has_limit: bool
116
+ ) -> Iterator[CostFactor]:
117
+ assert callable(rows)
118
+ select = facts.expression
119
+ if not isinstance(select, exp.Select):
120
+ return
121
+ base = [t for t in facts.tables if not t.is_function]
122
+ sizes = {t.alias: rows(t.name) for t in base}
123
+ big = [a for a, n in sizes.items() if n is not None and n >= large]
124
+ has_where = select.args.get("where") is not None
125
+
126
+ joins: list[exp.Join] = list(select.args.get("joins") or [])
127
+ from_ = select.args.get("from_") or select.args.get("from")
128
+ first_alias = from_.this.alias_or_name if from_ is not None and from_.this else None
129
+ for join in joins:
130
+ alias = join.alias_or_name
131
+ kind = (join.kind or "").upper()
132
+ no_condition = not join.args.get("on") and not join.args.get("using")
133
+ if (kind == "CROSS" or no_condition) and not (
134
+ first_alias and _linked(select, first_alias, alias)
135
+ ):
136
+ n_left = sizes.get(first_alias or "")
137
+ n_right = sizes.get(alias)
138
+ weight = 40 if (n_left or 0) >= large or (n_right or 0) >= large else 25
139
+ yield CostFactor(
140
+ "cartesian_join",
141
+ weight,
142
+ f"join with {alias} has no join condition (Cartesian product)",
143
+ )
144
+ if len(big) >= 2 and any(sizes[a] is not None for a in big):
145
+ names = sorted({t.name for t in base if t.alias in big})
146
+ if len(names) < len(big):
147
+ yield CostFactor("large_self_join", 10, "a large table is joined with itself")
148
+
149
+ for alias in big:
150
+ if not has_where:
151
+ yield CostFactor(
152
+ "large_scan_no_filter",
153
+ 25,
154
+ f"{alias} has an estimated {sizes[alias]:,} rows and is read without a filter",
155
+ )
156
+ if big and not root_has_limit and facts.depth == 0:
157
+ yield CostFactor("no_limit_on_large", 10, "large result possible and no LIMIT")
158
+ if big and select.args.get("order") and not facts.has_limit:
159
+ yield CostFactor("sort_without_limit", 10, "sorting a large input without LIMIT")
160
+ if big and select.args.get("distinct"):
161
+ yield CostFactor("distinct_on_large", 5, "DISTINCT over a large input")
162
+ if big and has_where:
163
+ where = select.args["where"]
164
+ for like in where.find_all(exp.Like, exp.ILike):
165
+ pattern = like.expression
166
+ if isinstance(pattern, exp.Literal) and str(pattern.this).startswith("%"):
167
+ yield CostFactor(
168
+ "leading_wildcard", 10, "LIKE pattern starts with % and cannot use an index"
169
+ )
170
+ break
171
+ if _REGEX_TYPES and any(True for _ in where.find_all(*_REGEX_TYPES)):
172
+ yield CostFactor("regex_filter", 5, "regular-expression filter on a large table")
173
+ if big and any(isinstance(p, exp.Subquery) or p.find(exp.Subquery) for p in select.expressions):
174
+ yield CostFactor(
175
+ "correlated_select_subquery",
176
+ 10,
177
+ "subquery in the SELECT list may run once per row of a large table",
178
+ )
179
+
180
+
181
+ def _expression_factors(root: exp.Expr) -> Iterator[CostFactor]:
182
+ with_ = root.args.get("with_") or root.args.get("with")
183
+ if isinstance(with_, exp.With) and with_.args.get("recursive"):
184
+ yield CostFactor(
185
+ "recursive_cte", 30, "recursive CTE; termination cannot be verified statically"
186
+ )
187
+ for cte in with_.expressions:
188
+ body = cte.this
189
+ if isinstance(body, exp.Union):
190
+ recursive_arm = body.expression
191
+ if (
192
+ isinstance(recursive_arm, exp.Select)
193
+ and recursive_arm.args.get("where") is None
194
+ ):
195
+ yield CostFactor(
196
+ "unbounded_recursion",
197
+ 30,
198
+ f"recursive arm of {cte.alias_or_name} has no terminating condition",
199
+ )
200
+ for func in root.find_all(exp.Func):
201
+ name = func.name.lower() if isinstance(func, exp.Anonymous) else func.sql_name().lower()
202
+ if isinstance(func, getattr(exp, "GenerateSeries", ())):
203
+ name = "generate_series"
204
+ if name in _GENERATOR_FUNCS:
205
+ values = [_int_literal(a) for a in func.args.values() if isinstance(a, exp.Expr)]
206
+ numbers = [v for v in values if v is not None]
207
+ if numbers and max(map(abs, numbers)) >= HUGE_LITERAL:
208
+ yield CostFactor("row_generator", 60, f"{name}() generating a very large range")
209
+ elif not numbers or len(numbers) < len(values):
210
+ yield CostFactor("row_generator", 40, f"{name}() with a non-literal bound")
211
+ elif name in _BLOWUP_FUNCS:
212
+ values = [_int_literal(a) for a in func.args.values() if isinstance(a, exp.Expr)]
213
+ if any(v is not None and v >= HUGE_LITERAL for v in values):
214
+ yield CostFactor("string_blowup", 60, f"{name}() with a very large size argument")
215
+
216
+
217
+ class StaticCostRule:
218
+ """Turn the static cost score into findings using the policy thresholds."""
219
+
220
+ rule_ids = ("cost.static_score",)
221
+
222
+ def check(self, ctx: RuleContext) -> Iterator[Finding]:
223
+ cfg = ctx.policy.cost
224
+ if not cfg.enabled:
225
+ return
226
+ report = analyse_static_cost(ctx.analysis, ctx.policy)
227
+ if report.score < cfg.review_score:
228
+ return
229
+ action = Action.DENY if report.score >= cfg.deny_score else Action.REVIEW
230
+ top = sorted(report.factors, key=lambda f: -f.points)[:3]
231
+ yield Finding(
232
+ rule_id="cost.static_score",
233
+ category=Category.COST,
234
+ severity=Severity.HIGH if action is Action.DENY else Severity.MEDIUM,
235
+ action=action,
236
+ message=f"Estimated query cost risk is {report.score}/100: "
237
+ + "; ".join(f.explanation for f in top)
238
+ + ".",
239
+ metadata=report.as_dict(),
240
+ remediation="Add selective filters, join conditions and a LIMIT.",
241
+ )
@@ -0,0 +1 @@
1
+ """Report rendering."""
@@ -0,0 +1,137 @@
1
+ """Human-readable Markdown reports (also used for the GitHub Actions job summary)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from sqlguard_ninja.benchmarking.compare import Comparison
8
+
9
+
10
+ def _pct(value: float | None) -> str:
11
+ return "n/a" if value is None else f"{value * 100:.1f}%"
12
+
13
+
14
+ def _ms(value: float | None) -> str:
15
+ return "n/a" if value is None else f"{value:.2f}"
16
+
17
+
18
+ def _row(name: str, m: dict[str, Any]) -> str:
19
+ return (
20
+ f"| {name} | {m['cases']} | {_pct(m['recall'])} | {_pct(m['precision'])} | "
21
+ f"{_pct(m['false_positive_rate'])} | {_pct(m['false_negative_rate'])} | "
22
+ f"{_pct(m['exact_match_rate'])} | {m['fn']} | {m['fp']} |"
23
+ )
24
+
25
+
26
+ _HEADER = (
27
+ "| Slice | Cases | Recall | Precision | FPR | FNR | Exact match | FN | FP |\n"
28
+ "|---|---:|---:|---:|---:|---:|---:|---:|---:|"
29
+ )
30
+
31
+
32
+ def benchmark_report(result: dict[str, Any], *, max_examples: int = 15) -> str:
33
+ m = result["metrics"]
34
+ b, a, env = result["benchmark"], result["adapter"], result["environment"]
35
+ lat = m["overall"]["latency_ms"]
36
+ lines = [
37
+ f"# SQL security benchmark — `{a['name']}` {a['version']}",
38
+ "",
39
+ f"Benchmark **{b['name']} {b['version']}** (fingerprint `{b['fingerprint']}`), "
40
+ f"{b['cases_evaluated']} evaluations. Python {env['python']}, sqlglot "
41
+ f"{env.get('sqlglot')}, {env['platform']}.",
42
+ "",
43
+ "Positive class = cases that must not run unattended (expected deny or review).",
44
+ "",
45
+ "## Summary",
46
+ "",
47
+ _HEADER,
48
+ _row("all cases", m["overall"]),
49
+ _row("within declared capability", m["within_capability"]),
50
+ "",
51
+ f"Latency per evaluation (ms): p50 {_ms(lat['p50'])}, p95 {_ms(lat['p95'])}, "
52
+ f"p99 {_ms(lat['p99'])}, max {_ms(lat['max'])}. Validator errors: "
53
+ f"{m['overall']['errors']}.",
54
+ "",
55
+ "## By category",
56
+ "",
57
+ _HEADER,
58
+ *(_row(k, v) for k, v in m["by_category"].items()),
59
+ "",
60
+ "## By dialect",
61
+ "",
62
+ _HEADER,
63
+ *(_row(k, v) for k, v in m["by_dialect"].items()),
64
+ "",
65
+ "## By provenance",
66
+ "",
67
+ _HEADER,
68
+ *(_row(k, v) for k, v in m["by_provenance"].items()),
69
+ "",
70
+ ]
71
+ by_id = {c["id"]: c for c in result["cases"]}
72
+ for title, key in (
73
+ ("False negatives (unsafe, allowed)", "false_negative_examples"),
74
+ ("False positives (safe, blocked)", "false_positive_examples"),
75
+ ):
76
+ ids = m[key][:max_examples]
77
+ lines += [f"## {title}", ""]
78
+ if not ids:
79
+ lines += ["None.", ""]
80
+ continue
81
+ lines += ["| Case | Category | Expected | Actual | Reasons |", "|---|---|---|---|---|"]
82
+ for cid in ids:
83
+ c = by_id[cid]
84
+ reasons = ", ".join(c["reasons"][:3]) or "—"
85
+ lines.append(
86
+ f"| `{cid}` | {c['category']} | {c['expected']} | {c['actual']} | {reasons} |"
87
+ )
88
+ lines.append("")
89
+ return "\n".join(lines)
90
+
91
+
92
+ def comparison_report(comp: Comparison, *, max_items: int = 20) -> str:
93
+ status = "✅ PASSED" if comp.passed else "❌ FAILED"
94
+ b, n = comp.baseline, comp.candidate
95
+ bm, nm = b["metrics"]["overall"], n["metrics"]["overall"]
96
+ lines = [
97
+ f"# SQL safety regression check: {status}",
98
+ "",
99
+ f"Baseline `{b['adapter']['name']} {b['adapter']['version']}` "
100
+ f"(benchmark `{b['benchmark']['fingerprint']}`) vs candidate "
101
+ f"`{n['adapter']['name']} {n['adapter']['version']}` "
102
+ f"(benchmark `{n['benchmark']['fingerprint']}`).",
103
+ "",
104
+ "| Metric | Baseline | Candidate |",
105
+ "|---|---:|---:|",
106
+ f"| Recall | {_pct(bm['recall'])} | {_pct(nm['recall'])} |",
107
+ f"| Precision | {_pct(bm['precision'])} | {_pct(nm['precision'])} |",
108
+ f"| False-positive rate | {_pct(bm['false_positive_rate'])} | "
109
+ f"{_pct(nm['false_positive_rate'])} |",
110
+ f"| p95 latency (ms) | {_ms(bm['latency_ms']['p95'])} | {_ms(nm['latency_ms']['p95'])} |",
111
+ "",
112
+ ]
113
+ if comp.failures:
114
+ lines += ["## Gate failures", "", *(f"- {f}" for f in comp.failures), ""]
115
+ sections = [
116
+ ("Security regressions (unsafe query now allowed)", comp.security_regressions),
117
+ (" of which privacy", comp.sensitive_regressions),
118
+ (" of which tenant isolation", comp.tenant_regressions),
119
+ ("Newly blocked legitimate queries", comp.usability_regressions),
120
+ ("New validator errors", comp.new_errors),
121
+ ("New parse failures on legitimate queries", comp.new_parse_failures),
122
+ ("Changed expected labels (need explicit approval)", comp.label_changes),
123
+ ("Other decision changes", comp.decision_changes),
124
+ ("Improvements", comp.improvements),
125
+ ]
126
+ for title, ids in sections:
127
+ lines.append(f"- **{title.strip()}**: {len(ids)}")
128
+ for cid in ids[:max_items]:
129
+ lines.append(f" - `{cid}`")
130
+ if comp.p95_change_pct is not None:
131
+ lines.append(f"- **p95 latency change**: {comp.p95_change_pct:+.1f}%")
132
+ lines += [
133
+ "",
134
+ "Baselines are never updated automatically. To accept intentional changes, run "
135
+ "`sqlninja benchmark baseline` locally and commit the result for review.",
136
+ ]
137
+ return "\n".join(lines)