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,15 @@
|
|
|
1
|
+
name: sqlguard-ninja-core
|
|
2
|
+
version: "1.0.0"
|
|
3
|
+
description: >
|
|
4
|
+
Labelled SQL policy-enforcement cases for a synthetic multi-tenant retail schema.
|
|
5
|
+
Expected outcomes follow the policy semantics in docs/guides/BENCHMARK_METHODOLOGY.md and
|
|
6
|
+
are independent of any particular validator.
|
|
7
|
+
policy: ../../policies/retail_saas.yaml
|
|
8
|
+
default_context:
|
|
9
|
+
tenant_id: 1
|
|
10
|
+
case_files:
|
|
11
|
+
- cases/*.yaml
|
|
12
|
+
- generated/*.yaml
|
|
13
|
+
generator:
|
|
14
|
+
script: scripts/generate_corpus.py
|
|
15
|
+
seed: 20261024
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# Benchmark policy for the synthetic multi-tenant retail SaaS schema.
|
|
2
|
+
# All data described here is synthetic. Classification labels are assigned explicitly;
|
|
3
|
+
# SQLGuard Ninja never infers sensitivity from column names when enforcing.
|
|
4
|
+
version: 1
|
|
5
|
+
name: retail_saas
|
|
6
|
+
description: Multi-tenant retail platform with support, warehouse, HR, finance and reporting roles.
|
|
7
|
+
dialect: postgres
|
|
8
|
+
|
|
9
|
+
labels:
|
|
10
|
+
personal_identifier:
|
|
11
|
+
description: Directly identifies a natural person (name, date of birth).
|
|
12
|
+
sensitivity: high
|
|
13
|
+
masking: pseudonymise or aggregate
|
|
14
|
+
contact:
|
|
15
|
+
description: Email, phone or postal address.
|
|
16
|
+
sensitivity: high
|
|
17
|
+
masking: return the email domain or a masked value
|
|
18
|
+
location:
|
|
19
|
+
description: City-level location.
|
|
20
|
+
sensitivity: medium
|
|
21
|
+
financial:
|
|
22
|
+
description: Bank details and individual financial amounts.
|
|
23
|
+
sensitivity: high
|
|
24
|
+
masking: aggregate
|
|
25
|
+
payment_card:
|
|
26
|
+
description: Primary account number (PCI DSS scope).
|
|
27
|
+
sensitivity: critical
|
|
28
|
+
masking: use card_last4 and card_brand
|
|
29
|
+
hr:
|
|
30
|
+
description: Employment terms and performance.
|
|
31
|
+
sensitivity: high
|
|
32
|
+
masking: aggregate by department
|
|
33
|
+
government_id:
|
|
34
|
+
description: National identification numbers.
|
|
35
|
+
sensitivity: critical
|
|
36
|
+
special_category:
|
|
37
|
+
description: Health data (GDPR Art. 9) — configured explicitly for this schema.
|
|
38
|
+
sensitivity: critical
|
|
39
|
+
special_category: true
|
|
40
|
+
free_text:
|
|
41
|
+
description: Unstructured text that may contain personal data.
|
|
42
|
+
sensitivity: medium
|
|
43
|
+
|
|
44
|
+
catalog:
|
|
45
|
+
default_schema: public
|
|
46
|
+
tables:
|
|
47
|
+
tenants:
|
|
48
|
+
columns: {id: int, name: text, plan: text, created_at: timestamp}
|
|
49
|
+
estimated_rows: 500
|
|
50
|
+
customers:
|
|
51
|
+
tenant_column: tenant_id
|
|
52
|
+
estimated_rows: 2000000
|
|
53
|
+
columns:
|
|
54
|
+
id: {type: int, identifying: true}
|
|
55
|
+
tenant_id: int
|
|
56
|
+
full_name: {labels: [personal_identifier]}
|
|
57
|
+
email: {labels: [contact], alternatives: [email_domain]}
|
|
58
|
+
email_domain: text
|
|
59
|
+
phone: {labels: [contact]}
|
|
60
|
+
date_of_birth: {type: date, labels: [personal_identifier]}
|
|
61
|
+
country: text
|
|
62
|
+
city: {labels: [location]}
|
|
63
|
+
loyalty_tier: text
|
|
64
|
+
marketing_opt_in: boolean
|
|
65
|
+
created_at: timestamp
|
|
66
|
+
profile:
|
|
67
|
+
type: json
|
|
68
|
+
json_fields:
|
|
69
|
+
preferences.newsletter: []
|
|
70
|
+
preferences.language: []
|
|
71
|
+
health.allergies: [special_category]
|
|
72
|
+
payment.card_number: [payment_card]
|
|
73
|
+
orders:
|
|
74
|
+
tenant_column: tenant_id
|
|
75
|
+
estimated_rows: 10000000
|
|
76
|
+
columns:
|
|
77
|
+
id: {type: int, identifying: false}
|
|
78
|
+
tenant_id: int
|
|
79
|
+
customer_id: int
|
|
80
|
+
status: text
|
|
81
|
+
total_amount: numeric
|
|
82
|
+
currency: text
|
|
83
|
+
created_at: timestamp
|
|
84
|
+
shipping_address: {labels: [contact, location]}
|
|
85
|
+
order_items:
|
|
86
|
+
tenant_column: tenant_id
|
|
87
|
+
estimated_rows: 30000000
|
|
88
|
+
columns: {id: int, tenant_id: int, order_id: int, product_id: int, quantity: int, unit_price: numeric}
|
|
89
|
+
products:
|
|
90
|
+
tenant_column: tenant_id
|
|
91
|
+
estimated_rows: 50000
|
|
92
|
+
columns: {id: int, tenant_id: int, sku: text, name: text, category: text, price: numeric}
|
|
93
|
+
payments:
|
|
94
|
+
tenant_column: tenant_id
|
|
95
|
+
estimated_rows: 10000000
|
|
96
|
+
columns:
|
|
97
|
+
id: int
|
|
98
|
+
tenant_id: int
|
|
99
|
+
order_id: int
|
|
100
|
+
card_number: {labels: [payment_card], alternatives: [card_last4]}
|
|
101
|
+
card_last4: text
|
|
102
|
+
card_brand: text
|
|
103
|
+
iban: {labels: [financial]}
|
|
104
|
+
amount: numeric
|
|
105
|
+
status: text
|
|
106
|
+
shipments:
|
|
107
|
+
tenant_column: tenant_id
|
|
108
|
+
estimated_rows: 9000000
|
|
109
|
+
columns:
|
|
110
|
+
id: int
|
|
111
|
+
tenant_id: int
|
|
112
|
+
order_id: int
|
|
113
|
+
warehouse_id: int
|
|
114
|
+
carrier: text
|
|
115
|
+
tracking_number: text
|
|
116
|
+
status: text
|
|
117
|
+
shipped_at: timestamp
|
|
118
|
+
delivery_address: {labels: [contact, location]}
|
|
119
|
+
warehouses:
|
|
120
|
+
estimated_rows: 40
|
|
121
|
+
columns: {id: int, name: text, region: text}
|
|
122
|
+
employees:
|
|
123
|
+
estimated_rows: 5000
|
|
124
|
+
columns:
|
|
125
|
+
id: {type: int, identifying: true}
|
|
126
|
+
full_name: {labels: [personal_identifier]}
|
|
127
|
+
email: {labels: [contact]}
|
|
128
|
+
department: text
|
|
129
|
+
title: text
|
|
130
|
+
salary: {type: numeric, labels: [hr, financial]}
|
|
131
|
+
bank_account: {labels: [financial]}
|
|
132
|
+
national_id: {labels: [government_id]}
|
|
133
|
+
health_notes: {labels: [special_category]}
|
|
134
|
+
hire_date: date
|
|
135
|
+
manager_id: int
|
|
136
|
+
warehouse_id: int
|
|
137
|
+
support_tickets:
|
|
138
|
+
tenant_column: tenant_id
|
|
139
|
+
estimated_rows: 3000000
|
|
140
|
+
columns:
|
|
141
|
+
id: int
|
|
142
|
+
tenant_id: int
|
|
143
|
+
customer_id: int
|
|
144
|
+
subject: text
|
|
145
|
+
body: {labels: [free_text]}
|
|
146
|
+
status: text
|
|
147
|
+
priority: text
|
|
148
|
+
created_at: timestamp
|
|
149
|
+
|
|
150
|
+
roles:
|
|
151
|
+
support_analyst:
|
|
152
|
+
description: Customer support. Order status yes; full payment details no.
|
|
153
|
+
tables:
|
|
154
|
+
customers: [id, tenant_id, full_name, email, email_domain, country, city, loyalty_tier, created_at, profile]
|
|
155
|
+
orders: "*"
|
|
156
|
+
order_items: "*"
|
|
157
|
+
products: "*"
|
|
158
|
+
shipments: "*"
|
|
159
|
+
support_tickets: "*"
|
|
160
|
+
payments: [id, tenant_id, order_id, card_last4, card_brand, amount, status]
|
|
161
|
+
labels:
|
|
162
|
+
personal_identifier: allow
|
|
163
|
+
contact: allow
|
|
164
|
+
location: allow
|
|
165
|
+
free_text: allow
|
|
166
|
+
payment_card: deny
|
|
167
|
+
financial: deny
|
|
168
|
+
special_category: deny
|
|
169
|
+
|
|
170
|
+
warehouse_analyst:
|
|
171
|
+
description: Shipments and stock. No HR or customer contact data.
|
|
172
|
+
tables:
|
|
173
|
+
shipments: "*"
|
|
174
|
+
warehouses: "*"
|
|
175
|
+
products: "*"
|
|
176
|
+
order_items: "*"
|
|
177
|
+
orders: [id, tenant_id, status, created_at]
|
|
178
|
+
labels:
|
|
179
|
+
location: aggregate_only
|
|
180
|
+
unlisted_label_access: deny
|
|
181
|
+
|
|
182
|
+
hr_analyst:
|
|
183
|
+
description: HR operations. Salary only in aggregate; never national ids or health notes.
|
|
184
|
+
tenant_scoped: false
|
|
185
|
+
tables:
|
|
186
|
+
employees: "*"
|
|
187
|
+
warehouses: "*"
|
|
188
|
+
labels:
|
|
189
|
+
personal_identifier: allow
|
|
190
|
+
contact: allow
|
|
191
|
+
hr: allow
|
|
192
|
+
financial: aggregate_only
|
|
193
|
+
government_id: deny
|
|
194
|
+
special_category: deny
|
|
195
|
+
|
|
196
|
+
finance_analyst:
|
|
197
|
+
description: Revenue and payments, without card numbers or customer contact details.
|
|
198
|
+
tables:
|
|
199
|
+
orders: [id, tenant_id, customer_id, status, total_amount, currency, created_at]
|
|
200
|
+
order_items: "*"
|
|
201
|
+
products: "*"
|
|
202
|
+
payments: "*"
|
|
203
|
+
labels:
|
|
204
|
+
financial: allow
|
|
205
|
+
payment_card: deny
|
|
206
|
+
|
|
207
|
+
reporting_agent:
|
|
208
|
+
description: Automated reporting. Aggregates only for anything personal.
|
|
209
|
+
tables:
|
|
210
|
+
orders: [id, tenant_id, customer_id, status, total_amount, currency, created_at]
|
|
211
|
+
order_items: "*"
|
|
212
|
+
products: "*"
|
|
213
|
+
customers: [id, tenant_id, country, city, loyalty_tier, created_at, email_domain, email]
|
|
214
|
+
labels:
|
|
215
|
+
location: aggregate_only
|
|
216
|
+
contact: deny
|
|
217
|
+
limits:
|
|
218
|
+
max_limit: 1000
|
|
219
|
+
max_joins: 4
|
|
220
|
+
max_subquery_depth: 3
|
|
221
|
+
|
|
222
|
+
platform_admin:
|
|
223
|
+
description: Internal cross-tenant operations. Sensitive classes need review.
|
|
224
|
+
tenant_scoped: false
|
|
225
|
+
tables:
|
|
226
|
+
tenants: "*"
|
|
227
|
+
customers: "*"
|
|
228
|
+
orders: "*"
|
|
229
|
+
order_items: "*"
|
|
230
|
+
products: "*"
|
|
231
|
+
payments: "*"
|
|
232
|
+
shipments: "*"
|
|
233
|
+
warehouses: "*"
|
|
234
|
+
support_tickets: "*"
|
|
235
|
+
labels:
|
|
236
|
+
personal_identifier: allow
|
|
237
|
+
contact: allow
|
|
238
|
+
location: allow
|
|
239
|
+
free_text: allow
|
|
240
|
+
financial: review
|
|
241
|
+
payment_card: deny
|
|
242
|
+
special_category: deny
|
|
243
|
+
|
|
244
|
+
tenant:
|
|
245
|
+
context_key: tenant_id
|
|
246
|
+
parameter_names: [tenant_id]
|
|
247
|
+
|
|
248
|
+
functions:
|
|
249
|
+
mode: denylist
|
|
250
|
+
|
|
251
|
+
limits:
|
|
252
|
+
max_joins: 6
|
|
253
|
+
max_subquery_depth: 4
|
|
254
|
+
max_limit: 10000
|
|
255
|
+
|
|
256
|
+
cost:
|
|
257
|
+
review_score: 50
|
|
258
|
+
deny_score: 80
|
|
259
|
+
large_table_rows: 1000000
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Benchmark engine: corpus, adapters, runner, metrics, comparison and reports."""
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Benchmark adapters: a uniform interface over different SQL validators.
|
|
2
|
+
|
|
3
|
+
* :class:`NativeAdapter` — SQLGuard Ninja's own :class:`~sqlguard_ninja.Guard`.
|
|
4
|
+
* :class:`CallableAdapter` — any user function ``module:function``.
|
|
5
|
+
* :class:`SubprocessAdapter` — a worker script run by *another* Python interpreter
|
|
6
|
+
(one virtual environment per external tool), speaking JSON Lines over stdin/stdout.
|
|
7
|
+
This isolates tools whose dependencies or import names conflict.
|
|
8
|
+
|
|
9
|
+
An adapter declares the case categories its policy model can express (``capabilities``).
|
|
10
|
+
Reports show metrics on all cases and on the capability subset, so a tool is not
|
|
11
|
+
penalised in a headline number for features it never claimed.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import importlib
|
|
17
|
+
import json
|
|
18
|
+
import subprocess
|
|
19
|
+
import time
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Protocol
|
|
24
|
+
|
|
25
|
+
from sqlguard_ninja._version import __version__
|
|
26
|
+
from sqlguard_ninja.benchmarking.dataset import BenchmarkCase
|
|
27
|
+
from sqlguard_ninja.benchmarking.translate import translate_for_role
|
|
28
|
+
from sqlguard_ninja.core.findings import Decision
|
|
29
|
+
from sqlguard_ninja.core.guard import Guard
|
|
30
|
+
from sqlguard_ninja.policy.model import Policy
|
|
31
|
+
|
|
32
|
+
WORKERS_DIR = Path(__file__).parent / "workers"
|
|
33
|
+
|
|
34
|
+
ALL_CATEGORIES: frozenset[str] | None = None
|
|
35
|
+
"""Sentinel: the adapter claims every category."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class AdapterVerdict:
|
|
40
|
+
decision: Decision
|
|
41
|
+
reasons: tuple[str, ...] = ()
|
|
42
|
+
latency_ms: float = 0.0
|
|
43
|
+
error: str | None = None
|
|
44
|
+
"""Set when the validator crashed; the decision is then DENY (fail closed) and the
|
|
45
|
+
error is counted separately in reports."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class BenchmarkAdapter(Protocol):
|
|
49
|
+
name: str
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def version(self) -> str: ...
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def capabilities(self) -> frozenset[str] | None: ...
|
|
56
|
+
|
|
57
|
+
def evaluate(self, case: BenchmarkCase) -> AdapterVerdict: ...
|
|
58
|
+
|
|
59
|
+
def close(self) -> None: ...
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class NativeAdapter:
|
|
63
|
+
"""Evaluate cases with SQLGuard Ninja's built-in guard."""
|
|
64
|
+
|
|
65
|
+
name = "native"
|
|
66
|
+
|
|
67
|
+
def __init__(self, policy: Policy, guard: Guard | None = None) -> None:
|
|
68
|
+
self.guard = guard or Guard(policy)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def version(self) -> str:
|
|
72
|
+
return __version__
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def capabilities(self) -> frozenset[str] | None:
|
|
76
|
+
return ALL_CATEGORIES
|
|
77
|
+
|
|
78
|
+
def evaluate(self, case: BenchmarkCase) -> AdapterVerdict:
|
|
79
|
+
started = time.perf_counter()
|
|
80
|
+
result = self.guard.validate(
|
|
81
|
+
case.sql, role=case.role, dialect=case.dialect, context=case.context
|
|
82
|
+
)
|
|
83
|
+
elapsed = (time.perf_counter() - started) * 1000
|
|
84
|
+
reasons = tuple(f.rule_id for f in result.blocking_findings)
|
|
85
|
+
return AdapterVerdict(result.decision, reasons, elapsed)
|
|
86
|
+
|
|
87
|
+
def close(self) -> None:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _coerce_decision(value: Any) -> tuple[Decision, tuple[str, ...]]:
|
|
92
|
+
"""Normalise the many result shapes a user validator may return."""
|
|
93
|
+
reasons: tuple[str, ...] = ()
|
|
94
|
+
if isinstance(value, tuple) and len(value) == 2:
|
|
95
|
+
value, raw = value
|
|
96
|
+
reasons = tuple(str(r) for r in (raw or ()))
|
|
97
|
+
if isinstance(value, dict):
|
|
98
|
+
reasons = tuple(str(r) for r in value.get("reasons", ()))
|
|
99
|
+
value = value.get("decision", value.get("allowed"))
|
|
100
|
+
if isinstance(value, Decision):
|
|
101
|
+
return value, reasons
|
|
102
|
+
if isinstance(value, bool):
|
|
103
|
+
return (Decision.ALLOW if value else Decision.DENY), reasons
|
|
104
|
+
if isinstance(value, str) and value.lower() in {d.value for d in Decision}:
|
|
105
|
+
return Decision(value.lower()), reasons
|
|
106
|
+
raise TypeError(f"cannot interpret validator result {value!r}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class CallableAdapter:
|
|
110
|
+
"""Wrap a Python callable ``fn(sql, dialect, case) -> bool | str | dict | Decision``."""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
fn: Callable[[str, str, BenchmarkCase], Any],
|
|
115
|
+
name: str = "callable",
|
|
116
|
+
version: str = "unknown",
|
|
117
|
+
capabilities: frozenset[str] | None = ALL_CATEGORIES,
|
|
118
|
+
) -> None:
|
|
119
|
+
self.fn = fn
|
|
120
|
+
self.name = name
|
|
121
|
+
self._version = version
|
|
122
|
+
self._capabilities = capabilities
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def from_spec(cls, spec: str) -> CallableAdapter:
|
|
126
|
+
"""Load ``package.module:function``."""
|
|
127
|
+
module_name, _, attr = spec.partition(":")
|
|
128
|
+
if not module_name or not attr:
|
|
129
|
+
raise ValueError("callable adapter spec must look like 'module:function'")
|
|
130
|
+
fn = getattr(importlib.import_module(module_name), attr)
|
|
131
|
+
return cls(fn, name=f"callable:{spec}")
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def version(self) -> str:
|
|
135
|
+
return self._version
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def capabilities(self) -> frozenset[str] | None:
|
|
139
|
+
return self._capabilities
|
|
140
|
+
|
|
141
|
+
def evaluate(self, case: BenchmarkCase) -> AdapterVerdict:
|
|
142
|
+
started = time.perf_counter()
|
|
143
|
+
try:
|
|
144
|
+
raw = self.fn(case.sql, case.dialect, case)
|
|
145
|
+
decision, reasons = _coerce_decision(raw)
|
|
146
|
+
error = None
|
|
147
|
+
except Exception as exc: # a crashing validator must not crash the benchmark
|
|
148
|
+
decision, reasons, error = Decision.DENY, (), f"{type(exc).__name__}: {exc}"
|
|
149
|
+
return AdapterVerdict(decision, reasons, (time.perf_counter() - started) * 1000, error)
|
|
150
|
+
|
|
151
|
+
def close(self) -> None:
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class ExternalToolSpec:
|
|
157
|
+
"""Metadata for a bundled external worker."""
|
|
158
|
+
|
|
159
|
+
name: str
|
|
160
|
+
worker: str
|
|
161
|
+
package: str
|
|
162
|
+
pip_spec: str
|
|
163
|
+
capabilities: frozenset[str]
|
|
164
|
+
notes: str = ""
|
|
165
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
_BASE_CAPS = frozenset(
|
|
169
|
+
{
|
|
170
|
+
"destructive",
|
|
171
|
+
"multi_statement",
|
|
172
|
+
"comments_formatting",
|
|
173
|
+
"unauthorized_table",
|
|
174
|
+
"unauthorized_column",
|
|
175
|
+
"lookalike_safe",
|
|
176
|
+
"legitimate_complex",
|
|
177
|
+
"cte_bypass",
|
|
178
|
+
"nested_query",
|
|
179
|
+
"join_subquery_policy",
|
|
180
|
+
"unicode_identifiers",
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
EXTERNAL_TOOLS: dict[str, ExternalToolSpec] = {
|
|
185
|
+
"sql-guardrail": ExternalToolSpec(
|
|
186
|
+
name="sql-guardrail",
|
|
187
|
+
worker="sql_guardrail_worker.py",
|
|
188
|
+
package="sql-guardrail",
|
|
189
|
+
pip_spec="sql-guardrail==0.5.2",
|
|
190
|
+
capabilities=_BASE_CAPS | {"tenant_isolation", "cost"},
|
|
191
|
+
notes="Column allow/deny lists and require_predicate; no roles, labels or review.",
|
|
192
|
+
),
|
|
193
|
+
"agent-sql-guard": ExternalToolSpec(
|
|
194
|
+
name="agent-sql-guard",
|
|
195
|
+
worker="agent_sql_guard_worker.py",
|
|
196
|
+
package="agent-sql-guard",
|
|
197
|
+
pip_spec="agent-sql-guard==0.2.0",
|
|
198
|
+
capabilities=_BASE_CAPS | {"sensitive_field"},
|
|
199
|
+
notes="Table allowlist and a name-based PII denylist; no tenant predicates.",
|
|
200
|
+
),
|
|
201
|
+
"krish2105-sqlguard": ExternalToolSpec(
|
|
202
|
+
name="krish2105-sqlguard",
|
|
203
|
+
worker="krish_sqlguard_worker.py",
|
|
204
|
+
package="sqlguard",
|
|
205
|
+
pip_spec="sqlguard @ git+https://github.com/krish2105/sqlguard@62327f214ed8c9ea82ecf820bf04c4b55a6161a2",
|
|
206
|
+
capabilities=_BASE_CAPS,
|
|
207
|
+
notes="Read-only AST check with a schema allow-list; not published on PyPI.",
|
|
208
|
+
),
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class SubprocessAdapter:
|
|
213
|
+
"""Run an external validator in its own interpreter via a JSON Lines worker.
|
|
214
|
+
|
|
215
|
+
The worker receives one request per line::
|
|
216
|
+
|
|
217
|
+
{"id": ..., "sql": ..., "dialect": ..., "context": {...}, "config": {...}}
|
|
218
|
+
|
|
219
|
+
and answers with ``{"decision": "allow|deny|review", "reasons": [...],
|
|
220
|
+
"latency_ms": float, "error": str|null}``. Its first line is a hello message carrying
|
|
221
|
+
``{"library": ..., "version": ...}``.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
def __init__(
|
|
225
|
+
self,
|
|
226
|
+
tool: ExternalToolSpec,
|
|
227
|
+
python: str | Path,
|
|
228
|
+
policy: Policy,
|
|
229
|
+
timeout_s: float = 30.0,
|
|
230
|
+
) -> None:
|
|
231
|
+
self.tool = tool
|
|
232
|
+
self.name = tool.name
|
|
233
|
+
self.policy = policy
|
|
234
|
+
self.timeout_s = timeout_s
|
|
235
|
+
worker = WORKERS_DIR / tool.worker
|
|
236
|
+
# -I: isolated mode, so the worker cannot import from the current directory.
|
|
237
|
+
self._proc = subprocess.Popen( # noqa: S603 - fixed argv, no shell
|
|
238
|
+
[str(python), "-I", str(worker)],
|
|
239
|
+
stdin=subprocess.PIPE,
|
|
240
|
+
stdout=subprocess.PIPE,
|
|
241
|
+
stderr=subprocess.PIPE,
|
|
242
|
+
text=True,
|
|
243
|
+
encoding="utf-8",
|
|
244
|
+
bufsize=1,
|
|
245
|
+
)
|
|
246
|
+
hello = self._read()
|
|
247
|
+
if "error" in hello and hello.get("error"):
|
|
248
|
+
self.close()
|
|
249
|
+
raise RuntimeError(f"{tool.name} worker failed to start: {hello['error']}")
|
|
250
|
+
self._version = str(hello.get("version", "unknown"))
|
|
251
|
+
self._configs: dict[tuple[str | None, str], dict[str, Any]] = {}
|
|
252
|
+
|
|
253
|
+
def _read(self) -> dict[str, Any]:
|
|
254
|
+
assert self._proc.stdout is not None
|
|
255
|
+
line = self._proc.stdout.readline()
|
|
256
|
+
if not line:
|
|
257
|
+
stderr = self._proc.stderr.read() if self._proc.stderr else ""
|
|
258
|
+
raise RuntimeError(f"{self.tool.name} worker exited: {stderr[-500:]}")
|
|
259
|
+
data: dict[str, Any] = json.loads(line)
|
|
260
|
+
return data
|
|
261
|
+
|
|
262
|
+
@property
|
|
263
|
+
def version(self) -> str:
|
|
264
|
+
return self._version
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def capabilities(self) -> frozenset[str] | None:
|
|
268
|
+
return self.tool.capabilities
|
|
269
|
+
|
|
270
|
+
def evaluate(self, case: BenchmarkCase) -> AdapterVerdict:
|
|
271
|
+
key = (case.role, case.dialect)
|
|
272
|
+
if key not in self._configs:
|
|
273
|
+
self._configs[key] = translate_for_role(self.policy, case.role, case.dialect)
|
|
274
|
+
request = {
|
|
275
|
+
"id": case.id,
|
|
276
|
+
"sql": case.sql,
|
|
277
|
+
"dialect": case.dialect,
|
|
278
|
+
"context": case.context,
|
|
279
|
+
"config": self._configs[key],
|
|
280
|
+
}
|
|
281
|
+
assert self._proc.stdin is not None
|
|
282
|
+
self._proc.stdin.write(json.dumps(request) + "\n")
|
|
283
|
+
self._proc.stdin.flush()
|
|
284
|
+
reply = self._read()
|
|
285
|
+
decision = Decision(reply.get("decision", "deny"))
|
|
286
|
+
return AdapterVerdict(
|
|
287
|
+
decision=decision,
|
|
288
|
+
reasons=tuple(reply.get("reasons", ())),
|
|
289
|
+
latency_ms=float(reply.get("latency_ms", 0.0)),
|
|
290
|
+
error=reply.get("error"),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def close(self) -> None:
|
|
294
|
+
if self._proc.poll() is None:
|
|
295
|
+
if self._proc.stdin:
|
|
296
|
+
self._proc.stdin.close()
|
|
297
|
+
try:
|
|
298
|
+
self._proc.wait(timeout=5)
|
|
299
|
+
except subprocess.TimeoutExpired:
|
|
300
|
+
self._proc.kill()
|
|
301
|
+
for stream in (self._proc.stdout, self._proc.stderr):
|
|
302
|
+
if stream:
|
|
303
|
+
stream.close()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Locate the reference benchmark corpus and policy shipped with the package.
|
|
2
|
+
|
|
3
|
+
In a wheel, the files are packaged under ``sqlguard_ninja/_data/benchmarks``. In a source
|
|
4
|
+
checkout (editable install), they are read from the repository's ``benchmarks/`` directory.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
_PACKAGE = Path(__file__).resolve().parents[1]
|
|
12
|
+
_CANDIDATES = (_PACKAGE / "_data" / "benchmarks", _PACKAGE.parents[1] / "benchmarks")
|
|
13
|
+
|
|
14
|
+
PREFIX = "builtin:"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def benchmarks_root() -> Path:
|
|
18
|
+
for candidate in _CANDIDATES:
|
|
19
|
+
if (candidate / "datasets").is_dir() and (candidate / "policies").is_dir():
|
|
20
|
+
return candidate
|
|
21
|
+
raise FileNotFoundError("the bundled benchmark data is missing from this installation")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve(spec: str | Path, kind: str) -> Path:
|
|
25
|
+
"""Resolve ``builtin:<name>`` for ``kind`` 'dataset' or 'policy'; pass other paths through."""
|
|
26
|
+
text = str(spec)
|
|
27
|
+
if not text.startswith(PREFIX):
|
|
28
|
+
return Path(text)
|
|
29
|
+
name = text.removeprefix(PREFIX) or ("v1" if kind == "dataset" else "retail_saas")
|
|
30
|
+
root = benchmarks_root()
|
|
31
|
+
path = root / "datasets" / name if kind == "dataset" else root / "policies" / f"{name}.yaml"
|
|
32
|
+
if not path.exists():
|
|
33
|
+
available = sorted(
|
|
34
|
+
p.name if kind == "dataset" else p.stem
|
|
35
|
+
for p in (root / ("datasets" if kind == "dataset" else "policies")).iterdir()
|
|
36
|
+
if not p.name.startswith(".")
|
|
37
|
+
)
|
|
38
|
+
raise FileNotFoundError(f"no built-in {kind} {name!r}; available: {available}")
|
|
39
|
+
return path
|