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,99 @@
|
|
|
1
|
+
"""Statement-shape rules: read-only enforcement and session-changing statements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
|
|
7
|
+
from sqlglot import exp
|
|
8
|
+
|
|
9
|
+
from sqlguard_ninja.core.analysis import SESSION_NODE_TYPES, WRITE_NODE_TYPES
|
|
10
|
+
from sqlguard_ninja.core.findings import Action, Category, Finding, Severity
|
|
11
|
+
from sqlguard_ninja.core.rules.base import RuleContext
|
|
12
|
+
|
|
13
|
+
_MYSQL_FAMILY = {"mysql", "doris", "starrocks", "singlestore"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ReadOnlyStatementRule:
|
|
17
|
+
"""Only a single SELECT (or set operation of SELECTs) is permitted, with no writes inside.
|
|
18
|
+
|
|
19
|
+
The check is an allowlist on the root statement plus a deny walk over the full tree, so
|
|
20
|
+
data-modifying CTEs, ``SELECT ... INTO`` and row locks are caught wherever they appear.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
rule_ids = (
|
|
24
|
+
"statement.not_read_only",
|
|
25
|
+
"statement.unsupported",
|
|
26
|
+
"statement.write_in_query",
|
|
27
|
+
"statement.select_into",
|
|
28
|
+
"statement.locking",
|
|
29
|
+
"statement.executable_comment",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def check(self, ctx: RuleContext) -> Iterator[Finding]:
|
|
33
|
+
root = ctx.analysis.root
|
|
34
|
+
if root is None:
|
|
35
|
+
return
|
|
36
|
+
if ctx.analysis.dialect in _MYSQL_FAMILY and "/*!" in ctx.analysis.sql:
|
|
37
|
+
yield Finding(
|
|
38
|
+
rule_id="statement.executable_comment",
|
|
39
|
+
category=Category.STATEMENT,
|
|
40
|
+
severity=Severity.CRITICAL,
|
|
41
|
+
action=Action.DENY,
|
|
42
|
+
message="MySQL executable comments (/*! ... */) are not permitted.",
|
|
43
|
+
detail="The server executes the comment body; the parser treats it as a comment.",
|
|
44
|
+
)
|
|
45
|
+
if isinstance(root, exp.Command):
|
|
46
|
+
keyword = str(root.this).split()[0].upper() if root.this else "UNKNOWN"
|
|
47
|
+
yield Finding(
|
|
48
|
+
rule_id="statement.unsupported",
|
|
49
|
+
category=Category.STATEMENT,
|
|
50
|
+
severity=Severity.HIGH,
|
|
51
|
+
action=Action.DENY,
|
|
52
|
+
message="This statement type is not supported; only SELECT queries are allowed.",
|
|
53
|
+
detail=f"unparsed command starting with {keyword}",
|
|
54
|
+
)
|
|
55
|
+
return
|
|
56
|
+
if not isinstance(root, (exp.Select, exp.SetOperation)):
|
|
57
|
+
yield Finding(
|
|
58
|
+
rule_id="statement.not_read_only",
|
|
59
|
+
category=Category.STATEMENT,
|
|
60
|
+
severity=Severity.CRITICAL,
|
|
61
|
+
action=Action.DENY,
|
|
62
|
+
message="Only read-only SELECT queries are allowed.",
|
|
63
|
+
detail=f"statement type {type(root).__name__}",
|
|
64
|
+
)
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
reported: set[str] = set()
|
|
68
|
+
for node in root.walk():
|
|
69
|
+
if isinstance(node, (*WRITE_NODE_TYPES, *SESSION_NODE_TYPES, exp.Command)):
|
|
70
|
+
key = type(node).__name__
|
|
71
|
+
if key in reported:
|
|
72
|
+
continue
|
|
73
|
+
reported.add(key)
|
|
74
|
+
yield Finding(
|
|
75
|
+
rule_id="statement.write_in_query",
|
|
76
|
+
category=Category.STATEMENT,
|
|
77
|
+
severity=Severity.CRITICAL,
|
|
78
|
+
action=Action.DENY,
|
|
79
|
+
message="The query contains a write or session-changing statement.",
|
|
80
|
+
detail=f"{key} nested inside a SELECT (e.g. a data-modifying CTE)",
|
|
81
|
+
)
|
|
82
|
+
elif isinstance(node, exp.Into) and "into" not in reported:
|
|
83
|
+
reported.add("into")
|
|
84
|
+
yield Finding(
|
|
85
|
+
rule_id="statement.select_into",
|
|
86
|
+
category=Category.STATEMENT,
|
|
87
|
+
severity=Severity.CRITICAL,
|
|
88
|
+
action=Action.DENY,
|
|
89
|
+
message="SELECT ... INTO creates a table and is not permitted.",
|
|
90
|
+
)
|
|
91
|
+
elif isinstance(node, exp.Lock) and "lock" not in reported:
|
|
92
|
+
reported.add("lock")
|
|
93
|
+
yield Finding(
|
|
94
|
+
rule_id="statement.locking",
|
|
95
|
+
category=Category.STATEMENT,
|
|
96
|
+
severity=Severity.HIGH,
|
|
97
|
+
action=Action.DENY,
|
|
98
|
+
message="Row-locking clauses (FOR UPDATE/SHARE) are not permitted.",
|
|
99
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Structural limits: joins, nesting depth and LIMIT policy."""
|
|
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
|
+
|
|
11
|
+
class StructureLimitsRule:
|
|
12
|
+
rule_ids = (
|
|
13
|
+
"structure.max_joins",
|
|
14
|
+
"structure.max_depth",
|
|
15
|
+
"structure.limit_exceeded",
|
|
16
|
+
"structure.limit_missing",
|
|
17
|
+
"structure.limit_unparseable",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
def check(self, ctx: RuleContext) -> Iterator[Finding]:
|
|
21
|
+
a = ctx.analysis
|
|
22
|
+
limits = ctx.limits
|
|
23
|
+
if limits.max_joins is not None and a.join_count > limits.max_joins:
|
|
24
|
+
yield Finding(
|
|
25
|
+
rule_id="structure.max_joins",
|
|
26
|
+
category=Category.STRUCTURE,
|
|
27
|
+
severity=Severity.MEDIUM,
|
|
28
|
+
action=Action.DENY,
|
|
29
|
+
message=f"The query uses {a.join_count} joins; the limit is {limits.max_joins}.",
|
|
30
|
+
)
|
|
31
|
+
if limits.max_subquery_depth is not None and a.max_depth > limits.max_subquery_depth:
|
|
32
|
+
yield Finding(
|
|
33
|
+
rule_id="structure.max_depth",
|
|
34
|
+
category=Category.STRUCTURE,
|
|
35
|
+
severity=Severity.MEDIUM,
|
|
36
|
+
action=Action.DENY,
|
|
37
|
+
message=f"Subqueries are nested {a.max_depth} levels deep; the limit is "
|
|
38
|
+
f"{limits.max_subquery_depth}.",
|
|
39
|
+
)
|
|
40
|
+
if not a.scopes:
|
|
41
|
+
return
|
|
42
|
+
root = a.scopes[-1] # traverse_scope yields the root scope last
|
|
43
|
+
if limits.max_limit is not None:
|
|
44
|
+
for facts in a.scopes:
|
|
45
|
+
if facts.limit_unparseable:
|
|
46
|
+
yield Finding(
|
|
47
|
+
rule_id="structure.limit_unparseable",
|
|
48
|
+
category=Category.STRUCTURE,
|
|
49
|
+
severity=Severity.MEDIUM,
|
|
50
|
+
action=Action.DENY,
|
|
51
|
+
message="LIMIT must be a numeric literal.",
|
|
52
|
+
)
|
|
53
|
+
break
|
|
54
|
+
if facts.limit is not None and facts.limit > limits.max_limit:
|
|
55
|
+
yield Finding(
|
|
56
|
+
rule_id="structure.limit_exceeded",
|
|
57
|
+
category=Category.STRUCTURE,
|
|
58
|
+
severity=Severity.MEDIUM,
|
|
59
|
+
action=Action.DENY,
|
|
60
|
+
message=f"LIMIT {facts.limit} exceeds the maximum of {limits.max_limit}.",
|
|
61
|
+
remediation=f"Use LIMIT {limits.max_limit} or less.",
|
|
62
|
+
)
|
|
63
|
+
break
|
|
64
|
+
if limits.require_limit and not root.has_limit:
|
|
65
|
+
yield Finding(
|
|
66
|
+
rule_id="structure.limit_missing",
|
|
67
|
+
category=Category.STRUCTURE,
|
|
68
|
+
severity=Severity.LOW,
|
|
69
|
+
action=Action.DENY,
|
|
70
|
+
message="The query must include a LIMIT clause.",
|
|
71
|
+
remediation=f"Add LIMIT {limits.max_limit or 100}.",
|
|
72
|
+
)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Tenant isolation: every read of a tenant-scoped table must be pinned to the caller's tenant.
|
|
2
|
+
|
|
3
|
+
Semantics (fail closed):
|
|
4
|
+
|
|
5
|
+
* The tenant value comes only from the trusted request context, never from the query.
|
|
6
|
+
* A table reference is *pinned* when, within the scope that reads it, its tenant column is
|
|
7
|
+
connected through ``=`` equalities to the context tenant value (as a literal or an
|
|
8
|
+
approved bind parameter). Only top-level ``AND`` conjuncts of ``WHERE`` and of
|
|
9
|
+
``INNER JOIN ... ON`` count, plus the ``ON`` clause of the ``LEFT JOIN`` that introduces
|
|
10
|
+
the reference. Anything under ``OR``/``NOT`` is ignored, so ``tenant_id = 1 OR 1 = 1``
|
|
11
|
+
does not pin anything.
|
|
12
|
+
* A filter applied in an *outer* scope does not pin a table read in an inner scope
|
|
13
|
+
(CTE or derived table). This is stricter than the database's semantics and is a known
|
|
14
|
+
source of false positives; it keeps the rule local and auditable.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Iterator
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from sqlglot import exp
|
|
23
|
+
|
|
24
|
+
from sqlguard_ninja.core.findings import Action, Category, Finding, Severity
|
|
25
|
+
from sqlguard_ninja.core.rules.base import RuleContext
|
|
26
|
+
|
|
27
|
+
Term = tuple[str, ...]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _UnionFind:
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
self.parent: dict[Term, Term] = {}
|
|
33
|
+
|
|
34
|
+
def find(self, x: Term) -> Term:
|
|
35
|
+
self.parent.setdefault(x, x)
|
|
36
|
+
while self.parent[x] != x:
|
|
37
|
+
self.parent[x] = self.parent[self.parent[x]]
|
|
38
|
+
x = self.parent[x]
|
|
39
|
+
return x
|
|
40
|
+
|
|
41
|
+
def union(self, a: Term, b: Term) -> None:
|
|
42
|
+
ra, rb = self.find(a), self.find(b)
|
|
43
|
+
if ra != rb:
|
|
44
|
+
self.parent[ra] = rb
|
|
45
|
+
|
|
46
|
+
def members(self, x: Term) -> list[Term]:
|
|
47
|
+
root = self.find(x)
|
|
48
|
+
return [t for t in list(self.parent) if self.find(t) == root]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _conjuncts(node: exp.Expr | None) -> list[exp.Expr]:
|
|
52
|
+
if node is None:
|
|
53
|
+
return []
|
|
54
|
+
if isinstance(node, (exp.Where, exp.Having)):
|
|
55
|
+
node = node.this
|
|
56
|
+
while isinstance(node, exp.Paren):
|
|
57
|
+
node = node.this
|
|
58
|
+
if isinstance(node, exp.And):
|
|
59
|
+
return _conjuncts(node.left) + _conjuncts(node.right)
|
|
60
|
+
return [node]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _term(node: exp.Expr) -> Term | None:
|
|
64
|
+
while isinstance(node, exp.Paren):
|
|
65
|
+
node = node.this
|
|
66
|
+
if isinstance(node, exp.Column) and node.table and not isinstance(node.this, exp.Star):
|
|
67
|
+
return ("col", node.table, node.name)
|
|
68
|
+
# Casts are unwrapped only around constants. A cast of the *column* can be lossy
|
|
69
|
+
# (e.g. text ids '1' and '01' both cast to 1), so it never counts as a pin.
|
|
70
|
+
while isinstance(node, (exp.Paren, exp.Cast, exp.TryCast)):
|
|
71
|
+
node = node.this
|
|
72
|
+
if isinstance(node, exp.Literal):
|
|
73
|
+
return ("const", str(node.this))
|
|
74
|
+
if isinstance(node, exp.Neg) and isinstance(node.this, exp.Literal):
|
|
75
|
+
return ("const", "-" + str(node.this.this))
|
|
76
|
+
if isinstance(node, exp.Placeholder):
|
|
77
|
+
return ("param", str(node.name or node.this or "?").lower())
|
|
78
|
+
if isinstance(node, exp.Parameter):
|
|
79
|
+
return ("param", str(node.name).lower())
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _add_equalities(uf: _UnionFind, preds: list[exp.Expr]) -> None:
|
|
84
|
+
for pred in preds:
|
|
85
|
+
if isinstance(pred, exp.EQ):
|
|
86
|
+
left, right = _term(pred.left), _term(pred.right)
|
|
87
|
+
if left and right:
|
|
88
|
+
uf.union(left, right)
|
|
89
|
+
elif isinstance(pred, exp.In) and not pred.args.get("query"):
|
|
90
|
+
values = pred.expressions
|
|
91
|
+
left = _term(pred.this)
|
|
92
|
+
if left and len(values) == 1:
|
|
93
|
+
right = _term(values[0])
|
|
94
|
+
if right:
|
|
95
|
+
uf.union(left, right)
|
|
96
|
+
elif left and len(values) > 1:
|
|
97
|
+
# Several tenants requested: record each so the rule can explain it.
|
|
98
|
+
for value in values:
|
|
99
|
+
t = _term(value)
|
|
100
|
+
if t:
|
|
101
|
+
uf.union(left, ("multi", *t))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TenantIsolationRule:
|
|
105
|
+
rule_ids = ("tenant.context_missing", "tenant.missing_predicate", "tenant.wrong_value")
|
|
106
|
+
|
|
107
|
+
def check(self, ctx: RuleContext) -> Iterator[Finding]:
|
|
108
|
+
if ctx.role is not None and not ctx.role.tenant_scoped:
|
|
109
|
+
return
|
|
110
|
+
catalog = ctx.policy.catalog
|
|
111
|
+
cfg = ctx.policy.tenant
|
|
112
|
+
tenant_value: Any = ctx.context.get(cfg.context_key)
|
|
113
|
+
params = {p.lower() for p in cfg.parameter_names}
|
|
114
|
+
reported_missing_ctx = False
|
|
115
|
+
|
|
116
|
+
for facts in ctx.analysis.scopes:
|
|
117
|
+
scoped = [
|
|
118
|
+
ref
|
|
119
|
+
for ref in facts.tables
|
|
120
|
+
if not ref.is_function
|
|
121
|
+
and ref.name in catalog.tables
|
|
122
|
+
and catalog.tables[ref.name].tenant_column
|
|
123
|
+
]
|
|
124
|
+
if not scoped or not isinstance(facts.expression, exp.Select):
|
|
125
|
+
continue
|
|
126
|
+
if tenant_value is None:
|
|
127
|
+
if not reported_missing_ctx:
|
|
128
|
+
reported_missing_ctx = True
|
|
129
|
+
yield Finding(
|
|
130
|
+
rule_id="tenant.context_missing",
|
|
131
|
+
category=Category.TENANT,
|
|
132
|
+
severity=Severity.CRITICAL,
|
|
133
|
+
action=Action.DENY,
|
|
134
|
+
message="The request has no tenant context; tenant-scoped data is "
|
|
135
|
+
"unavailable.",
|
|
136
|
+
detail=f"context key {cfg.context_key!r} not supplied by the application",
|
|
137
|
+
)
|
|
138
|
+
continue
|
|
139
|
+
select = facts.expression
|
|
140
|
+
joins: list[exp.Join] = list(select.args.get("joins") or [])
|
|
141
|
+
global_preds = _conjuncts(select.args.get("where"))
|
|
142
|
+
left_join_on: dict[str, list[exp.Expr]] = {}
|
|
143
|
+
for join in joins:
|
|
144
|
+
side = (join.side or "").upper()
|
|
145
|
+
kind = (join.kind or "").upper()
|
|
146
|
+
on_preds = _conjuncts(join.args.get("on"))
|
|
147
|
+
if not side and kind in ("", "INNER"):
|
|
148
|
+
global_preds += on_preds
|
|
149
|
+
elif side == "LEFT":
|
|
150
|
+
left_join_on[join.alias_or_name] = on_preds
|
|
151
|
+
|
|
152
|
+
expected = str(tenant_value)
|
|
153
|
+
for ref in scoped:
|
|
154
|
+
tenant_col = catalog.tables[ref.name].tenant_column
|
|
155
|
+
assert tenant_col is not None
|
|
156
|
+
uf = _UnionFind()
|
|
157
|
+
_add_equalities(uf, global_preds + left_join_on.get(ref.alias, []))
|
|
158
|
+
members = uf.members(("col", ref.alias, tenant_col))
|
|
159
|
+
pinned = any(
|
|
160
|
+
m == ("const", expected) or (m[0] == "param" and m[1] in params)
|
|
161
|
+
for m in members
|
|
162
|
+
)
|
|
163
|
+
others = sorted(
|
|
164
|
+
{m[-1] for m in members if m[0] in ("const", "multi") and m[-1] != expected}
|
|
165
|
+
)
|
|
166
|
+
multi = any(m[0] == "multi" for m in members)
|
|
167
|
+
table_txt = ctx.name("table", ref.name)
|
|
168
|
+
if pinned and not multi:
|
|
169
|
+
continue
|
|
170
|
+
if others or multi:
|
|
171
|
+
yield Finding(
|
|
172
|
+
rule_id="tenant.wrong_value",
|
|
173
|
+
category=Category.TENANT,
|
|
174
|
+
severity=Severity.CRITICAL,
|
|
175
|
+
action=Action.DENY,
|
|
176
|
+
message=f"The query filters {table_txt} by a tenant other than the "
|
|
177
|
+
"caller's own.",
|
|
178
|
+
detail=f"{ref.alias}.{tenant_col} compared with {others}",
|
|
179
|
+
table=ref.name,
|
|
180
|
+
column=tenant_col,
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
yield Finding(
|
|
184
|
+
rule_id="tenant.missing_predicate",
|
|
185
|
+
category=Category.TENANT,
|
|
186
|
+
severity=Severity.CRITICAL,
|
|
187
|
+
action=Action.DENY,
|
|
188
|
+
message=f"Every read of {table_txt} must filter on the caller's tenant.",
|
|
189
|
+
detail=f"no top-level equality on {ref.alias}.{tenant_col} in its scope",
|
|
190
|
+
table=ref.name,
|
|
191
|
+
column=tenant_col,
|
|
192
|
+
remediation=f"Add `{ref.alias}.{tenant_col} = :{cfg.parameter_names[0]}`"
|
|
193
|
+
" to the WHERE clause of the scope that reads this table."
|
|
194
|
+
if cfg.parameter_names
|
|
195
|
+
else None,
|
|
196
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Agent and application integrations.
|
|
2
|
+
|
|
3
|
+
The framework-specific modules (``langchain``, ``langgraph``, ``fastapi``) import their
|
|
4
|
+
frameworks and are only available with the matching extra installed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from sqlguard_ninja.integrations.audit import (
|
|
8
|
+
AuditEvent,
|
|
9
|
+
InMemoryAuditSink,
|
|
10
|
+
JsonlAuditSink,
|
|
11
|
+
LoggingAuditSink,
|
|
12
|
+
redact_sql,
|
|
13
|
+
)
|
|
14
|
+
from sqlguard_ninja.integrations.checkpoint import (
|
|
15
|
+
ApprovalDecision,
|
|
16
|
+
ApprovalQueue,
|
|
17
|
+
ApprovalRequest,
|
|
18
|
+
CallbackApprover,
|
|
19
|
+
CheckpointOutcome,
|
|
20
|
+
DenyAllApprover,
|
|
21
|
+
EnforcementMode,
|
|
22
|
+
RequestContext,
|
|
23
|
+
SQLBlockedError,
|
|
24
|
+
SQLCheckpoint,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"ApprovalDecision",
|
|
29
|
+
"ApprovalQueue",
|
|
30
|
+
"ApprovalRequest",
|
|
31
|
+
"AuditEvent",
|
|
32
|
+
"CallbackApprover",
|
|
33
|
+
"CheckpointOutcome",
|
|
34
|
+
"DenyAllApprover",
|
|
35
|
+
"EnforcementMode",
|
|
36
|
+
"InMemoryAuditSink",
|
|
37
|
+
"JsonlAuditSink",
|
|
38
|
+
"LoggingAuditSink",
|
|
39
|
+
"RequestContext",
|
|
40
|
+
"SQLBlockedError",
|
|
41
|
+
"SQLCheckpoint",
|
|
42
|
+
"redact_sql",
|
|
43
|
+
]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Audit events and sinks.
|
|
2
|
+
|
|
3
|
+
Events never contain literal values from the SQL by default: the statement is stored as its
|
|
4
|
+
SHA-256 plus a *redacted* form in which every literal is replaced by ``?``. That keeps
|
|
5
|
+
customer data and secrets that an LLM may have inlined out of logs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import threading
|
|
13
|
+
import uuid
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Protocol
|
|
17
|
+
|
|
18
|
+
import sqlglot
|
|
19
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
20
|
+
from sqlglot import exp
|
|
21
|
+
from sqlglot.errors import SqlglotError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def redact_sql(sql: str, dialect: str) -> str | None:
|
|
25
|
+
"""Replace every literal with ``?``. Returns None if the SQL cannot be parsed."""
|
|
26
|
+
try:
|
|
27
|
+
statements = [s for s in sqlglot.parse(sql, dialect=dialect) if s is not None]
|
|
28
|
+
except SqlglotError:
|
|
29
|
+
return None
|
|
30
|
+
if not statements:
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
def _strip(node: exp.Expr) -> exp.Expr:
|
|
34
|
+
if isinstance(node, exp.Literal):
|
|
35
|
+
return exp.Var(this="?") # dialect-neutral marker, never a bind parameter
|
|
36
|
+
return node
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
return "; ".join(s.transform(_strip).sql(dialect=dialect) for s in statements)
|
|
40
|
+
except SqlglotError:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AuditEvent(BaseModel):
|
|
45
|
+
model_config = ConfigDict(frozen=True)
|
|
46
|
+
|
|
47
|
+
event_id: str = Field(default_factory=lambda: uuid.uuid4().hex)
|
|
48
|
+
timestamp: str = Field(
|
|
49
|
+
default_factory=lambda: datetime.now(UTC).isoformat(timespec="milliseconds")
|
|
50
|
+
)
|
|
51
|
+
event_type: str = "sql.checkpoint"
|
|
52
|
+
trace_id: str
|
|
53
|
+
principal: str | None
|
|
54
|
+
role: str | None
|
|
55
|
+
tenant: str | None
|
|
56
|
+
mode: str
|
|
57
|
+
decision: str
|
|
58
|
+
authorized: bool
|
|
59
|
+
enforced: bool
|
|
60
|
+
would_block: bool
|
|
61
|
+
sql_sha256: str
|
|
62
|
+
sql_redacted: str | None
|
|
63
|
+
dialect: str
|
|
64
|
+
policy: str
|
|
65
|
+
findings: list[dict[str, Any]]
|
|
66
|
+
approval: dict[str, Any] | None = None
|
|
67
|
+
attempt: int = 1
|
|
68
|
+
elapsed_ms: float = 0.0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AuditSink(Protocol):
|
|
72
|
+
def emit(self, event: AuditEvent) -> None: ...
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class InMemoryAuditSink:
|
|
76
|
+
"""Keeps events in a list. For tests and demos."""
|
|
77
|
+
|
|
78
|
+
def __init__(self) -> None:
|
|
79
|
+
self.events: list[AuditEvent] = []
|
|
80
|
+
self._lock = threading.Lock()
|
|
81
|
+
|
|
82
|
+
def emit(self, event: AuditEvent) -> None:
|
|
83
|
+
with self._lock:
|
|
84
|
+
self.events.append(event)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class JsonlAuditSink:
|
|
88
|
+
"""Append one JSON object per line. Thread-safe within a process."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, path: str | Path) -> None:
|
|
91
|
+
self.path = Path(path)
|
|
92
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
self._lock = threading.Lock()
|
|
94
|
+
|
|
95
|
+
def emit(self, event: AuditEvent) -> None:
|
|
96
|
+
line = event.model_dump_json() + "\n"
|
|
97
|
+
with self._lock, self.path.open("a", encoding="utf-8") as fh:
|
|
98
|
+
fh.write(line)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class LoggingAuditSink:
|
|
102
|
+
"""Emit events through :mod:`logging` as JSON strings."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, logger: logging.Logger | None = None, level: int = logging.INFO) -> None:
|
|
105
|
+
self.logger = logger or logging.getLogger("sqlguard_ninja.audit")
|
|
106
|
+
self.level = level
|
|
107
|
+
|
|
108
|
+
def emit(self, event: AuditEvent) -> None:
|
|
109
|
+
self.logger.log(self.level, json.dumps(event.model_dump(mode="json")))
|