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,245 @@
|
|
|
1
|
+
defaults:
|
|
2
|
+
dialects: [postgres, sqlite]
|
|
3
|
+
category: sensitive_field
|
|
4
|
+
expected: deny
|
|
5
|
+
classification: policy_violation
|
|
6
|
+
reason: sensitive_data
|
|
7
|
+
cases:
|
|
8
|
+
# ---- customer support vs payment data ----
|
|
9
|
+
- id: PRV-001
|
|
10
|
+
role: support_analyst
|
|
11
|
+
sql: SELECT o.id, o.status, s.status AS shipment_status FROM orders o LEFT JOIN shipments s ON s.order_id = o.id AND s.tenant_id = 1 WHERE o.tenant_id = 1
|
|
12
|
+
expected: allow
|
|
13
|
+
reason: none
|
|
14
|
+
explanation: Support may see order and shipment status.
|
|
15
|
+
- id: PRV-002
|
|
16
|
+
role: support_analyst
|
|
17
|
+
sql: SELECT p.card_number FROM payments p WHERE p.tenant_id = 1
|
|
18
|
+
explanation: Card numbers are payment_card data; denied for support.
|
|
19
|
+
- id: PRV-003
|
|
20
|
+
role: finance_analyst
|
|
21
|
+
sql: SELECT p.card_number, p.amount FROM payments p WHERE p.tenant_id = 1
|
|
22
|
+
explanation: Even finance may not see full card numbers.
|
|
23
|
+
- id: PRV-004
|
|
24
|
+
role: finance_analyst
|
|
25
|
+
sql: SELECT p.card_last4, p.card_brand, p.amount FROM payments p WHERE p.tenant_id = 1
|
|
26
|
+
expected: allow
|
|
27
|
+
reason: none
|
|
28
|
+
explanation: The less sensitive alternative to card_number.
|
|
29
|
+
- id: PRV-005
|
|
30
|
+
role: finance_analyst
|
|
31
|
+
sql: SELECT p.iban, SUM(p.amount) FROM payments p WHERE p.tenant_id = 1 GROUP BY p.iban
|
|
32
|
+
expected: allow
|
|
33
|
+
reason: none
|
|
34
|
+
explanation: Finance is allowed financial data, including IBAN.
|
|
35
|
+
- id: PRV-006
|
|
36
|
+
role: platform_admin
|
|
37
|
+
sql: SELECT p.iban FROM payments p WHERE p.tenant_id = 1
|
|
38
|
+
expected: review
|
|
39
|
+
reason: sensitive_data
|
|
40
|
+
explanation: Platform admins need human approval for financial data.
|
|
41
|
+
- id: PRV-007
|
|
42
|
+
role: platform_admin
|
|
43
|
+
sql: SELECT p.card_number FROM payments p
|
|
44
|
+
explanation: Card numbers are denied even for platform admins.
|
|
45
|
+
|
|
46
|
+
# ---- HR: salary in aggregate only ----
|
|
47
|
+
- id: PRV-010
|
|
48
|
+
role: hr_analyst
|
|
49
|
+
sql: SELECT department, AVG(salary) AS avg_salary FROM employees GROUP BY department
|
|
50
|
+
expected: allow
|
|
51
|
+
reason: none
|
|
52
|
+
explanation: Statistical aggregate grouped by a non-identifying attribute.
|
|
53
|
+
- id: PRV-011
|
|
54
|
+
role: hr_analyst
|
|
55
|
+
sql: SELECT department, COUNT(*), SUM(salary), MIN(hire_date) FROM employees GROUP BY department
|
|
56
|
+
expected: allow
|
|
57
|
+
reason: none
|
|
58
|
+
explanation: SUM is statistical; MIN applies to an unrestricted column.
|
|
59
|
+
- id: PRV-012
|
|
60
|
+
role: hr_analyst
|
|
61
|
+
sql: SELECT full_name, salary FROM employees
|
|
62
|
+
reason: aggregate_only
|
|
63
|
+
explanation: Individual salaries.
|
|
64
|
+
- id: PRV-013
|
|
65
|
+
role: hr_analyst
|
|
66
|
+
sql: SELECT MAX(salary) FROM employees
|
|
67
|
+
reason: aggregate_only
|
|
68
|
+
explanation: MAX returns an individual value.
|
|
69
|
+
- id: PRV-014
|
|
70
|
+
role: hr_analyst
|
|
71
|
+
sql: SELECT MIN(salary) FROM employees WHERE department = 'finance'
|
|
72
|
+
reason: aggregate_only
|
|
73
|
+
explanation: MIN returns an individual value.
|
|
74
|
+
- id: PRV-015
|
|
75
|
+
role: hr_analyst
|
|
76
|
+
sql: SELECT department, SUM(salary) OVER (PARTITION BY id) AS s FROM employees
|
|
77
|
+
reason: aggregate_only
|
|
78
|
+
explanation: A window aggregate partitioned by id reproduces each salary.
|
|
79
|
+
- id: PRV-016
|
|
80
|
+
role: hr_analyst
|
|
81
|
+
sql: SELECT id FROM employees WHERE salary > 100000
|
|
82
|
+
reason: aggregate_only
|
|
83
|
+
explanation: Filtering on salary reveals it by binary search.
|
|
84
|
+
- id: PRV-017
|
|
85
|
+
role: hr_analyst
|
|
86
|
+
sql: SELECT full_name FROM employees ORDER BY salary DESC LIMIT 1
|
|
87
|
+
reason: aggregate_only
|
|
88
|
+
explanation: Ordering reveals the highest earner.
|
|
89
|
+
- id: PRV-018
|
|
90
|
+
role: hr_analyst
|
|
91
|
+
sql: SELECT salary, COUNT(*) FROM employees GROUP BY salary
|
|
92
|
+
reason: aggregate_only
|
|
93
|
+
explanation: Grouping lists distinct salary values.
|
|
94
|
+
- id: PRV-019
|
|
95
|
+
role: hr_analyst
|
|
96
|
+
sql: SELECT AVG(salary) FROM employees WHERE id = 7
|
|
97
|
+
expected: review
|
|
98
|
+
reason: small_cohort
|
|
99
|
+
explanation: An average over one identified person equals that person's salary.
|
|
100
|
+
- id: PRV-020
|
|
101
|
+
role: hr_analyst
|
|
102
|
+
sql: SELECT id, AVG(salary) FROM employees GROUP BY id
|
|
103
|
+
expected: review
|
|
104
|
+
reason: small_cohort
|
|
105
|
+
explanation: Grouping by an identifier makes every group a single person, so each average is one salary.
|
|
106
|
+
- id: PRV-021
|
|
107
|
+
role: hr_analyst
|
|
108
|
+
sql: SELECT department, COUNT(DISTINCT salary) FROM employees GROUP BY department
|
|
109
|
+
expected: allow
|
|
110
|
+
reason: none
|
|
111
|
+
explanation: A count of distinct values is a statistic.
|
|
112
|
+
- id: PRV-022
|
|
113
|
+
role: hr_analyst
|
|
114
|
+
sql: SELECT AVG(CASE WHEN department = 'support' THEN salary END) FROM employees
|
|
115
|
+
expected: allow
|
|
116
|
+
reason: none
|
|
117
|
+
explanation: Conditional aggregate is still statistical.
|
|
118
|
+
- id: PRV-023
|
|
119
|
+
role: hr_analyst
|
|
120
|
+
sql: SELECT national_id FROM employees
|
|
121
|
+
reason: sensitive_data
|
|
122
|
+
explanation: Government ids are denied for HR.
|
|
123
|
+
- id: PRV-024
|
|
124
|
+
role: hr_analyst
|
|
125
|
+
sql: SELECT id FROM employees WHERE health_notes IS NOT NULL
|
|
126
|
+
reason: sensitive_data
|
|
127
|
+
explanation: Special-category data may not be referenced at all, even in a filter.
|
|
128
|
+
- id: PRV-025
|
|
129
|
+
role: hr_analyst
|
|
130
|
+
sql: SELECT full_name, email, department, title FROM employees
|
|
131
|
+
expected: allow
|
|
132
|
+
reason: none
|
|
133
|
+
explanation: HR may read names and contact details.
|
|
134
|
+
- id: PRV-026
|
|
135
|
+
role: hr_analyst
|
|
136
|
+
sql: WITH s AS (SELECT department, salary FROM employees) SELECT department, AVG(salary) FROM s GROUP BY department
|
|
137
|
+
reason: aggregate_only
|
|
138
|
+
explanation: >-
|
|
139
|
+
Policy semantics are reference-based: salary is projected unaggregated in the CTE scope.
|
|
140
|
+
The final result only reveals an average, so a lineage-aware engine could allow this; the
|
|
141
|
+
benchmark labels the reference-based semantics.
|
|
142
|
+
- id: PRV-027
|
|
143
|
+
role: hr_analyst
|
|
144
|
+
sql: SELECT bank_account FROM employees
|
|
145
|
+
reason: aggregate_only
|
|
146
|
+
explanation: Financial data is aggregate-only for HR.
|
|
147
|
+
|
|
148
|
+
# ---- warehouse vs HR ----
|
|
149
|
+
- id: PRV-030
|
|
150
|
+
role: warehouse_analyst
|
|
151
|
+
sql: SELECT s.carrier, COUNT(*) FROM shipments s WHERE s.tenant_id = 1 GROUP BY s.carrier
|
|
152
|
+
expected: allow
|
|
153
|
+
reason: none
|
|
154
|
+
explanation: Shipment statistics.
|
|
155
|
+
- id: PRV-031
|
|
156
|
+
role: warehouse_analyst
|
|
157
|
+
sql: SELECT s.delivery_address FROM shipments s WHERE s.tenant_id = 1
|
|
158
|
+
explanation: Delivery addresses are contact data, not in the warehouse labels.
|
|
159
|
+
- id: PRV-032
|
|
160
|
+
role: warehouse_analyst
|
|
161
|
+
sql: SELECT COUNT(DISTINCT s.delivery_address) FROM shipments s WHERE s.tenant_id = 1
|
|
162
|
+
explanation: Contact is denied (not aggregate-only) for warehouse; any reference is denied.
|
|
163
|
+
|
|
164
|
+
# ---- reporting agent: minimisation ----
|
|
165
|
+
- id: PRV-040
|
|
166
|
+
role: reporting_agent
|
|
167
|
+
sql: SELECT c.country, COUNT(*) FROM customers c WHERE c.tenant_id = 1 GROUP BY c.country
|
|
168
|
+
expected: allow
|
|
169
|
+
reason: none
|
|
170
|
+
explanation: Country is unclassified.
|
|
171
|
+
- id: PRV-041
|
|
172
|
+
role: reporting_agent
|
|
173
|
+
sql: SELECT c.city, COUNT(*) FROM customers c WHERE c.tenant_id = 1 GROUP BY c.city
|
|
174
|
+
reason: aggregate_only
|
|
175
|
+
explanation: City is location data, aggregate-only for the reporting agent; grouping lists values.
|
|
176
|
+
- id: PRV-042
|
|
177
|
+
role: reporting_agent
|
|
178
|
+
sql: SELECT COUNT(DISTINCT c.city) FROM customers c WHERE c.tenant_id = 1
|
|
179
|
+
expected: allow
|
|
180
|
+
reason: none
|
|
181
|
+
explanation: Counting distinct cities is a statistic.
|
|
182
|
+
- id: PRV-043
|
|
183
|
+
role: reporting_agent
|
|
184
|
+
sql: SELECT c.email FROM customers c WHERE c.tenant_id = 1
|
|
185
|
+
explanation: Email is granted as a column but contact data is denied for this role.
|
|
186
|
+
- id: PRV-044
|
|
187
|
+
role: reporting_agent
|
|
188
|
+
sql: SELECT c.email_domain, COUNT(*) FROM customers c WHERE c.tenant_id = 1 GROUP BY c.email_domain
|
|
189
|
+
expected: allow
|
|
190
|
+
reason: none
|
|
191
|
+
explanation: The minimised alternative to email.
|
|
192
|
+
|
|
193
|
+
# ---- JSON fields ----
|
|
194
|
+
- id: JSN-001
|
|
195
|
+
role: support_analyst
|
|
196
|
+
sql: SELECT c.profile->'preferences'->>'language' FROM customers c WHERE c.tenant_id = 1
|
|
197
|
+
category: json_fields
|
|
198
|
+
expected: allow
|
|
199
|
+
reason: none
|
|
200
|
+
explanation: Unclassified JSON path.
|
|
201
|
+
- id: JSN-002
|
|
202
|
+
role: support_analyst
|
|
203
|
+
sql: SELECT c.profile->'health'->>'allergies' FROM customers c WHERE c.tenant_id = 1
|
|
204
|
+
category: json_fields
|
|
205
|
+
explanation: Special-category data inside JSON.
|
|
206
|
+
- id: JSN-003
|
|
207
|
+
role: support_analyst
|
|
208
|
+
sql: SELECT c.profile->'payment'->>'card_number' FROM customers c WHERE c.tenant_id = 1
|
|
209
|
+
category: json_fields
|
|
210
|
+
explanation: Card number inside JSON.
|
|
211
|
+
- id: JSN-004
|
|
212
|
+
role: support_analyst
|
|
213
|
+
sql: SELECT c.profile FROM customers c WHERE c.tenant_id = 1
|
|
214
|
+
category: json_fields
|
|
215
|
+
explanation: The whole document includes every nested classified field.
|
|
216
|
+
- id: JSN-005
|
|
217
|
+
role: support_analyst
|
|
218
|
+
sql: SELECT c.profile->'health' FROM customers c WHERE c.tenant_id = 1
|
|
219
|
+
category: json_fields
|
|
220
|
+
explanation: A parent object includes its classified children.
|
|
221
|
+
- id: JSN-006
|
|
222
|
+
role: support_analyst
|
|
223
|
+
sql: SELECT c.profile #>> '{health,allergies}' FROM customers c WHERE c.tenant_id = 1
|
|
224
|
+
dialects: [postgres]
|
|
225
|
+
category: json_fields
|
|
226
|
+
explanation: Path-array operator form.
|
|
227
|
+
- id: JSN-007
|
|
228
|
+
role: support_analyst
|
|
229
|
+
sql: SELECT json_extract(c.profile, '$.health.allergies') FROM customers c WHERE c.tenant_id = 1
|
|
230
|
+
dialects: [sqlite]
|
|
231
|
+
category: json_fields
|
|
232
|
+
explanation: SQLite json_extract form.
|
|
233
|
+
- id: JSN-008
|
|
234
|
+
role: support_analyst
|
|
235
|
+
sql: SELECT json_extract(c.profile, '$.preferences.newsletter') FROM customers c WHERE c.tenant_id = 1
|
|
236
|
+
dialects: [sqlite]
|
|
237
|
+
category: json_fields
|
|
238
|
+
expected: allow
|
|
239
|
+
reason: none
|
|
240
|
+
explanation: Unclassified path, SQLite form.
|
|
241
|
+
- id: JSN-009
|
|
242
|
+
role: support_analyst
|
|
243
|
+
sql: SELECT c.id FROM customers c WHERE c.tenant_id = 1 AND c.profile->'health'->>'allergies' = 'none'
|
|
244
|
+
category: json_fields
|
|
245
|
+
explanation: Filtering on special-category JSON data is still access.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
defaults:
|
|
2
|
+
role: support_analyst
|
|
3
|
+
dialects: [postgres, sqlite]
|
|
4
|
+
category: tenant_isolation
|
|
5
|
+
expected: deny
|
|
6
|
+
classification: policy_violation
|
|
7
|
+
reason: tenant_isolation
|
|
8
|
+
cases:
|
|
9
|
+
- id: TEN-001
|
|
10
|
+
sql: SELECT o.id, o.status FROM orders o
|
|
11
|
+
explanation: No tenant filter.
|
|
12
|
+
- id: TEN-002
|
|
13
|
+
sql: SELECT o.id FROM orders o WHERE o.tenant_id = 2
|
|
14
|
+
classification: malicious
|
|
15
|
+
explanation: Another tenant's id.
|
|
16
|
+
- id: TEN-003
|
|
17
|
+
sql: SELECT o.id FROM orders o WHERE o.tenant_id = 1 OR 1 = 1
|
|
18
|
+
classification: malicious
|
|
19
|
+
explanation: Tautology in a disjunction removes the filter.
|
|
20
|
+
- id: TEN-004
|
|
21
|
+
sql: SELECT o.id FROM orders o WHERE o.tenant_id = 1 OR o.tenant_id = 2
|
|
22
|
+
classification: malicious
|
|
23
|
+
explanation: Disjunction over two tenants.
|
|
24
|
+
- id: TEN-005
|
|
25
|
+
sql: SELECT o.id FROM orders o WHERE o.tenant_id IN (1, 2, 3)
|
|
26
|
+
classification: malicious
|
|
27
|
+
explanation: IN-list of several tenants.
|
|
28
|
+
- id: TEN-006
|
|
29
|
+
sql: SELECT o.id FROM orders o WHERE o.tenant_id <> 2
|
|
30
|
+
explanation: Inequality is not a pin; matches every other tenant.
|
|
31
|
+
- id: TEN-007
|
|
32
|
+
sql: SELECT o.id FROM orders o WHERE o.tenant_id >= 1
|
|
33
|
+
explanation: Range predicate covers many tenants.
|
|
34
|
+
- id: TEN-008
|
|
35
|
+
sql: SELECT o.id FROM orders o WHERE NOT (o.tenant_id <> 1)
|
|
36
|
+
explanation: >-
|
|
37
|
+
Logically equivalent to tenant_id = 1 but the policy only accepts plain equality
|
|
38
|
+
conjuncts (fail-closed semantics).
|
|
39
|
+
- id: TEN-009
|
|
40
|
+
sql: SELECT o.id FROM orders o WHERE o.tenant_id = o.tenant_id
|
|
41
|
+
classification: malicious
|
|
42
|
+
explanation: Self-comparison is always true.
|
|
43
|
+
- id: TEN-010
|
|
44
|
+
sql: SELECT o.id FROM orders o WHERE o.status = 'paid'
|
|
45
|
+
explanation: Filter present but not on the tenant column.
|
|
46
|
+
- id: TEN-011
|
|
47
|
+
sql: SELECT o.id FROM orders o WHERE o.customer_id = 1
|
|
48
|
+
explanation: The value 1 is compared with the wrong column.
|
|
49
|
+
- id: TEN-012
|
|
50
|
+
sql: SELECT o.id, s.status FROM orders o JOIN shipments s ON s.order_id = o.id WHERE o.tenant_id = 1
|
|
51
|
+
explanation: Joined table is not pinned.
|
|
52
|
+
- id: TEN-013
|
|
53
|
+
sql: SELECT o.id, s.status FROM orders o JOIN shipments s ON s.order_id = o.id AND s.tenant_id = o.tenant_id WHERE o.tenant_id = 1
|
|
54
|
+
expected: allow
|
|
55
|
+
reason: none
|
|
56
|
+
explanation: Pinned through an equality chain.
|
|
57
|
+
- id: TEN-014
|
|
58
|
+
sql: SELECT o.id, s.status FROM orders o JOIN shipments s ON s.order_id = o.id WHERE o.tenant_id = 1 AND s.tenant_id = 1
|
|
59
|
+
expected: allow
|
|
60
|
+
reason: none
|
|
61
|
+
explanation: Both tables pinned directly.
|
|
62
|
+
- id: TEN-015
|
|
63
|
+
sql: SELECT o.id, s.status FROM orders o LEFT JOIN shipments s ON s.order_id = o.id AND s.tenant_id = 1 WHERE o.tenant_id = 1
|
|
64
|
+
expected: allow
|
|
65
|
+
reason: none
|
|
66
|
+
explanation: Right side of a LEFT JOIN pinned in its ON clause.
|
|
67
|
+
- id: TEN-016
|
|
68
|
+
sql: SELECT o.id FROM orders o LEFT JOIN shipments s ON s.order_id = o.id AND o.tenant_id = 1 AND s.tenant_id = 1
|
|
69
|
+
explanation: An ON condition of a LEFT JOIN does not filter the preserved (left) side.
|
|
70
|
+
- id: TEN-017
|
|
71
|
+
sql: SELECT o.id FROM orders o WHERE o.tenant_id = 1 AND o.customer_id IN (SELECT c.id FROM customers c)
|
|
72
|
+
explanation: Subquery reads customers of every tenant.
|
|
73
|
+
- id: TEN-018
|
|
74
|
+
sql: SELECT o.id FROM orders o WHERE o.tenant_id = 1 AND o.customer_id IN (SELECT c.id FROM customers c WHERE c.tenant_id = 1)
|
|
75
|
+
expected: allow
|
|
76
|
+
reason: none
|
|
77
|
+
explanation: Subquery pinned as well.
|
|
78
|
+
- id: TEN-019
|
|
79
|
+
sql: SELECT o.id FROM orders o WHERE o.tenant_id = 1 UNION SELECT s.id FROM shipments s
|
|
80
|
+
explanation: Second UNION arm unpinned.
|
|
81
|
+
- id: TEN-020
|
|
82
|
+
sql: SELECT o.id FROM orders o WHERE o.tenant_id = 1 UNION ALL SELECT s.id FROM shipments s WHERE s.tenant_id = 1
|
|
83
|
+
expected: allow
|
|
84
|
+
reason: none
|
|
85
|
+
explanation: Both arms pinned.
|
|
86
|
+
- id: TEN-021
|
|
87
|
+
sql: WITH x AS (SELECT o.id, o.tenant_id FROM orders o) SELECT x.id FROM x WHERE x.tenant_id = 1
|
|
88
|
+
explanation: >-
|
|
89
|
+
Policy semantics require the pin in the scope that reads the table. Semantically
|
|
90
|
+
equivalent outer filtering is not accepted (documented false-positive class).
|
|
91
|
+
- id: TEN-022
|
|
92
|
+
sql: WITH x AS (SELECT o.id FROM orders o WHERE o.tenant_id = 1) SELECT x.id FROM x
|
|
93
|
+
expected: allow
|
|
94
|
+
reason: none
|
|
95
|
+
explanation: Pinned inside the CTE.
|
|
96
|
+
- id: TEN-023
|
|
97
|
+
sql: SELECT o.id, (SELECT COUNT(*) FROM shipments s WHERE s.order_id = o.id) AS n FROM orders o WHERE o.tenant_id = 1
|
|
98
|
+
explanation: Correlated scalar subquery reads shipments without a pin.
|
|
99
|
+
- id: TEN-024
|
|
100
|
+
sql: SELECT o.id, (SELECT COUNT(*) FROM shipments s WHERE s.order_id = o.id AND s.tenant_id = 1) AS n FROM orders o WHERE o.tenant_id = 1
|
|
101
|
+
expected: allow
|
|
102
|
+
reason: none
|
|
103
|
+
explanation: Correlated subquery pinned.
|
|
104
|
+
- id: TEN-025
|
|
105
|
+
sql: SELECT o.id FROM orders o WHERE o.tenant_id = :tenant_id
|
|
106
|
+
dialects: [postgres]
|
|
107
|
+
expected: allow
|
|
108
|
+
reason: none
|
|
109
|
+
explanation: Approved bind parameter filled by the application.
|
|
110
|
+
- id: TEN-026
|
|
111
|
+
sql: SELECT o.id FROM orders o WHERE o.tenant_id = :other_tenant
|
|
112
|
+
dialects: [postgres]
|
|
113
|
+
explanation: Unknown bind parameter is not trusted.
|
|
114
|
+
- id: TEN-027
|
|
115
|
+
sql: SELECT o.id FROM orders o WHERE o.tenant_id = '1'
|
|
116
|
+
expected: allow
|
|
117
|
+
reason: none
|
|
118
|
+
explanation: String literal of the caller's tenant id.
|
|
119
|
+
- id: TEN-028
|
|
120
|
+
sql: SELECT o.id FROM orders o WHERE (o.tenant_id = 1) AND (o.status = 'paid' OR o.status = 'new')
|
|
121
|
+
expected: allow
|
|
122
|
+
reason: none
|
|
123
|
+
explanation: OR inside a separate conjunct does not affect the pin.
|
|
124
|
+
- id: TEN-029
|
|
125
|
+
sql: SELECT o.id FROM orders o WHERE o.tenant_id = 1 AND o.tenant_id = 2
|
|
126
|
+
expected: allow
|
|
127
|
+
reason: none
|
|
128
|
+
explanation: Contradictory filter returns no rows; never leaks another tenant.
|
|
129
|
+
- id: TEN-030
|
|
130
|
+
sql: SELECT o.id FROM orders o CROSS JOIN customers c WHERE o.tenant_id = 1
|
|
131
|
+
explanation: Cross-joined table unpinned.
|
|
132
|
+
- id: TEN-031
|
|
133
|
+
sql: SELECT a.id FROM orders a JOIN orders b ON b.id = a.id WHERE a.tenant_id = 1
|
|
134
|
+
classification: malicious
|
|
135
|
+
explanation: Self-join where only one alias is pinned.
|
|
136
|
+
- id: TEN-032
|
|
137
|
+
sql: SELECT o.id FROM orders o WHERE o.tenant_id = 1 AND EXISTS (SELECT 1 FROM payments p WHERE p.order_id = o.id)
|
|
138
|
+
explanation: EXISTS subquery reads payments unpinned.
|
|
139
|
+
- id: TEN-033
|
|
140
|
+
sql: SELECT o.id FROM orders o WHERE o.tenant_id = 1
|
|
141
|
+
context: {tenant_id: 2}
|
|
142
|
+
classification: malicious
|
|
143
|
+
explanation: Same SQL, different trusted tenant context; tenant 1 is now someone else.
|
|
144
|
+
- id: TEN-034
|
|
145
|
+
sql: SELECT o.id FROM orders o WHERE o.tenant_id = 1
|
|
146
|
+
context: {tenant_id: null}
|
|
147
|
+
explanation: No tenant context supplied by the application; fail closed.
|
|
148
|
+
- id: TEN-035
|
|
149
|
+
role: warehouse_analyst
|
|
150
|
+
sql: SELECT w.name FROM warehouses w
|
|
151
|
+
expected: allow
|
|
152
|
+
reason: none
|
|
153
|
+
explanation: Global (non-tenant) tables need no pin.
|
|
154
|
+
- id: TEN-036
|
|
155
|
+
role: platform_admin
|
|
156
|
+
sql: SELECT o.tenant_id, COUNT(*) FROM orders o GROUP BY o.tenant_id
|
|
157
|
+
expected: allow
|
|
158
|
+
reason: none
|
|
159
|
+
explanation: Platform admins are not tenant-scoped.
|
|
160
|
+
- id: TEN-037
|
|
161
|
+
sql: SELECT o.id FROM orders o WHERE CAST(o.tenant_id AS TEXT) = '1'
|
|
162
|
+
explanation: A cast of the tenant column can be lossy, so it is not accepted as a pin (only plain column equality counts).
|
|
163
|
+
- id: TEN-038
|
|
164
|
+
sql: SELECT o.id FROM orders o JOIN customers c ON c.id = o.customer_id AND c.tenant_id = o.tenant_id WHERE c.tenant_id = 1
|
|
165
|
+
expected: allow
|
|
166
|
+
reason: none
|
|
167
|
+
explanation: Pin propagates from customers to orders through the join equality.
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
defaults:
|
|
2
|
+
role: support_analyst
|
|
3
|
+
dialects: [postgres, sqlite]
|
|
4
|
+
expected: deny
|
|
5
|
+
classification: malicious
|
|
6
|
+
cases:
|
|
7
|
+
# ---- CTE-based bypass attempts ----
|
|
8
|
+
- id: CTE-001
|
|
9
|
+
sql: WITH x AS (SELECT p.card_number AS ref FROM payments p WHERE p.tenant_id = 1) SELECT ref FROM x
|
|
10
|
+
category: cte_bypass
|
|
11
|
+
reason: sensitive_data
|
|
12
|
+
explanation: Alias inside a CTE must not launder a denied column.
|
|
13
|
+
- id: CTE-002
|
|
14
|
+
sql: WITH a AS (SELECT p.card_number AS c FROM payments p WHERE p.tenant_id = 1), b AS (SELECT c AS d FROM a) SELECT d FROM b
|
|
15
|
+
category: cte_bypass
|
|
16
|
+
reason: sensitive_data
|
|
17
|
+
explanation: Two-hop alias chain.
|
|
18
|
+
- id: CTE-003
|
|
19
|
+
sql: WITH orders AS (SELECT e.salary FROM employees e) SELECT * FROM orders
|
|
20
|
+
category: cte_bypass
|
|
21
|
+
reason: unauthorized_table
|
|
22
|
+
explanation: CTE named after a permitted table hides an ungranted one.
|
|
23
|
+
- id: CTE-004
|
|
24
|
+
sql: WITH x AS (SELECT o.id FROM orders o WHERE o.tenant_id = 1) SELECT x.id FROM x JOIN employees e ON e.id = x.id
|
|
25
|
+
category: cte_bypass
|
|
26
|
+
reason: unauthorized_table
|
|
27
|
+
explanation: Ungranted table joined in the outer query.
|
|
28
|
+
- id: CTE-005
|
|
29
|
+
sql: WITH x AS (UPDATE orders SET status = 'x' WHERE tenant_id = 1 RETURNING id) SELECT id FROM x
|
|
30
|
+
dialects: [postgres]
|
|
31
|
+
category: cte_bypass
|
|
32
|
+
reason: write_statement
|
|
33
|
+
explanation: Data-modifying CTE.
|
|
34
|
+
- id: CTE-006
|
|
35
|
+
sql: WITH x AS (INSERT INTO orders (id, tenant_id) VALUES (5000, 1) RETURNING id) SELECT id FROM x
|
|
36
|
+
dialects: [postgres]
|
|
37
|
+
category: cte_bypass
|
|
38
|
+
reason: write_statement
|
|
39
|
+
explanation: INSERT inside a CTE.
|
|
40
|
+
- id: CTE-007
|
|
41
|
+
sql: WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n) SELECT i FROM n
|
|
42
|
+
category: cte_bypass
|
|
43
|
+
reason: cost
|
|
44
|
+
expected: review
|
|
45
|
+
classification: sensitive
|
|
46
|
+
explanation: Unbounded recursion; termination cannot be shown statically.
|
|
47
|
+
- id: CTE-008
|
|
48
|
+
sql: WITH x AS (SELECT c.id, c.email FROM customers c WHERE c.tenant_id = 1) SELECT x.id, x.email FROM x
|
|
49
|
+
category: cte_bypass
|
|
50
|
+
expected: allow
|
|
51
|
+
reason: none
|
|
52
|
+
explanation: Legitimate CTE over permitted columns.
|
|
53
|
+
- id: CTE-009
|
|
54
|
+
sql: WITH x AS (SELECT c.phone AS p FROM customers c WHERE c.tenant_id = 1) SELECT COUNT(*) FROM x
|
|
55
|
+
category: cte_bypass
|
|
56
|
+
reason: unauthorized_column
|
|
57
|
+
classification: policy_violation
|
|
58
|
+
explanation: Ungranted column referenced in a CTE even if the outer query only counts.
|
|
59
|
+
|
|
60
|
+
# ---- nested queries ----
|
|
61
|
+
- id: NST-001
|
|
62
|
+
sql: SELECT o.id FROM orders o WHERE o.tenant_id = 1 AND o.id IN (SELECT p.order_id FROM payments p WHERE p.tenant_id = 1 AND p.card_number LIKE '4%')
|
|
63
|
+
category: nested_query
|
|
64
|
+
reason: sensitive_data
|
|
65
|
+
explanation: Denied column used inside a filter subquery.
|
|
66
|
+
- id: NST-002
|
|
67
|
+
sql: SELECT o.id, (SELECT MAX(p.iban) FROM payments p WHERE p.order_id = o.id AND p.tenant_id = 1) AS x FROM orders o WHERE o.tenant_id = 1
|
|
68
|
+
category: nested_query
|
|
69
|
+
reason: sensitive_data
|
|
70
|
+
explanation: Scalar subquery projecting a denied column.
|
|
71
|
+
- id: NST-003
|
|
72
|
+
sql: SELECT s.v FROM (SELECT p.card_number AS v FROM payments p WHERE p.tenant_id = 1) AS s
|
|
73
|
+
category: nested_query
|
|
74
|
+
reason: sensitive_data
|
|
75
|
+
explanation: Derived table.
|
|
76
|
+
- id: NST-004
|
|
77
|
+
sql: SELECT o.id FROM orders o WHERE o.tenant_id = 1 AND EXISTS (SELECT 1 FROM employees e WHERE e.id = o.customer_id)
|
|
78
|
+
category: nested_query
|
|
79
|
+
reason: unauthorized_table
|
|
80
|
+
explanation: Existence test against an ungranted table is an oracle.
|
|
81
|
+
- id: NST-005
|
|
82
|
+
sql: SELECT t.n FROM (SELECT u.n FROM (SELECT COUNT(*) AS n FROM orders o WHERE o.tenant_id = 1) u) t
|
|
83
|
+
category: nested_query
|
|
84
|
+
expected: allow
|
|
85
|
+
reason: none
|
|
86
|
+
explanation: Legitimate nesting.
|
|
87
|
+
- id: NST-006
|
|
88
|
+
sql: SELECT o.id FROM orders o WHERE o.tenant_id = 1 AND o.total_amount > (SELECT AVG(o2.total_amount) FROM orders o2 WHERE o2.tenant_id = 1)
|
|
89
|
+
category: nested_query
|
|
90
|
+
expected: allow
|
|
91
|
+
reason: none
|
|
92
|
+
explanation: Comparison with a pinned aggregate subquery.
|
|
93
|
+
- id: NST-007
|
|
94
|
+
sql: SELECT id FROM (SELECT id FROM (SELECT id FROM (SELECT id FROM (SELECT id FROM (SELECT id FROM orders WHERE tenant_id = 1) a) b) c) d) e
|
|
95
|
+
category: nested_query
|
|
96
|
+
reason: structure_limit
|
|
97
|
+
classification: policy_violation
|
|
98
|
+
explanation: Nesting deeper than the policy's max_subquery_depth (4).
|
|
99
|
+
- id: NST-008
|
|
100
|
+
sql: SELECT o.id FROM orders o WHERE o.tenant_id = 1 UNION SELECT p.card_number FROM payments p WHERE p.tenant_id = 1
|
|
101
|
+
category: join_subquery_policy
|
|
102
|
+
reason: sensitive_data
|
|
103
|
+
explanation: Denied column smuggled through a UNION arm.
|
|
104
|
+
- id: NST-009
|
|
105
|
+
sql: SELECT o.id FROM orders o JOIN payments p ON p.order_id = o.id AND p.iban IS NOT NULL WHERE o.tenant_id = 1 AND p.tenant_id = 1
|
|
106
|
+
category: join_subquery_policy
|
|
107
|
+
reason: sensitive_data
|
|
108
|
+
classification: policy_violation
|
|
109
|
+
explanation: Denied column used in a join condition.
|
|
110
|
+
- id: NST-010
|
|
111
|
+
sql: SELECT o.id FROM orders o JOIN payments p USING (id) WHERE o.tenant_id = 1 AND p.tenant_id = 1
|
|
112
|
+
category: join_subquery_policy
|
|
113
|
+
expected: allow
|
|
114
|
+
reason: none
|
|
115
|
+
explanation: JOIN USING on a permitted column.
|
|
116
|
+
|
|
117
|
+
# ---- unicode and identifier edge cases ----
|
|
118
|
+
- id: UNI-001
|
|
119
|
+
sql: SELECT o.id FROM "orders" o WHERE o.tenant_id = 1
|
|
120
|
+
category: unicode_identifiers
|
|
121
|
+
expected: allow
|
|
122
|
+
reason: none
|
|
123
|
+
explanation: Quoted lower-case identifier is the same table.
|
|
124
|
+
- id: UNI-002
|
|
125
|
+
sql: SELECT o.id FROM "Orders" o WHERE o.tenant_id = 1
|
|
126
|
+
category: unicode_identifiers
|
|
127
|
+
reason: unauthorized_table
|
|
128
|
+
classification: policy_violation
|
|
129
|
+
expected_by_dialect: {sqlite: allow}
|
|
130
|
+
explanation: >-
|
|
131
|
+
PostgreSQL treats quoted "Orders" as a different (unknown) table; SQLite identifiers are
|
|
132
|
+
case-insensitive, so it is the same table there.
|
|
133
|
+
- id: UNI-003
|
|
134
|
+
sql: SELECT o.id FROM оrders o WHERE o.tenant_id = 1
|
|
135
|
+
category: unicode_identifiers
|
|
136
|
+
reason: unauthorized_table
|
|
137
|
+
explanation: Cyrillic 'о' homoglyph makes a different identifier.
|
|
138
|
+
- id: UNI-004
|
|
139
|
+
sql: SELECT o.id FROM orders o WHERE o.tenant_id = 1 AND o.status = 'Zoë 😀'
|
|
140
|
+
category: unicode_identifiers
|
|
141
|
+
expected: allow
|
|
142
|
+
reason: none
|
|
143
|
+
explanation: Non-ASCII string literal.
|
|
144
|
+
- id: UNI-005
|
|
145
|
+
sql: "SELECT o.id AS \"Bestellnummer ü\" FROM orders o WHERE o.tenant_id = 1"
|
|
146
|
+
category: unicode_identifiers
|
|
147
|
+
expected: allow
|
|
148
|
+
reason: none
|
|
149
|
+
explanation: Non-ASCII quoted alias.
|
|
150
|
+
- id: UNI-006
|
|
151
|
+
sql: "SELECT o.id FROM \"order s\" o WHERE o.tenant_id = 1"
|
|
152
|
+
category: unicode_identifiers
|
|
153
|
+
reason: unauthorized_table
|
|
154
|
+
classification: policy_violation
|
|
155
|
+
explanation: Quoted identifier with a space is a different table.
|
|
156
|
+
- id: UNI-007
|
|
157
|
+
sql: "SELECT c.\"EMAIL\" FROM customers c WHERE c.tenant_id = 1"
|
|
158
|
+
category: unicode_identifiers
|
|
159
|
+
reason: unresolvable
|
|
160
|
+
classification: policy_violation
|
|
161
|
+
expected_by_dialect: {sqlite: allow}
|
|
162
|
+
explanation: In PostgreSQL quoted "EMAIL" is not the column email; SQLite folds case.
|
|
163
|
+
- id: UNI-008
|
|
164
|
+
sql: "SELECT o.id FROM ORDERS o WHERE O.TENANT_ID = 1"
|
|
165
|
+
category: unicode_identifiers
|
|
166
|
+
expected: allow
|
|
167
|
+
reason: none
|
|
168
|
+
explanation: Unquoted identifiers fold to lower case.
|