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,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
|
sqlguard_ninja/py.typed
ADDED
|
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)
|