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,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
+ )