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,370 @@
|
|
|
1
|
+
"""Versioned, validated policy schema.
|
|
2
|
+
|
|
3
|
+
A policy describes *what exists* (the catalog and its classification labels) and *who may
|
|
4
|
+
see what* (roles). It is loaded from YAML or JSON and validated eagerly so that a typo in a
|
|
5
|
+
role grant fails at start-up instead of silently allowing or denying queries.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
17
|
+
|
|
18
|
+
from sqlguard_ninja.core.findings import Action
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PolicyError(ValueError):
|
|
22
|
+
"""Raised when a policy file is malformed or internally inconsistent."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Access(StrEnum):
|
|
26
|
+
"""How a role may use a column or a label."""
|
|
27
|
+
|
|
28
|
+
ALLOW = "allow"
|
|
29
|
+
AGGREGATE_ONLY = "aggregate_only"
|
|
30
|
+
REVIEW = "review"
|
|
31
|
+
DENY = "deny"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Sensitivity(StrEnum):
|
|
35
|
+
LOW = "low"
|
|
36
|
+
MEDIUM = "medium"
|
|
37
|
+
HIGH = "high"
|
|
38
|
+
CRITICAL = "critical"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _Model(BaseModel):
|
|
42
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _lower_keys(value: Any) -> Any:
|
|
46
|
+
if isinstance(value, dict):
|
|
47
|
+
return {str(k).lower(): v for k, v in value.items()}
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _lower_list(value: Any) -> Any:
|
|
52
|
+
if isinstance(value, list):
|
|
53
|
+
return [str(v).lower() for v in value]
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class LabelDef(_Model):
|
|
58
|
+
"""A classification label such as ``contact`` or ``financial``."""
|
|
59
|
+
|
|
60
|
+
description: str = ""
|
|
61
|
+
sensitivity: Sensitivity = Sensitivity.MEDIUM
|
|
62
|
+
masking: str | None = Field(
|
|
63
|
+
default=None, description="Recommended masking or minimisation technique."
|
|
64
|
+
)
|
|
65
|
+
special_category: bool = Field(
|
|
66
|
+
default=False, description="GDPR Art. 9 special-category data, when so configured."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ColumnDef(_Model):
|
|
71
|
+
"""Metadata for one column. Labels are configuration, never inferred from the name."""
|
|
72
|
+
|
|
73
|
+
type: str = "text"
|
|
74
|
+
labels: tuple[str, ...] = ()
|
|
75
|
+
identifying: bool = Field(
|
|
76
|
+
default=False,
|
|
77
|
+
description="Uniquely identifies a person/entity. Used for small-cohort checks.",
|
|
78
|
+
)
|
|
79
|
+
alternatives: tuple[str, ...] = Field(
|
|
80
|
+
default=(), description="Less sensitive columns of the same table that may suffice."
|
|
81
|
+
)
|
|
82
|
+
json_fields: dict[str, tuple[str, ...]] = Field(
|
|
83
|
+
default_factory=dict,
|
|
84
|
+
description="Labels for paths inside a JSON column, keyed by dotted path.",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
_norm_labels = field_validator("labels", "alternatives", mode="before")(_lower_list)
|
|
88
|
+
|
|
89
|
+
@field_validator("json_fields", mode="before")
|
|
90
|
+
@classmethod
|
|
91
|
+
def _norm_json(cls, value: Any) -> Any:
|
|
92
|
+
if isinstance(value, dict):
|
|
93
|
+
return {str(k).lower(): _lower_list(list(v)) for k, v in value.items()}
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
def effective_labels(self, json_path: tuple[str, ...] | None = None) -> set[str]:
|
|
97
|
+
"""Labels revealed by reading this column, or one JSON path inside it.
|
|
98
|
+
|
|
99
|
+
Reading the whole column reveals every nested field, so its labels are the union of
|
|
100
|
+
the column's own labels and all JSON field labels. Reading a path reveals the
|
|
101
|
+
column's labels plus labels of that path, its ancestors and its descendants.
|
|
102
|
+
"""
|
|
103
|
+
labels = set(self.labels)
|
|
104
|
+
if json_path is None:
|
|
105
|
+
for field_labels in self.json_fields.values():
|
|
106
|
+
labels.update(field_labels)
|
|
107
|
+
return labels
|
|
108
|
+
requested = ".".join(p.lower() for p in json_path)
|
|
109
|
+
for path, field_labels in self.json_fields.items():
|
|
110
|
+
if (
|
|
111
|
+
path == requested
|
|
112
|
+
or path.startswith(requested + ".")
|
|
113
|
+
or requested.startswith(path + ".")
|
|
114
|
+
):
|
|
115
|
+
labels.update(field_labels)
|
|
116
|
+
return labels
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TableDef(_Model):
|
|
120
|
+
description: str = ""
|
|
121
|
+
columns: dict[str, ColumnDef]
|
|
122
|
+
tenant_column: str | None = None
|
|
123
|
+
estimated_rows: int | None = Field(default=None, ge=0)
|
|
124
|
+
|
|
125
|
+
@field_validator("columns", mode="before")
|
|
126
|
+
@classmethod
|
|
127
|
+
def _norm_columns(cls, value: Any) -> Any:
|
|
128
|
+
if not isinstance(value, dict):
|
|
129
|
+
return value
|
|
130
|
+
out: dict[str, Any] = {}
|
|
131
|
+
for name, spec in value.items():
|
|
132
|
+
if spec is None:
|
|
133
|
+
spec = {}
|
|
134
|
+
elif isinstance(spec, str):
|
|
135
|
+
spec = {"type": spec}
|
|
136
|
+
elif isinstance(spec, list):
|
|
137
|
+
spec = {"labels": spec}
|
|
138
|
+
out[str(name).lower()] = spec
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
@field_validator("tenant_column", mode="before")
|
|
142
|
+
@classmethod
|
|
143
|
+
def _norm_tenant(cls, value: Any) -> Any:
|
|
144
|
+
return value.lower() if isinstance(value, str) else value
|
|
145
|
+
|
|
146
|
+
@model_validator(mode="after")
|
|
147
|
+
def _check(self) -> TableDef:
|
|
148
|
+
if self.tenant_column and self.tenant_column not in self.columns:
|
|
149
|
+
raise ValueError(f"tenant_column '{self.tenant_column}' is not a declared column")
|
|
150
|
+
for name, col in self.columns.items():
|
|
151
|
+
for alt in col.alternatives:
|
|
152
|
+
if alt not in self.columns:
|
|
153
|
+
raise ValueError(f"column '{name}' lists unknown alternative '{alt}'")
|
|
154
|
+
return self
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class Catalog(_Model):
|
|
158
|
+
default_schema: str | None = Field(
|
|
159
|
+
default=None,
|
|
160
|
+
description="Schema that unqualified names resolve to (e.g. 'public' or 'main').",
|
|
161
|
+
)
|
|
162
|
+
tables: dict[str, TableDef]
|
|
163
|
+
|
|
164
|
+
_norm = field_validator("tables", mode="before")(_lower_keys)
|
|
165
|
+
|
|
166
|
+
@field_validator("default_schema", mode="before")
|
|
167
|
+
@classmethod
|
|
168
|
+
def _norm_schema(cls, value: Any) -> Any:
|
|
169
|
+
return value.lower() if isinstance(value, str) else value
|
|
170
|
+
|
|
171
|
+
def sqlglot_schema(self) -> dict[str, dict[str, str]]:
|
|
172
|
+
return {
|
|
173
|
+
name: {col: spec.type for col, spec in table.columns.items()}
|
|
174
|
+
for name, table in self.tables.items()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class TableGrant(_Model):
|
|
179
|
+
"""Which columns of a table a role may reference."""
|
|
180
|
+
|
|
181
|
+
columns: Literal["*"] | tuple[str, ...] = "*"
|
|
182
|
+
deny_columns: tuple[str, ...] = ()
|
|
183
|
+
column_access: dict[str, Access] = Field(
|
|
184
|
+
default_factory=dict, description="Per-column overrides; most specific rule wins."
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
@field_validator("columns", mode="before")
|
|
188
|
+
@classmethod
|
|
189
|
+
def _norm_cols(cls, value: Any) -> Any:
|
|
190
|
+
return _lower_list(value) if isinstance(value, list) else value
|
|
191
|
+
|
|
192
|
+
_norm_deny = field_validator("deny_columns", mode="before")(_lower_list)
|
|
193
|
+
_norm_access = field_validator("column_access", mode="before")(_lower_keys)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class Limits(_Model):
|
|
197
|
+
max_sql_length: int = Field(default=20_000, gt=0)
|
|
198
|
+
max_ast_nodes: int = Field(default=5_000, gt=0)
|
|
199
|
+
max_joins: int | None = Field(default=8, ge=0)
|
|
200
|
+
max_subquery_depth: int | None = Field(default=5, ge=0)
|
|
201
|
+
max_limit: int | None = Field(default=None, gt=0)
|
|
202
|
+
require_limit: bool = False
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class Role(_Model):
|
|
206
|
+
description: str = ""
|
|
207
|
+
tables: dict[str, TableGrant] = Field(default_factory=dict)
|
|
208
|
+
labels: dict[str, Access] = Field(
|
|
209
|
+
default_factory=dict, description="Access per classification label."
|
|
210
|
+
)
|
|
211
|
+
unlisted_label_access: Access = Field(
|
|
212
|
+
default=Access.DENY,
|
|
213
|
+
description="Access for labelled columns whose label the role does not mention.",
|
|
214
|
+
)
|
|
215
|
+
tenant_scoped: bool = True
|
|
216
|
+
limits: Limits | None = None
|
|
217
|
+
|
|
218
|
+
_norm_labels = field_validator("labels", mode="before")(_lower_keys)
|
|
219
|
+
|
|
220
|
+
@field_validator("tables", mode="before")
|
|
221
|
+
@classmethod
|
|
222
|
+
def _grant_shorthand(cls, value: Any) -> Any:
|
|
223
|
+
# Allow `tables: {orders: "*"}` and `tables: {orders: [id, status]}`.
|
|
224
|
+
if isinstance(value, dict):
|
|
225
|
+
out: dict[str, Any] = {}
|
|
226
|
+
for name, grant in value.items():
|
|
227
|
+
if grant is None or grant == "*":
|
|
228
|
+
grant = {"columns": "*"}
|
|
229
|
+
elif isinstance(grant, list):
|
|
230
|
+
grant = {"columns": grant}
|
|
231
|
+
out[str(name).lower()] = grant
|
|
232
|
+
return out
|
|
233
|
+
return value
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class FunctionPolicy(_Model):
|
|
237
|
+
mode: Literal["denylist", "allowlist"] = "denylist"
|
|
238
|
+
deny: tuple[str, ...] = ()
|
|
239
|
+
allow: tuple[str, ...] = Field(
|
|
240
|
+
default=(),
|
|
241
|
+
description="In allowlist mode: the permitted functions. In denylist mode: "
|
|
242
|
+
"exceptions removed from the built-in deny list.",
|
|
243
|
+
)
|
|
244
|
+
review: tuple[str, ...] = ()
|
|
245
|
+
unknown_function_action: Action = Field(
|
|
246
|
+
default=Action.WARN,
|
|
247
|
+
description="Action for functions sqlglot does not recognise (possible UDFs).",
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
_norm = field_validator("deny", "allow", "review", mode="before")(_lower_list)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class TenantConfig(_Model):
|
|
254
|
+
context_key: str = "tenant_id"
|
|
255
|
+
parameter_names: tuple[str, ...] = Field(
|
|
256
|
+
default=("tenant_id",),
|
|
257
|
+
description="Bind-parameter names that the application fills with the trusted tenant.",
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class Defaults(_Model):
|
|
262
|
+
unknown_table_action: Action = Action.DENY
|
|
263
|
+
unresolved_column_action: Action = Action.DENY
|
|
264
|
+
unexpandable_star_action: Action = Action.DENY
|
|
265
|
+
catalog_access_action: Action = Action.DENY
|
|
266
|
+
minimization_action: Action = Action.WARN
|
|
267
|
+
small_cohort_action: Action = Action.REVIEW
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class CostThresholds(_Model):
|
|
271
|
+
"""Static and planner thresholds; see :mod:`sqlguard_ninja.query_cost`."""
|
|
272
|
+
|
|
273
|
+
enabled: bool = True
|
|
274
|
+
review_score: int = Field(default=50, ge=0, le=100)
|
|
275
|
+
deny_score: int = Field(default=80, ge=0, le=100)
|
|
276
|
+
large_table_rows: int = Field(default=1_000_000, ge=0)
|
|
277
|
+
max_plan_total_cost: float | None = None
|
|
278
|
+
max_plan_rows: float | None = None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class Disclosure(StrEnum):
|
|
282
|
+
"""How much schema detail agent-facing messages may contain."""
|
|
283
|
+
|
|
284
|
+
NAMES = "names"
|
|
285
|
+
GENERIC = "generic"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class Policy(_Model):
|
|
289
|
+
"""Top-level policy document (``version: 1``)."""
|
|
290
|
+
|
|
291
|
+
version: Literal[1] = 1
|
|
292
|
+
name: str
|
|
293
|
+
description: str = ""
|
|
294
|
+
dialect: str = "postgres"
|
|
295
|
+
catalog: Catalog
|
|
296
|
+
labels: dict[str, LabelDef] = Field(default_factory=dict)
|
|
297
|
+
roles: dict[str, Role] = Field(default_factory=dict)
|
|
298
|
+
default_role: str | None = None
|
|
299
|
+
tenant: TenantConfig = Field(default_factory=TenantConfig)
|
|
300
|
+
functions: FunctionPolicy = Field(default_factory=FunctionPolicy)
|
|
301
|
+
limits: Limits = Field(default_factory=Limits)
|
|
302
|
+
defaults: Defaults = Field(default_factory=Defaults)
|
|
303
|
+
cost: CostThresholds = Field(default_factory=CostThresholds)
|
|
304
|
+
disclosure: Disclosure = Disclosure.NAMES
|
|
305
|
+
|
|
306
|
+
_norm_labels = field_validator("labels", mode="before")(_lower_keys)
|
|
307
|
+
|
|
308
|
+
@model_validator(mode="after")
|
|
309
|
+
def _check_references(self) -> Policy:
|
|
310
|
+
errors: list[str] = []
|
|
311
|
+
for tname, table in self.catalog.tables.items():
|
|
312
|
+
for cname, col in table.columns.items():
|
|
313
|
+
for label in col.effective_labels():
|
|
314
|
+
if label not in self.labels:
|
|
315
|
+
errors.append(f"{tname}.{cname}: unknown label '{label}'")
|
|
316
|
+
for rname, role in self.roles.items():
|
|
317
|
+
for label in role.labels:
|
|
318
|
+
if label not in self.labels:
|
|
319
|
+
errors.append(f"role '{rname}': unknown label '{label}'")
|
|
320
|
+
for tname, grant in role.tables.items():
|
|
321
|
+
tdef = self.catalog.tables.get(tname)
|
|
322
|
+
if tdef is None:
|
|
323
|
+
errors.append(f"role '{rname}': grant on unknown table '{tname}'")
|
|
324
|
+
continue
|
|
325
|
+
named = [
|
|
326
|
+
*(grant.columns if grant.columns != "*" else ()),
|
|
327
|
+
*grant.deny_columns,
|
|
328
|
+
*grant.column_access,
|
|
329
|
+
]
|
|
330
|
+
for cname in named:
|
|
331
|
+
if cname not in tdef.columns:
|
|
332
|
+
errors.append(f"role '{rname}': unknown column '{tname}.{cname}'")
|
|
333
|
+
if self.default_role and self.default_role not in self.roles:
|
|
334
|
+
errors.append(f"default_role '{self.default_role}' is not defined")
|
|
335
|
+
if self.cost.review_score > self.cost.deny_score:
|
|
336
|
+
errors.append("cost.review_score must not exceed cost.deny_score")
|
|
337
|
+
if errors:
|
|
338
|
+
raise ValueError("; ".join(errors))
|
|
339
|
+
return self
|
|
340
|
+
|
|
341
|
+
# ------------------------------------------------------------------ loading
|
|
342
|
+
@classmethod
|
|
343
|
+
def from_dict(cls, data: dict[str, Any]) -> Policy:
|
|
344
|
+
try:
|
|
345
|
+
return cls.model_validate(data)
|
|
346
|
+
except ValueError as exc: # pydantic.ValidationError subclasses ValueError
|
|
347
|
+
raise PolicyError(f"invalid policy: {exc}") from exc
|
|
348
|
+
|
|
349
|
+
@classmethod
|
|
350
|
+
def from_file(cls, path: str | Path) -> Policy:
|
|
351
|
+
"""Load a policy from ``.yaml``/``.yml`` or ``.json``."""
|
|
352
|
+
p = Path(path)
|
|
353
|
+
try:
|
|
354
|
+
text = p.read_text(encoding="utf-8")
|
|
355
|
+
except OSError as exc:
|
|
356
|
+
raise PolicyError(f"cannot read policy file {p}: {exc}") from exc
|
|
357
|
+
data = json.loads(text) if p.suffix.lower() == ".json" else yaml.safe_load(text)
|
|
358
|
+
if not isinstance(data, dict):
|
|
359
|
+
raise PolicyError(f"policy file {p} must contain a mapping at the top level")
|
|
360
|
+
return cls.from_dict(data)
|
|
361
|
+
|
|
362
|
+
def role(self, name: str | None) -> tuple[str | None, Role | None]:
|
|
363
|
+
"""Resolve a role name (falling back to ``default_role``)."""
|
|
364
|
+
key = name or self.default_role
|
|
365
|
+
if key is None:
|
|
366
|
+
return None, None
|
|
367
|
+
return key, self.roles.get(key)
|
|
368
|
+
|
|
369
|
+
def effective_limits(self, role: Role | None) -> Limits:
|
|
370
|
+
return role.limits if role is not None and role.limits is not None else self.limits
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Privacy policy pack (classification labels, aggregate-only access, minimisation)."""
|
|
2
|
+
|
|
3
|
+
from sqlguard_ninja.privacy.rules import (
|
|
4
|
+
ColumnAccessDecision,
|
|
5
|
+
PrivacyPolicyRule,
|
|
6
|
+
resolve_column_access,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = ["ColumnAccessDecision", "PrivacyPolicyRule", "resolve_column_access"]
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Privacy policy pack: label-based column restrictions, aggregate-only access, data
|
|
2
|
+
minimisation and small-cohort re-identification warnings.
|
|
3
|
+
|
|
4
|
+
This is a technical control library. It helps implement data-minimisation and
|
|
5
|
+
need-to-know principles; it does not certify GDPR compliance.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from sqlguard_ninja.core.analysis import AggregateKind, Clause, ColumnRef
|
|
14
|
+
from sqlguard_ninja.core.findings import Action, Category, Finding, Severity
|
|
15
|
+
from sqlguard_ninja.core.rules.base import RuleContext
|
|
16
|
+
from sqlguard_ninja.policy.model import Access, ColumnDef, Disclosure, Policy, Role, Sensitivity
|
|
17
|
+
|
|
18
|
+
_SEVERITY = {
|
|
19
|
+
Sensitivity.LOW: Severity.LOW,
|
|
20
|
+
Sensitivity.MEDIUM: Severity.MEDIUM,
|
|
21
|
+
Sensitivity.HIGH: Severity.HIGH,
|
|
22
|
+
Sensitivity.CRITICAL: Severity.CRITICAL,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ColumnAccessDecision:
|
|
28
|
+
"""How a role may use one column, and why."""
|
|
29
|
+
|
|
30
|
+
restrictions: tuple[tuple[Access, str], ...]
|
|
31
|
+
"""(access, source) pairs; source is ``column:<name>`` or ``label:<label>``."""
|
|
32
|
+
labels: frozenset[str]
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def strongest(self) -> Access:
|
|
36
|
+
order = [Access.DENY, Access.AGGREGATE_ONLY, Access.REVIEW, Access.ALLOW]
|
|
37
|
+
present = {a for a, _ in self.restrictions}
|
|
38
|
+
return next((a for a in order if a in present), Access.ALLOW)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve_column_access(
|
|
42
|
+
policy: Policy,
|
|
43
|
+
role: Role | None,
|
|
44
|
+
table: str,
|
|
45
|
+
column: str,
|
|
46
|
+
json_path: tuple[str, ...] | None = None,
|
|
47
|
+
) -> ColumnAccessDecision:
|
|
48
|
+
"""Resolve effective access: explicit column rule → label rules → role default."""
|
|
49
|
+
tdef = policy.catalog.tables.get(table)
|
|
50
|
+
cdef: ColumnDef | None = tdef.columns.get(column) if tdef else None
|
|
51
|
+
labels = frozenset(cdef.effective_labels(json_path)) if cdef else frozenset()
|
|
52
|
+
if role is not None:
|
|
53
|
+
grant = role.tables.get(table)
|
|
54
|
+
if grant is not None and column in grant.column_access:
|
|
55
|
+
return ColumnAccessDecision(
|
|
56
|
+
((grant.column_access[column], f"column:{table}.{column}"),), labels
|
|
57
|
+
)
|
|
58
|
+
restrictions: list[tuple[Access, str]] = []
|
|
59
|
+
for label in sorted(labels):
|
|
60
|
+
access = Access.DENY if role is None else role.labels.get(label, role.unlisted_label_access)
|
|
61
|
+
if access is not Access.ALLOW:
|
|
62
|
+
restrictions.append((access, f"label:{label}"))
|
|
63
|
+
return ColumnAccessDecision(tuple(restrictions), labels)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class PrivacyPolicyRule:
|
|
67
|
+
rule_ids = (
|
|
68
|
+
"privacy.label_denied",
|
|
69
|
+
"privacy.aggregate_only",
|
|
70
|
+
"privacy.label_review",
|
|
71
|
+
"privacy.small_cohort",
|
|
72
|
+
"privacy.minimization",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def check(self, ctx: RuleContext) -> Iterator[Finding]:
|
|
76
|
+
policy = ctx.policy
|
|
77
|
+
role = ctx.role
|
|
78
|
+
emitted: set[tuple[str, str, str]] = set()
|
|
79
|
+
aggregate_only_used = False
|
|
80
|
+
star_labelled: dict[str, set[str]] = {}
|
|
81
|
+
|
|
82
|
+
for ref in ctx.analysis.columns:
|
|
83
|
+
tdef = policy.catalog.tables.get(ref.table)
|
|
84
|
+
if tdef is None or ref.column not in tdef.columns:
|
|
85
|
+
continue
|
|
86
|
+
if role is not None and ref.table not in role.tables:
|
|
87
|
+
continue
|
|
88
|
+
decision = resolve_column_access(policy, role, ref.table, ref.column, ref.json_path)
|
|
89
|
+
if ref.whole_row and decision.labels:
|
|
90
|
+
star_labelled.setdefault(ref.table, set()).update(decision.labels)
|
|
91
|
+
for access, source in decision.restrictions:
|
|
92
|
+
finding = self._finding_for(ctx, ref, access, source, decision.labels)
|
|
93
|
+
if finding is None:
|
|
94
|
+
if access is Access.AGGREGATE_ONLY:
|
|
95
|
+
aggregate_only_used = True
|
|
96
|
+
continue
|
|
97
|
+
key = (finding.rule_id, f"{ref.table}.{ref.column}", "")
|
|
98
|
+
if key in emitted:
|
|
99
|
+
continue
|
|
100
|
+
emitted.add(key)
|
|
101
|
+
yield finding
|
|
102
|
+
|
|
103
|
+
if aggregate_only_used:
|
|
104
|
+
yield from self._small_cohort(ctx)
|
|
105
|
+
|
|
106
|
+
for table, labels in sorted(star_labelled.items()):
|
|
107
|
+
yield Finding(
|
|
108
|
+
rule_id="privacy.minimization",
|
|
109
|
+
category=Category.PRIVACY,
|
|
110
|
+
severity=Severity.LOW,
|
|
111
|
+
action=policy.defaults.minimization_action,
|
|
112
|
+
message=f"Selecting every column of {ctx.name('table', table)} returns "
|
|
113
|
+
"classified data; select only the columns the task needs.",
|
|
114
|
+
labels=tuple(sorted(labels)),
|
|
115
|
+
table=table,
|
|
116
|
+
)
|
|
117
|
+
yield from self._alternatives(ctx)
|
|
118
|
+
|
|
119
|
+
# -------------------------------------------------------------- helpers
|
|
120
|
+
def _finding_for(
|
|
121
|
+
self,
|
|
122
|
+
ctx: RuleContext,
|
|
123
|
+
ref: ColumnRef,
|
|
124
|
+
access: Access,
|
|
125
|
+
source: str,
|
|
126
|
+
labels: frozenset[str],
|
|
127
|
+
) -> Finding | None:
|
|
128
|
+
policy = ctx.policy
|
|
129
|
+
col_txt = ctx.name("column", f"{ref.table}.{ref.column}")
|
|
130
|
+
label_names = tuple(sorted(labels))
|
|
131
|
+
label_defs = [policy.labels[x] for x in label_names if x in policy.labels]
|
|
132
|
+
severity = max(
|
|
133
|
+
(_SEVERITY[d.sensitivity] for d in label_defs),
|
|
134
|
+
default=Severity.MEDIUM,
|
|
135
|
+
key=list(Severity).index,
|
|
136
|
+
)
|
|
137
|
+
special = any(d.special_category for d in label_defs)
|
|
138
|
+
masking = next((d.masking for d in label_defs if d.masking), None)
|
|
139
|
+
alt = self._allowed_alternative(ctx, ref)
|
|
140
|
+
remediation_parts = []
|
|
141
|
+
if alt:
|
|
142
|
+
remediation_parts.append(f"Use {ctx.name('column', alt)} instead.")
|
|
143
|
+
if masking:
|
|
144
|
+
remediation_parts.append(f"Recommended treatment: {masking}.")
|
|
145
|
+
remediation = " ".join(remediation_parts) or None
|
|
146
|
+
path_txt = f" (JSON path {'.'.join(ref.json_path)})" if ref.json_path else ""
|
|
147
|
+
cls_txt = (
|
|
148
|
+
"special-category personal data"
|
|
149
|
+
if special
|
|
150
|
+
else ("classified data (" + ", ".join(label_names) + ")" if label_names else "data")
|
|
151
|
+
)
|
|
152
|
+
if policy.disclosure == Disclosure.GENERIC:
|
|
153
|
+
cls_txt = "classified data"
|
|
154
|
+
meta = {"source": source, "clause": ref.clause.value, "aggregate": ref.aggregate.value}
|
|
155
|
+
|
|
156
|
+
if access is Access.DENY:
|
|
157
|
+
return Finding(
|
|
158
|
+
rule_id="privacy.label_denied",
|
|
159
|
+
category=Category.PRIVACY,
|
|
160
|
+
severity=Severity.CRITICAL if special else severity,
|
|
161
|
+
action=Action.DENY,
|
|
162
|
+
message=f"The {col_txt}{path_txt} contains {cls_txt} that this role may not "
|
|
163
|
+
"access.",
|
|
164
|
+
detail=f"restricted by {source}",
|
|
165
|
+
table=ref.table,
|
|
166
|
+
column=ref.column,
|
|
167
|
+
labels=label_names,
|
|
168
|
+
remediation=remediation,
|
|
169
|
+
metadata=meta,
|
|
170
|
+
)
|
|
171
|
+
if access is Access.AGGREGATE_ONLY:
|
|
172
|
+
if ref.aggregate is AggregateKind.STATISTICAL:
|
|
173
|
+
return None
|
|
174
|
+
where = (
|
|
175
|
+
"in a filter, grouping or ordering"
|
|
176
|
+
if ref.clause in (Clause.WHERE, Clause.JOIN, Clause.GROUP, Clause.ORDER)
|
|
177
|
+
else "as individual values"
|
|
178
|
+
)
|
|
179
|
+
return Finding(
|
|
180
|
+
rule_id="privacy.aggregate_only",
|
|
181
|
+
category=Category.PRIVACY,
|
|
182
|
+
severity=severity,
|
|
183
|
+
action=Action.DENY,
|
|
184
|
+
message=f"The {col_txt} may only be used inside statistical aggregates "
|
|
185
|
+
f"(COUNT, SUM, AVG, ...), not {where}.",
|
|
186
|
+
detail=f"restricted by {source}; aggregate={ref.aggregate.value}",
|
|
187
|
+
table=ref.table,
|
|
188
|
+
column=ref.column,
|
|
189
|
+
labels=label_names,
|
|
190
|
+
remediation=remediation
|
|
191
|
+
or "Aggregate the column (e.g. AVG) and group by non-identifying attributes.",
|
|
192
|
+
metadata=meta,
|
|
193
|
+
)
|
|
194
|
+
if access is Access.REVIEW:
|
|
195
|
+
return Finding(
|
|
196
|
+
rule_id="privacy.label_review",
|
|
197
|
+
category=Category.PRIVACY,
|
|
198
|
+
severity=severity,
|
|
199
|
+
action=Action.REVIEW,
|
|
200
|
+
message=f"Access to the {col_txt} ({cls_txt}) requires human approval.",
|
|
201
|
+
detail=f"restricted by {source}",
|
|
202
|
+
table=ref.table,
|
|
203
|
+
column=ref.column,
|
|
204
|
+
labels=label_names,
|
|
205
|
+
remediation=remediation,
|
|
206
|
+
metadata=meta,
|
|
207
|
+
)
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
def _allowed_alternative(self, ctx: RuleContext, ref: ColumnRef) -> str | None:
|
|
211
|
+
tdef = ctx.policy.catalog.tables[ref.table]
|
|
212
|
+
cdef = tdef.columns[ref.column]
|
|
213
|
+
for alt in cdef.alternatives:
|
|
214
|
+
decision = resolve_column_access(ctx.policy, ctx.role, ref.table, alt)
|
|
215
|
+
if decision.strongest is Access.ALLOW and self._granted(ctx, ref.table, alt):
|
|
216
|
+
return f"{ref.table}.{alt}"
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
@staticmethod
|
|
220
|
+
def _granted(ctx: RuleContext, table: str, column: str) -> bool:
|
|
221
|
+
if ctx.role is None:
|
|
222
|
+
return True
|
|
223
|
+
grant = ctx.role.tables.get(table)
|
|
224
|
+
if grant is None or column in grant.deny_columns:
|
|
225
|
+
return False
|
|
226
|
+
return grant.columns == "*" or column in grant.columns
|
|
227
|
+
|
|
228
|
+
def _small_cohort(self, ctx: RuleContext) -> Iterator[Finding]:
|
|
229
|
+
"""Aggregates over a group filtered or grouped by an identifier reveal single values."""
|
|
230
|
+
catalog = ctx.policy.catalog
|
|
231
|
+
for ref in ctx.analysis.columns:
|
|
232
|
+
if ref.clause not in (Clause.WHERE, Clause.GROUP, Clause.JOIN, Clause.HAVING):
|
|
233
|
+
continue
|
|
234
|
+
if ref.clause is Clause.JOIN:
|
|
235
|
+
continue # join keys are expected to be identifiers
|
|
236
|
+
tdef = catalog.tables.get(ref.table)
|
|
237
|
+
if tdef is None or ref.column not in tdef.columns:
|
|
238
|
+
continue
|
|
239
|
+
if tdef.columns[ref.column].identifying and ref.aggregate is AggregateKind.NONE:
|
|
240
|
+
yield Finding(
|
|
241
|
+
rule_id="privacy.small_cohort",
|
|
242
|
+
category=Category.PRIVACY,
|
|
243
|
+
severity=Severity.MEDIUM,
|
|
244
|
+
action=ctx.policy.defaults.small_cohort_action,
|
|
245
|
+
message="An aggregate of restricted data is filtered or grouped by an "
|
|
246
|
+
"identifying column, which can reveal individual values.",
|
|
247
|
+
detail=f"identifying column {ref.table}.{ref.column} in {ref.clause.value}",
|
|
248
|
+
table=ref.table,
|
|
249
|
+
column=ref.column,
|
|
250
|
+
remediation="Group by non-identifying attributes and ensure groups are "
|
|
251
|
+
"large enough (k-anonymity is not checked statically).",
|
|
252
|
+
)
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
def _alternatives(self, ctx: RuleContext) -> Iterator[Finding]:
|
|
256
|
+
"""Suggest less sensitive alternatives for permitted-but-labelled columns."""
|
|
257
|
+
seen: set[tuple[str, str]] = set()
|
|
258
|
+
for ref in ctx.analysis.columns:
|
|
259
|
+
if ref.whole_row or ref.clause is not Clause.SELECT:
|
|
260
|
+
continue
|
|
261
|
+
key = (ref.table, ref.column)
|
|
262
|
+
if key in seen:
|
|
263
|
+
continue
|
|
264
|
+
seen.add(key)
|
|
265
|
+
tdef = ctx.policy.catalog.tables.get(ref.table)
|
|
266
|
+
if tdef is None or ref.column not in tdef.columns:
|
|
267
|
+
continue
|
|
268
|
+
if not tdef.columns[ref.column].alternatives:
|
|
269
|
+
continue
|
|
270
|
+
decision = resolve_column_access(ctx.policy, ctx.role, ref.table, ref.column)
|
|
271
|
+
if decision.strongest is not Access.ALLOW or not decision.labels:
|
|
272
|
+
continue
|
|
273
|
+
alt = self._allowed_alternative(ctx, ref)
|
|
274
|
+
if alt:
|
|
275
|
+
yield Finding(
|
|
276
|
+
rule_id="privacy.minimization",
|
|
277
|
+
category=Category.PRIVACY,
|
|
278
|
+
severity=Severity.INFO,
|
|
279
|
+
action=Action.WARN,
|
|
280
|
+
message=f"The {ctx.name('column', f'{ref.table}.{ref.column}')} is "
|
|
281
|
+
f"classified; {ctx.name('column', alt)} may be sufficient.",
|
|
282
|
+
table=ref.table,
|
|
283
|
+
column=ref.column,
|
|
284
|
+
labels=tuple(sorted(decision.labels)),
|
|
285
|
+
remediation=f"Prefer {alt}.",
|
|
286
|
+
)
|