sqlguard-ninja 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. sqlguard_ninja/__init__.py +35 -0
  2. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/01_destructive.yaml +169 -0
  3. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/02_multi_statement_and_formatting.yaml +155 -0
  4. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/03_table_and_column_access.yaml +205 -0
  5. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/04_privacy.yaml +245 -0
  6. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/05_tenant_isolation.yaml +167 -0
  7. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/06_cte_nested_unicode.yaml +168 -0
  8. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/07_legitimate_lookalike_cost_dialect.yaml +243 -0
  9. sqlguard_ninja/_data/benchmarks/datasets/v1/generated/grant_matrix.yaml +573 -0
  10. sqlguard_ninja/_data/benchmarks/datasets/v1/generated/label_matrix.yaml +403 -0
  11. sqlguard_ninja/_data/benchmarks/datasets/v1/generated/tenant_matrix.yaml +684 -0
  12. sqlguard_ninja/_data/benchmarks/datasets/v1/generated/transforms.yaml +927 -0
  13. sqlguard_ninja/_data/benchmarks/datasets/v1/manifest.yaml +15 -0
  14. sqlguard_ninja/_data/benchmarks/policies/retail_saas.yaml +259 -0
  15. sqlguard_ninja/_version.py +1 -0
  16. sqlguard_ninja/benchmarking/__init__.py +1 -0
  17. sqlguard_ninja/benchmarking/adapters.py +303 -0
  18. sqlguard_ninja/benchmarking/builtin.py +39 -0
  19. sqlguard_ninja/benchmarking/compare.py +168 -0
  20. sqlguard_ninja/benchmarking/dataset.py +265 -0
  21. sqlguard_ninja/benchmarking/fixtures.py +274 -0
  22. sqlguard_ninja/benchmarking/metrics.py +114 -0
  23. sqlguard_ninja/benchmarking/runner.py +126 -0
  24. sqlguard_ninja/benchmarking/translate.py +64 -0
  25. sqlguard_ninja/benchmarking/workers/_protocol.py +53 -0
  26. sqlguard_ninja/benchmarking/workers/agent_sql_guard_worker.py +40 -0
  27. sqlguard_ninja/benchmarking/workers/krish_sqlguard_worker.py +25 -0
  28. sqlguard_ninja/benchmarking/workers/sql_guardrail_worker.py +48 -0
  29. sqlguard_ninja/cli/__init__.py +1 -0
  30. sqlguard_ninja/cli/config.py +76 -0
  31. sqlguard_ninja/cli/main.py +433 -0
  32. sqlguard_ninja/cli/templates.py +91 -0
  33. sqlguard_ninja/core/__init__.py +1 -0
  34. sqlguard_ninja/core/analysis.py +581 -0
  35. sqlguard_ninja/core/findings.py +133 -0
  36. sqlguard_ninja/core/guard.py +256 -0
  37. sqlguard_ninja/core/rules/__init__.py +1 -0
  38. sqlguard_ninja/core/rules/access.py +127 -0
  39. sqlguard_ninja/core/rules/base.py +40 -0
  40. sqlguard_ninja/core/rules/catalog.py +61 -0
  41. sqlguard_ninja/core/rules/functions.py +197 -0
  42. sqlguard_ninja/core/rules/statements.py +99 -0
  43. sqlguard_ninja/core/rules/structure.py +72 -0
  44. sqlguard_ninja/core/rules/tenant.py +196 -0
  45. sqlguard_ninja/integrations/__init__.py +43 -0
  46. sqlguard_ninja/integrations/audit.py +109 -0
  47. sqlguard_ninja/integrations/checkpoint.py +342 -0
  48. sqlguard_ninja/integrations/executors.py +64 -0
  49. sqlguard_ninja/integrations/fastapi.py +89 -0
  50. sqlguard_ninja/integrations/langchain.py +67 -0
  51. sqlguard_ninja/integrations/langgraph.py +87 -0
  52. sqlguard_ninja/policy/__init__.py +39 -0
  53. sqlguard_ninja/policy/model.py +370 -0
  54. sqlguard_ninja/privacy/__init__.py +9 -0
  55. sqlguard_ninja/privacy/rules.py +286 -0
  56. sqlguard_ninja/privacy/suggest.py +46 -0
  57. sqlguard_ninja/py.typed +0 -0
  58. sqlguard_ninja/query_cost/__init__.py +1 -0
  59. sqlguard_ninja/query_cost/postgres.py +226 -0
  60. sqlguard_ninja/query_cost/static.py +241 -0
  61. sqlguard_ninja/reporting/__init__.py +1 -0
  62. sqlguard_ninja/reporting/markdown.py +137 -0
  63. sqlguard_ninja-0.1.0.dist-info/METADATA +370 -0
  64. sqlguard_ninja-0.1.0.dist-info/RECORD +68 -0
  65. sqlguard_ninja-0.1.0.dist-info/WHEEL +4 -0
  66. sqlguard_ninja-0.1.0.dist-info/entry_points.txt +2 -0
  67. sqlguard_ninja-0.1.0.dist-info/licenses/LICENSE +202 -0
  68. sqlguard_ninja-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +56 -0
@@ -0,0 +1,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.