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,256 @@
|
|
|
1
|
+
"""The :class:`Guard` validates one SQL string against a policy for one role."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from sqlguard_ninja.core.analysis import analyse
|
|
11
|
+
from sqlguard_ninja.core.findings import (
|
|
12
|
+
Action,
|
|
13
|
+
Category,
|
|
14
|
+
Finding,
|
|
15
|
+
Severity,
|
|
16
|
+
ValidationResult,
|
|
17
|
+
combine,
|
|
18
|
+
)
|
|
19
|
+
from sqlguard_ninja.core.rules.access import ColumnAccessRule, TableAccessRule
|
|
20
|
+
from sqlguard_ninja.core.rules.base import Rule, RuleContext
|
|
21
|
+
from sqlguard_ninja.core.rules.catalog import SystemCatalogRule
|
|
22
|
+
from sqlguard_ninja.core.rules.functions import FunctionPolicyRule
|
|
23
|
+
from sqlguard_ninja.core.rules.statements import ReadOnlyStatementRule
|
|
24
|
+
from sqlguard_ninja.core.rules.structure import StructureLimitsRule
|
|
25
|
+
from sqlguard_ninja.core.rules.tenant import TenantIsolationRule
|
|
26
|
+
from sqlguard_ninja.policy.model import Access, Policy, Role, TableGrant
|
|
27
|
+
from sqlguard_ninja.privacy.rules import PrivacyPolicyRule
|
|
28
|
+
from sqlguard_ninja.query_cost.static import StaticCostRule
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def default_rules() -> list[Rule]:
|
|
32
|
+
"""The built-in rule pipeline, in evaluation order."""
|
|
33
|
+
return [
|
|
34
|
+
ReadOnlyStatementRule(),
|
|
35
|
+
SystemCatalogRule(),
|
|
36
|
+
FunctionPolicyRule(),
|
|
37
|
+
TableAccessRule(),
|
|
38
|
+
ColumnAccessRule(),
|
|
39
|
+
PrivacyPolicyRule(),
|
|
40
|
+
TenantIsolationRule(),
|
|
41
|
+
StructureLimitsRule(),
|
|
42
|
+
StaticCostRule(),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def sql_sha256(sql: str) -> str:
|
|
47
|
+
# surrogatepass: hashing must not fail on input that is itself rejected as invalid.
|
|
48
|
+
return hashlib.sha256(sql.encode("utf-8", errors="surrogatepass")).hexdigest()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _preflight(sql: str, policy: Policy, limits_len: int) -> Finding | None:
|
|
52
|
+
if not sql or not sql.strip():
|
|
53
|
+
return Finding(
|
|
54
|
+
rule_id="parse.empty",
|
|
55
|
+
category=Category.PARSE,
|
|
56
|
+
severity=Severity.LOW,
|
|
57
|
+
action=Action.DENY,
|
|
58
|
+
message="The query is empty.",
|
|
59
|
+
)
|
|
60
|
+
if len(sql) > limits_len:
|
|
61
|
+
return Finding(
|
|
62
|
+
rule_id="parse.too_long",
|
|
63
|
+
category=Category.PARSE,
|
|
64
|
+
severity=Severity.MEDIUM,
|
|
65
|
+
action=Action.DENY,
|
|
66
|
+
message=f"The query is longer than {limits_len} characters.",
|
|
67
|
+
)
|
|
68
|
+
try:
|
|
69
|
+
sql.encode("utf-8")
|
|
70
|
+
except UnicodeEncodeError:
|
|
71
|
+
return Finding(
|
|
72
|
+
rule_id="parse.invalid_characters",
|
|
73
|
+
category=Category.PARSE,
|
|
74
|
+
severity=Severity.HIGH,
|
|
75
|
+
action=Action.DENY,
|
|
76
|
+
message="The query is not valid UTF-8 text.",
|
|
77
|
+
)
|
|
78
|
+
if "\x00" in sql:
|
|
79
|
+
return Finding(
|
|
80
|
+
rule_id="parse.invalid_characters",
|
|
81
|
+
category=Category.PARSE,
|
|
82
|
+
severity=Severity.HIGH,
|
|
83
|
+
action=Action.DENY,
|
|
84
|
+
message="The query contains NUL characters.",
|
|
85
|
+
)
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Guard:
|
|
90
|
+
"""Validate SQL against a :class:`~sqlguard_ninja.policy.Policy`.
|
|
91
|
+
|
|
92
|
+
The guard is stateless and thread-safe: create one per policy at start-up and reuse it.
|
|
93
|
+
It never executes SQL and never rewrites it — callers must execute exactly the string
|
|
94
|
+
they validated.
|
|
95
|
+
|
|
96
|
+
Example::
|
|
97
|
+
|
|
98
|
+
guard = Guard(Policy.from_file("policy.yaml"))
|
|
99
|
+
result = guard.validate("SELECT id FROM orders WHERE tenant_id = :tenant_id",
|
|
100
|
+
role="support_analyst", context={"tenant_id": 42})
|
|
101
|
+
if not result.allowed:
|
|
102
|
+
print(result.explain())
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, policy: Policy, rules: Sequence[Rule] | None = None) -> None:
|
|
106
|
+
self.policy = policy
|
|
107
|
+
self.rules: list[Rule] = list(rules) if rules is not None else default_rules()
|
|
108
|
+
|
|
109
|
+
def with_rules(self, extra: Iterable[Rule]) -> Guard:
|
|
110
|
+
"""Return a new guard with additional custom rules appended."""
|
|
111
|
+
return Guard(self.policy, [*self.rules, *extra])
|
|
112
|
+
|
|
113
|
+
def _implicit_role(self) -> Role:
|
|
114
|
+
return Role(
|
|
115
|
+
description="implicit: policy defines no roles",
|
|
116
|
+
tables={name: TableGrant() for name in self.policy.catalog.tables},
|
|
117
|
+
unlisted_label_access=Access.DENY,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def validate(
|
|
121
|
+
self,
|
|
122
|
+
sql: str,
|
|
123
|
+
*,
|
|
124
|
+
role: str | None = None,
|
|
125
|
+
dialect: str | None = None,
|
|
126
|
+
context: Mapping[str, Any] | None = None,
|
|
127
|
+
) -> ValidationResult:
|
|
128
|
+
"""Validate ``sql`` and return a :class:`ValidationResult`. Never raises on bad SQL."""
|
|
129
|
+
started = time.perf_counter()
|
|
130
|
+
dialect = (dialect or self.policy.dialect).lower()
|
|
131
|
+
context = dict(context or {})
|
|
132
|
+
findings: list[Finding] = []
|
|
133
|
+
|
|
134
|
+
role_name, role_obj = self.policy.role(role)
|
|
135
|
+
if not self.policy.roles:
|
|
136
|
+
role_obj = self._implicit_role()
|
|
137
|
+
elif role_obj is None:
|
|
138
|
+
findings.append(
|
|
139
|
+
Finding(
|
|
140
|
+
rule_id="role.unknown",
|
|
141
|
+
category=Category.TABLE_ACCESS,
|
|
142
|
+
severity=Severity.HIGH,
|
|
143
|
+
action=Action.DENY,
|
|
144
|
+
message="No valid role was supplied for this request.",
|
|
145
|
+
detail=f"role {role!r} is not defined in policy {self.policy.name!r}",
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
limits = self.policy.effective_limits(role_obj)
|
|
149
|
+
|
|
150
|
+
pre = _preflight(sql, self.policy, limits.max_sql_length)
|
|
151
|
+
analysis = None
|
|
152
|
+
if pre is not None:
|
|
153
|
+
findings.append(pre)
|
|
154
|
+
else:
|
|
155
|
+
analysis = analyse(sql, dialect, self.policy.catalog, max_nodes=limits.max_ast_nodes)
|
|
156
|
+
findings.extend(self._structural_findings(analysis, limits.max_ast_nodes))
|
|
157
|
+
|
|
158
|
+
if analysis is not None and not findings_block_analysis(findings):
|
|
159
|
+
ctx = RuleContext(
|
|
160
|
+
analysis=analysis,
|
|
161
|
+
policy=self.policy,
|
|
162
|
+
role_name=role_name,
|
|
163
|
+
role=role_obj,
|
|
164
|
+
limits=limits,
|
|
165
|
+
context=context,
|
|
166
|
+
)
|
|
167
|
+
for rule in self.rules:
|
|
168
|
+
findings.extend(rule.check(ctx))
|
|
169
|
+
|
|
170
|
+
elapsed = (time.perf_counter() - started) * 1000
|
|
171
|
+
return ValidationResult(
|
|
172
|
+
decision=combine(findings),
|
|
173
|
+
findings=tuple(findings),
|
|
174
|
+
sql_sha256=sql_sha256(sql or ""),
|
|
175
|
+
dialect=dialect,
|
|
176
|
+
role=role_name,
|
|
177
|
+
policy_name=self.policy.name,
|
|
178
|
+
policy_version=self.policy.version,
|
|
179
|
+
tables=tuple(analysis.base_table_names) if analysis else (),
|
|
180
|
+
columns=tuple(sorted({f"{c.table}.{c.column}" for c in analysis.columns}))
|
|
181
|
+
if analysis
|
|
182
|
+
else (),
|
|
183
|
+
elapsed_ms=elapsed,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def _structural_findings(analysis: Any, max_nodes: int) -> list[Finding]:
|
|
188
|
+
out: list[Finding] = []
|
|
189
|
+
if analysis.parse_error is not None:
|
|
190
|
+
out.append(
|
|
191
|
+
Finding(
|
|
192
|
+
rule_id="parse.error",
|
|
193
|
+
category=Category.PARSE,
|
|
194
|
+
severity=Severity.MEDIUM,
|
|
195
|
+
action=Action.DENY,
|
|
196
|
+
message="The query could not be parsed in the configured SQL dialect.",
|
|
197
|
+
detail=analysis.parse_error,
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
elif len(analysis.statements) == 0:
|
|
201
|
+
out.append(
|
|
202
|
+
Finding(
|
|
203
|
+
rule_id="parse.empty",
|
|
204
|
+
category=Category.PARSE,
|
|
205
|
+
severity=Severity.LOW,
|
|
206
|
+
action=Action.DENY,
|
|
207
|
+
message="The query contains no statement.",
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
elif len(analysis.statements) > 1:
|
|
211
|
+
out.append(
|
|
212
|
+
Finding(
|
|
213
|
+
rule_id="statement.multiple",
|
|
214
|
+
category=Category.STATEMENT,
|
|
215
|
+
severity=Severity.CRITICAL,
|
|
216
|
+
action=Action.DENY,
|
|
217
|
+
message="Only one statement may be executed at a time.",
|
|
218
|
+
detail=f"{len(analysis.statements)} statements",
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
elif analysis.node_count > max_nodes:
|
|
222
|
+
out.append(
|
|
223
|
+
Finding(
|
|
224
|
+
rule_id="parse.too_complex",
|
|
225
|
+
category=Category.PARSE,
|
|
226
|
+
severity=Severity.MEDIUM,
|
|
227
|
+
action=Action.DENY,
|
|
228
|
+
message="The query is too complex to analyse safely.",
|
|
229
|
+
detail=f"{analysis.node_count} AST nodes > {max_nodes}",
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
elif analysis.qualify_error is not None:
|
|
233
|
+
out.append(
|
|
234
|
+
Finding(
|
|
235
|
+
rule_id="parse.analysis_incomplete",
|
|
236
|
+
category=Category.PARSE,
|
|
237
|
+
severity=Severity.MEDIUM,
|
|
238
|
+
action=Action.DENY,
|
|
239
|
+
message="The query could not be fully analysed, so it is not permitted.",
|
|
240
|
+
detail=analysis.qualify_error,
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
return out
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def findings_block_analysis(findings: list[Finding]) -> bool:
|
|
247
|
+
"""Parse-level failures and multi-statements stop the rule pipeline.
|
|
248
|
+
|
|
249
|
+
An incomplete *qualification* does not: statement, catalog and function rules still
|
|
250
|
+
run on the parsed tree so the caller gets the most specific reason available.
|
|
251
|
+
"""
|
|
252
|
+
return any(
|
|
253
|
+
(f.rule_id.startswith("parse.") and f.rule_id != "parse.analysis_incomplete")
|
|
254
|
+
or f.rule_id == "statement.multiple"
|
|
255
|
+
for f in findings
|
|
256
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""rules package."""
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Table and column grants.
|
|
2
|
+
|
|
3
|
+
Unknown and ungranted objects produce the *same* agent-facing message, so a caller cannot use
|
|
4
|
+
the guard to discover which tables exist.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
|
|
11
|
+
from sqlguard_ninja.core.findings import Action, Category, Finding, Severity
|
|
12
|
+
from sqlguard_ninja.core.rules.base import RuleContext
|
|
13
|
+
from sqlguard_ninja.core.rules.catalog import is_system_object
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TableAccessRule:
|
|
17
|
+
rule_ids = ("table.not_permitted",)
|
|
18
|
+
|
|
19
|
+
def check(self, ctx: RuleContext) -> Iterator[Finding]:
|
|
20
|
+
catalog = ctx.policy.catalog
|
|
21
|
+
seen: set[str] = set()
|
|
22
|
+
for ref in ctx.analysis.tables:
|
|
23
|
+
if ref.is_function or is_system_object(ref.name, ref.schema):
|
|
24
|
+
continue # handled by the function and catalog rules
|
|
25
|
+
if ref.qualified in seen:
|
|
26
|
+
continue
|
|
27
|
+
seen.add(ref.qualified)
|
|
28
|
+
message = f"The {ctx.name('table', ref.name)} is not available to this role."
|
|
29
|
+
schema_ok = ref.schema is None or ref.schema == catalog.default_schema
|
|
30
|
+
if ref.name not in catalog.tables or not schema_ok:
|
|
31
|
+
yield Finding(
|
|
32
|
+
rule_id="table.not_permitted",
|
|
33
|
+
category=Category.TABLE_ACCESS,
|
|
34
|
+
severity=Severity.HIGH,
|
|
35
|
+
action=ctx.policy.defaults.unknown_table_action,
|
|
36
|
+
message=message,
|
|
37
|
+
detail=f"table {ref.qualified} is not in the policy catalog",
|
|
38
|
+
table=ref.qualified,
|
|
39
|
+
)
|
|
40
|
+
continue
|
|
41
|
+
if ctx.role is not None and ref.name not in ctx.role.tables:
|
|
42
|
+
yield Finding(
|
|
43
|
+
rule_id="table.not_permitted",
|
|
44
|
+
category=Category.TABLE_ACCESS,
|
|
45
|
+
severity=Severity.HIGH,
|
|
46
|
+
action=Action.DENY,
|
|
47
|
+
message=message,
|
|
48
|
+
detail=f"role {ctx.role_name!r} has no grant on {ref.name}",
|
|
49
|
+
table=ref.name,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ColumnAccessRule:
|
|
54
|
+
"""Column grants, unresolved columns and ``*`` that cannot be expanded."""
|
|
55
|
+
|
|
56
|
+
rule_ids = ("column.not_permitted", "column.unresolved", "column.star_unexpandable")
|
|
57
|
+
|
|
58
|
+
def check(self, ctx: RuleContext) -> Iterator[Finding]:
|
|
59
|
+
catalog = ctx.policy.catalog
|
|
60
|
+
defaults = ctx.policy.defaults
|
|
61
|
+
seen: set[tuple[str, str]] = set()
|
|
62
|
+
for ref in ctx.analysis.columns:
|
|
63
|
+
key = (ref.table, ref.column)
|
|
64
|
+
if key in seen:
|
|
65
|
+
continue
|
|
66
|
+
table = catalog.tables.get(ref.table)
|
|
67
|
+
if table is None:
|
|
68
|
+
continue # the table rule already reported it
|
|
69
|
+
grant = ctx.role.tables.get(ref.table) if ctx.role is not None else None
|
|
70
|
+
if ctx.role is not None and grant is None:
|
|
71
|
+
continue # ungranted table: reported once by the table rule
|
|
72
|
+
col_msg = ctx.name("column", f"{ref.table}.{ref.column}")
|
|
73
|
+
star_hint = (
|
|
74
|
+
" The query selects all columns; list only the columns you need."
|
|
75
|
+
if ref.whole_row
|
|
76
|
+
else ""
|
|
77
|
+
)
|
|
78
|
+
if ref.column not in table.columns:
|
|
79
|
+
seen.add(key)
|
|
80
|
+
yield Finding(
|
|
81
|
+
rule_id="column.not_permitted",
|
|
82
|
+
category=Category.COLUMN_ACCESS,
|
|
83
|
+
severity=Severity.MEDIUM,
|
|
84
|
+
action=defaults.unresolved_column_action,
|
|
85
|
+
message=f"The {col_msg} is not available to this role.",
|
|
86
|
+
detail="column not in catalog",
|
|
87
|
+
table=ref.table,
|
|
88
|
+
column=ref.column,
|
|
89
|
+
)
|
|
90
|
+
continue
|
|
91
|
+
if grant is None:
|
|
92
|
+
continue
|
|
93
|
+
listed = grant.columns == "*" or ref.column in grant.columns
|
|
94
|
+
if not listed or ref.column in grant.deny_columns:
|
|
95
|
+
seen.add(key)
|
|
96
|
+
yield Finding(
|
|
97
|
+
rule_id="column.not_permitted",
|
|
98
|
+
category=Category.COLUMN_ACCESS,
|
|
99
|
+
severity=Severity.HIGH,
|
|
100
|
+
action=Action.DENY,
|
|
101
|
+
message=f"The {col_msg} is not available to this role.{star_hint}",
|
|
102
|
+
detail=f"role {ctx.role_name!r} grant excludes this column",
|
|
103
|
+
table=ref.table,
|
|
104
|
+
column=ref.column,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
for name in sorted(set(ctx.analysis.unresolved_columns)):
|
|
108
|
+
yield Finding(
|
|
109
|
+
rule_id="column.unresolved",
|
|
110
|
+
category=Category.COLUMN_ACCESS,
|
|
111
|
+
severity=Severity.MEDIUM,
|
|
112
|
+
action=defaults.unresolved_column_action,
|
|
113
|
+
message=(
|
|
114
|
+
"A column reference could not be attributed to a permitted table; "
|
|
115
|
+
"qualify columns with their table alias."
|
|
116
|
+
),
|
|
117
|
+
detail=f"unresolved column {name}",
|
|
118
|
+
)
|
|
119
|
+
for star in sorted(set(ctx.analysis.unexpandable_stars)):
|
|
120
|
+
yield Finding(
|
|
121
|
+
rule_id="column.star_unexpandable",
|
|
122
|
+
category=Category.COLUMN_ACCESS,
|
|
123
|
+
severity=Severity.MEDIUM,
|
|
124
|
+
action=defaults.unexpandable_star_action,
|
|
125
|
+
message="SELECT * over a source without a known schema is not permitted.",
|
|
126
|
+
detail=f"cannot expand {star}",
|
|
127
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Rule protocol and the context passed to every rule."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
from sqlguard_ninja.core.analysis import QueryAnalysis
|
|
10
|
+
from sqlguard_ninja.core.findings import Finding
|
|
11
|
+
from sqlguard_ninja.policy.model import Disclosure, Limits, Policy, Role
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class RuleContext:
|
|
16
|
+
"""Everything a rule may look at. Rules must not mutate it."""
|
|
17
|
+
|
|
18
|
+
analysis: QueryAnalysis
|
|
19
|
+
policy: Policy
|
|
20
|
+
role_name: str | None
|
|
21
|
+
role: Role | None
|
|
22
|
+
limits: Limits
|
|
23
|
+
context: Mapping[str, Any] = field(default_factory=dict)
|
|
24
|
+
"""Trusted, server-side request values (e.g. ``tenant_id``). Never model output."""
|
|
25
|
+
|
|
26
|
+
def name(self, kind: str, value: str) -> str:
|
|
27
|
+
"""Render a schema object name subject to the policy's disclosure setting."""
|
|
28
|
+
if self.policy.disclosure == Disclosure.GENERIC:
|
|
29
|
+
return f"requested {kind}"
|
|
30
|
+
return f"{kind} '{value}'"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@runtime_checkable
|
|
34
|
+
class Rule(Protocol):
|
|
35
|
+
"""A policy rule. ``rule_ids`` documents every id the rule can emit."""
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def rule_ids(self) -> tuple[str, ...]: ...
|
|
39
|
+
|
|
40
|
+
def check(self, ctx: RuleContext) -> Iterable[Finding]: ...
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""System-catalog and metadata access."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
|
|
7
|
+
from sqlguard_ninja.core.findings import Category, Finding, Severity
|
|
8
|
+
from sqlguard_ninja.core.rules.base import RuleContext
|
|
9
|
+
|
|
10
|
+
SYSTEM_SCHEMAS = frozenset(
|
|
11
|
+
{"information_schema", "pg_catalog", "pg_toast", "mysql", "performance_schema", "sys"}
|
|
12
|
+
)
|
|
13
|
+
SYSTEM_TABLES = frozenset(
|
|
14
|
+
{
|
|
15
|
+
"sqlite_master",
|
|
16
|
+
"sqlite_schema",
|
|
17
|
+
"sqlite_temp_master",
|
|
18
|
+
"sqlite_temp_schema",
|
|
19
|
+
"sqlite_sequence",
|
|
20
|
+
"sqlite_dbpage",
|
|
21
|
+
"sqlite_stmt",
|
|
22
|
+
"dbstat",
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
SYSTEM_PREFIXES = ("pg_", "sqlite_", "pragma_")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_system_object(name: str, schema: str | None) -> bool:
|
|
29
|
+
"""True for catalog tables, views and table-valued pragma functions."""
|
|
30
|
+
if schema and schema in SYSTEM_SCHEMAS:
|
|
31
|
+
return True
|
|
32
|
+
return name in SYSTEM_TABLES or name.startswith(SYSTEM_PREFIXES)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SystemCatalogRule:
|
|
36
|
+
"""Deny reads of ``information_schema``, ``pg_catalog``, ``sqlite_master`` and similar.
|
|
37
|
+
|
|
38
|
+
Catalog reads let an agent enumerate tables and columns it was never granted, and some
|
|
39
|
+
catalog relations (``pg_shadow``, ``pg_authid``) expose credentials.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
rule_ids = ("catalog.system_object",)
|
|
43
|
+
|
|
44
|
+
def check(self, ctx: RuleContext) -> Iterator[Finding]:
|
|
45
|
+
seen: set[str] = set()
|
|
46
|
+
for ref in ctx.analysis.tables:
|
|
47
|
+
name = ref.function_name if ref.is_function else ref.name
|
|
48
|
+
if name is None or not is_system_object(name, ref.schema):
|
|
49
|
+
continue
|
|
50
|
+
if ref.qualified in seen:
|
|
51
|
+
continue
|
|
52
|
+
seen.add(ref.qualified)
|
|
53
|
+
yield Finding(
|
|
54
|
+
rule_id="catalog.system_object",
|
|
55
|
+
category=Category.CATALOG,
|
|
56
|
+
severity=Severity.HIGH,
|
|
57
|
+
action=ctx.policy.defaults.catalog_access_action,
|
|
58
|
+
message="Access to database system catalogs and metadata is not permitted.",
|
|
59
|
+
detail=f"system object {ref.qualified}",
|
|
60
|
+
table=ref.qualified,
|
|
61
|
+
)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Function-call policy: built-in deny list, optional allowlist mode, and UDF handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
|
|
7
|
+
from sqlguard_ninja.core.findings import Action, Category, Finding, Severity
|
|
8
|
+
from sqlguard_ninja.core.rules.base import RuleContext
|
|
9
|
+
|
|
10
|
+
# Grouped so that a finding can say *why* a function is dangerous.
|
|
11
|
+
DENIED_FUNCTIONS: dict[str, frozenset[str]] = {
|
|
12
|
+
"file_or_program_access": frozenset(
|
|
13
|
+
{
|
|
14
|
+
"pg_read_file",
|
|
15
|
+
"pg_read_binary_file",
|
|
16
|
+
"pg_ls_dir",
|
|
17
|
+
"pg_stat_file",
|
|
18
|
+
"pg_ls_logdir",
|
|
19
|
+
"pg_ls_waldir",
|
|
20
|
+
"pg_ls_tmpdir",
|
|
21
|
+
"pg_ls_archive_statusdir",
|
|
22
|
+
"lo_import",
|
|
23
|
+
"lo_export",
|
|
24
|
+
"lo_get",
|
|
25
|
+
"lo_put",
|
|
26
|
+
"lo_create",
|
|
27
|
+
"lo_unlink",
|
|
28
|
+
"lo_from_bytea",
|
|
29
|
+
"readfile",
|
|
30
|
+
"writefile",
|
|
31
|
+
"edit",
|
|
32
|
+
"load_extension",
|
|
33
|
+
"fts3_tokenizer",
|
|
34
|
+
}
|
|
35
|
+
),
|
|
36
|
+
"remote_or_dynamic_sql": frozenset(
|
|
37
|
+
{
|
|
38
|
+
"dblink",
|
|
39
|
+
"dblink_exec",
|
|
40
|
+
"dblink_connect",
|
|
41
|
+
"dblink_connect_u",
|
|
42
|
+
"dblink_open",
|
|
43
|
+
"dblink_send_query",
|
|
44
|
+
"query_to_xml",
|
|
45
|
+
"query_to_xml_and_xmlschema",
|
|
46
|
+
"query_to_xmlschema",
|
|
47
|
+
"cursor_to_xml",
|
|
48
|
+
"table_to_xml",
|
|
49
|
+
"table_to_xml_and_xmlschema",
|
|
50
|
+
"schema_to_xml",
|
|
51
|
+
"database_to_xml",
|
|
52
|
+
"database_to_xml_and_xmlschema",
|
|
53
|
+
}
|
|
54
|
+
),
|
|
55
|
+
"denial_of_service": frozenset(
|
|
56
|
+
{
|
|
57
|
+
"pg_sleep",
|
|
58
|
+
"pg_sleep_for",
|
|
59
|
+
"pg_sleep_until",
|
|
60
|
+
"pg_advisory_lock",
|
|
61
|
+
"pg_advisory_xact_lock",
|
|
62
|
+
"pg_advisory_lock_shared",
|
|
63
|
+
"pg_try_advisory_lock",
|
|
64
|
+
"randomblob",
|
|
65
|
+
"zeroblob",
|
|
66
|
+
}
|
|
67
|
+
),
|
|
68
|
+
"administration_or_state_change": frozenset(
|
|
69
|
+
{
|
|
70
|
+
"pg_terminate_backend",
|
|
71
|
+
"pg_cancel_backend",
|
|
72
|
+
"pg_reload_conf",
|
|
73
|
+
"pg_rotate_logfile",
|
|
74
|
+
"set_config",
|
|
75
|
+
"pg_switch_wal",
|
|
76
|
+
"pg_create_restore_point",
|
|
77
|
+
"pg_promote",
|
|
78
|
+
"pg_logical_emit_message",
|
|
79
|
+
"pg_notify",
|
|
80
|
+
"nextval",
|
|
81
|
+
"setval",
|
|
82
|
+
"txid_current",
|
|
83
|
+
"pg_current_xact_id",
|
|
84
|
+
}
|
|
85
|
+
),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
RECONNAISSANCE_FUNCTIONS = frozenset(
|
|
89
|
+
{
|
|
90
|
+
"version",
|
|
91
|
+
"current_version",
|
|
92
|
+
"current_setting",
|
|
93
|
+
"inet_server_addr",
|
|
94
|
+
"inet_server_port",
|
|
95
|
+
"inet_client_addr",
|
|
96
|
+
"pg_postmaster_start_time",
|
|
97
|
+
"pg_backend_pid",
|
|
98
|
+
"pg_has_role",
|
|
99
|
+
"has_table_privilege",
|
|
100
|
+
"has_column_privilege",
|
|
101
|
+
"has_schema_privilege",
|
|
102
|
+
"has_database_privilege",
|
|
103
|
+
"has_function_privilege",
|
|
104
|
+
"sqlite_version",
|
|
105
|
+
"sqlite_source_id",
|
|
106
|
+
"sqlite_compileoption_used",
|
|
107
|
+
"sqlite_compileoption_get",
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
DENIED_PREFIXES = ("dblink", "lo_")
|
|
112
|
+
REVIEW_PREFIXES = ("pg_",)
|
|
113
|
+
|
|
114
|
+
_ALL_DENIED = frozenset().union(*DENIED_FUNCTIONS.values())
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _deny_group(name: str) -> str | None:
|
|
118
|
+
for group, names in DENIED_FUNCTIONS.items():
|
|
119
|
+
if name in names:
|
|
120
|
+
return group
|
|
121
|
+
if name.startswith(DENIED_PREFIXES):
|
|
122
|
+
return "file_or_program_access"
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class FunctionPolicyRule:
|
|
127
|
+
"""Apply the policy's function rules to every function call in the query."""
|
|
128
|
+
|
|
129
|
+
rule_ids = (
|
|
130
|
+
"function.denied",
|
|
131
|
+
"function.not_allowlisted",
|
|
132
|
+
"function.review",
|
|
133
|
+
"function.unknown",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def check(self, ctx: RuleContext) -> Iterator[Finding]:
|
|
137
|
+
fp = ctx.policy.functions
|
|
138
|
+
extra_deny = set(fp.deny)
|
|
139
|
+
exceptions = set(fp.allow)
|
|
140
|
+
review = set(fp.review)
|
|
141
|
+
seen: set[str] = set()
|
|
142
|
+
for func in ctx.analysis.functions:
|
|
143
|
+
if func.display in seen:
|
|
144
|
+
continue
|
|
145
|
+
seen.add(func.display)
|
|
146
|
+
names = func.names
|
|
147
|
+
shown = ctx.name("function", func.display)
|
|
148
|
+
|
|
149
|
+
if fp.mode == "allowlist":
|
|
150
|
+
if not names & exceptions:
|
|
151
|
+
yield Finding(
|
|
152
|
+
rule_id="function.not_allowlisted",
|
|
153
|
+
category=Category.FUNCTION,
|
|
154
|
+
severity=Severity.MEDIUM,
|
|
155
|
+
action=Action.DENY,
|
|
156
|
+
message=f"The {shown} is not on the approved function list.",
|
|
157
|
+
)
|
|
158
|
+
continue
|
|
159
|
+
elif names & exceptions:
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
group = next((g for n in names if (g := _deny_group(n))), None)
|
|
163
|
+
if names & extra_deny or (group is not None and fp.mode == "denylist"):
|
|
164
|
+
yield Finding(
|
|
165
|
+
rule_id="function.denied",
|
|
166
|
+
category=Category.FUNCTION,
|
|
167
|
+
severity=Severity.CRITICAL,
|
|
168
|
+
action=Action.DENY,
|
|
169
|
+
message=f"The {shown} is not permitted.",
|
|
170
|
+
detail=f"group={group or 'policy_deny'}",
|
|
171
|
+
metadata={"group": group or "policy_deny"},
|
|
172
|
+
)
|
|
173
|
+
continue
|
|
174
|
+
if fp.mode == "allowlist":
|
|
175
|
+
continue
|
|
176
|
+
if names & review or names & RECONNAISSANCE_FUNCTIONS:
|
|
177
|
+
yield Finding(
|
|
178
|
+
rule_id="function.review",
|
|
179
|
+
category=Category.FUNCTION,
|
|
180
|
+
severity=Severity.MEDIUM,
|
|
181
|
+
action=Action.REVIEW,
|
|
182
|
+
message=f"The {shown} reveals server or privilege details and needs review.",
|
|
183
|
+
)
|
|
184
|
+
continue
|
|
185
|
+
if not func.known:
|
|
186
|
+
if any(n.startswith(REVIEW_PREFIXES) for n in names):
|
|
187
|
+
action = Action.REVIEW
|
|
188
|
+
else:
|
|
189
|
+
action = fp.unknown_function_action
|
|
190
|
+
yield Finding(
|
|
191
|
+
rule_id="function.unknown",
|
|
192
|
+
category=Category.FUNCTION,
|
|
193
|
+
severity=Severity.LOW,
|
|
194
|
+
action=action,
|
|
195
|
+
message=f"The {shown} is not a recognised built-in function.",
|
|
196
|
+
detail="may be a user-defined function; consider functions.mode=allowlist",
|
|
197
|
+
)
|