sql-data-guard 0.1.8__tar.gz → 0.2.0__tar.gz

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 (53) hide show
  1. {sql_data_guard-0.1.8/src/sql_data_guard.egg-info → sql_data_guard-0.2.0}/PKG-INFO +58 -20
  2. {sql_data_guard-0.1.8 → sql_data_guard-0.2.0}/README.md +49 -16
  3. sql_data_guard-0.2.0/pyproject.toml +50 -0
  4. sql_data_guard-0.2.0/src/sql_data_guard/__init__.py +4 -0
  5. sql_data_guard-0.2.0/src/sql_data_guard/column_masking.py +127 -0
  6. sql_data_guard-0.2.0/src/sql_data_guard/config_suggestion.py +169 -0
  7. sql_data_guard-0.2.0/src/sql_data_guard/mcpwrapper/mcp_wrapper.py +331 -0
  8. sql_data_guard-0.2.0/src/sql_data_guard/rest/__init__.py +3 -0
  9. sql_data_guard-0.2.0/src/sql_data_guard/rest/gunicorn.conf.py +20 -0
  10. sql_data_guard-0.2.0/src/sql_data_guard/rest/sql_data_guard_rest.py +87 -0
  11. {sql_data_guard-0.1.8 → sql_data_guard-0.2.0}/src/sql_data_guard/restriction_validation.py +45 -5
  12. {sql_data_guard-0.1.8 → sql_data_guard-0.2.0}/src/sql_data_guard/restriction_verification.py +60 -30
  13. sql_data_guard-0.2.0/src/sql_data_guard/sql_data_guard.py +729 -0
  14. sql_data_guard-0.2.0/src/sql_data_guard/verification_context.py +127 -0
  15. sql_data_guard-0.2.0/src/sql_data_guard/verification_utils.py +66 -0
  16. {sql_data_guard-0.1.8 → sql_data_guard-0.2.0/src/sql_data_guard.egg-info}/PKG-INFO +58 -20
  17. {sql_data_guard-0.1.8 → sql_data_guard-0.2.0}/src/sql_data_guard.egg-info/SOURCES.txt +14 -0
  18. sql_data_guard-0.2.0/src/sql_data_guard.egg-info/requires.txt +1 -0
  19. sql_data_guard-0.2.0/test/test_bypass_unit.py +790 -0
  20. sql_data_guard-0.2.0/test/test_column_masking_unit.py +241 -0
  21. sql_data_guard-0.2.0/test/test_config_suggestion_unit.py +267 -0
  22. sql_data_guard-0.2.0/test/test_correlated_unit.py +96 -0
  23. sql_data_guard-0.2.0/test/test_cte_scope_unit.py +66 -0
  24. sql_data_guard-0.2.0/test/test_dify_plugin_unit.py +136 -0
  25. {sql_data_guard-0.1.8 → sql_data_guard-0.2.0}/test/test_duckdb_unit.py +4 -3
  26. sql_data_guard-0.2.0/test/test_function_policy_unit.py +156 -0
  27. sql_data_guard-0.2.0/test/test_identifier_case_unit.py +59 -0
  28. sql_data_guard-0.2.0/test/test_limit_enforcement_unit.py +167 -0
  29. sql_data_guard-0.2.0/test/test_mcp_wrapper_unit.py +430 -0
  30. sql_data_guard-0.2.0/test/test_rest_api_unit.py +240 -0
  31. sql_data_guard-0.2.0/test/test_security_unit.py +249 -0
  32. {sql_data_guard-0.1.8 → sql_data_guard-0.2.0}/test/test_sql_guard_curr_unit.py +2 -6
  33. sql_data_guard-0.2.0/test/test_sql_guard_joins_unit.py +385 -0
  34. {sql_data_guard-0.1.8 → sql_data_guard-0.2.0}/test/test_sql_guard_llm.py +2 -2
  35. {sql_data_guard-0.1.8 → sql_data_guard-0.2.0}/test/test_sql_guard_unit.py +41 -0
  36. {sql_data_guard-0.1.8 → sql_data_guard-0.2.0}/test/test_sql_guard_updates_unit.py +23 -15
  37. {sql_data_guard-0.1.8 → sql_data_guard-0.2.0}/test/test_utils.py +12 -7
  38. sql_data_guard-0.1.8/pyproject.toml +0 -30
  39. sql_data_guard-0.1.8/src/sql_data_guard/__init__.py +0 -1
  40. sql_data_guard-0.1.8/src/sql_data_guard/mcpwrapper/mcp_wrapper.py +0 -86
  41. sql_data_guard-0.1.8/src/sql_data_guard/rest/__init__.py +0 -1
  42. sql_data_guard-0.1.8/src/sql_data_guard/rest/sql_data_guard_rest.py +0 -39
  43. sql_data_guard-0.1.8/src/sql_data_guard/sql_data_guard.py +0 -295
  44. sql_data_guard-0.1.8/src/sql_data_guard/verification_context.py +0 -63
  45. sql_data_guard-0.1.8/src/sql_data_guard/verification_utils.py +0 -18
  46. sql_data_guard-0.1.8/src/sql_data_guard.egg-info/requires.txt +0 -1
  47. sql_data_guard-0.1.8/test/test_rest_api_unit.py +0 -76
  48. sql_data_guard-0.1.8/test/test_sql_guard_joins_unit.py +0 -300
  49. {sql_data_guard-0.1.8 → sql_data_guard-0.2.0}/LICENSE.md +0 -0
  50. {sql_data_guard-0.1.8 → sql_data_guard-0.2.0}/setup.cfg +0 -0
  51. {sql_data_guard-0.1.8 → sql_data_guard-0.2.0}/src/sql_data_guard.egg-info/dependency_links.txt +0 -0
  52. {sql_data_guard-0.1.8 → sql_data_guard-0.2.0}/src/sql_data_guard.egg-info/top_level.txt +0 -0
  53. {sql_data_guard-0.1.8 → sql_data_guard-0.2.0}/test/test_sql_guard_validation_unit.py +0 -0
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql-data-guard
3
- Version: 0.1.8
3
+ Version: 0.2.0
4
4
  Summary: Safety Layer for LLM Database Interactions
5
- Author-email: Imperva - Threat Reseach Infra <ww.dis.imperva.threat-research-infra@thalesgroup.com>
5
+ Author-email: Imperva - Threat Research Infra <ww.dis.imperva.threat-research-infra@thalesgroup.com>
6
6
  License: MIT License
7
7
 
8
8
  Copyright (c) 2025 Imperva
@@ -15,11 +15,16 @@ License: MIT License
15
15
  Project-URL: Homepage, https://github.com/ThalesGroup/sql-data-guard
16
16
  Project-URL: Issues, https://github.com/ThalesGroup/sql-data-guard/issues
17
17
  Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
18
23
  Classifier: Operating System :: OS Independent
19
- Requires-Python: >=3.8
24
+ Requires-Python: >=3.10
20
25
  Description-Content-Type: text/markdown
21
26
  License-File: LICENSE.md
22
- Requires-Dist: sqlglot
27
+ Requires-Dist: sqlglot<31,>=25.20.1
23
28
  Dynamic: license-file
24
29
 
25
30
 
@@ -32,7 +37,7 @@ Dynamic: license-file
32
37
  SQL is the go-to language for performing queries on databases, and for a good reason - it’s well known, easy to use and pretty simple. However, it seems that it’s as easy to use as it is to exploit, and SQL injection is still one of the most targeted vulnerabilities - especially nowadays with the proliferation of “natural language queries” harnessing Large Language Models (LLMs) power to generate and run SQL queries.
33
38
 
34
39
 
35
- To help solve this problem, we developed sql-data-guard, an open-source project designed to verify that SQL queries access only the data they are allowed to. It takes a query and a restriction configuration, and returns whether the query is allowed to run or not. Additionally, it can modify the query to ensure it complies with the restrictions. sql-data-guard has also a built-in module for detection of malicious payloads, allowing it to report on and remove malicious expressions before query execution.
40
+ To help solve this problem, we developed sql-data-guard, an open-source project designed to verify that SQL queries access only the data they are allowed to. It takes a query and a restriction configuration, and returns whether the query is allowed to run or not. Additionally, it can modify the query to ensure it complies with the restrictions. sql-data-guard also detects "always-true" (static) expressions such as `OR 1 = 1`, a common injection payload, and removes them before query execution.
36
41
 
37
42
 
38
43
  sql-data-guard is particularly useful when constructing SQL queries with LLMs, as such queries can’t run as prepared statements. Prepared statements secure a query’s structure, but LLM-generated queries are dynamic and lack this fixed form, increasing SQL injection risk. sql-data-guard mitigates this by inspecting and validating the query's content.
@@ -45,14 +50,14 @@ Connecting LLMs to SQL databases without strict controls can risk accidental dat
45
50
 
46
51
  ## Why Use sql-data-guard?
47
52
 
48
- Consider using sql-guard if your application constructs SQL queries, and you need to ensure that only permitted data is accessed. This is particularly beneficial if:
53
+ Consider using sql-data-guard if your application constructs SQL queries, and you need to ensure that only permitted data is accessed. This is particularly beneficial if:
49
54
  - Your application generates complex SQL queries.
50
55
  - Your application employs LLM (Large Language Models) to create SQL queries, making it difficult to fully control the queries.
51
56
  - Different application users and roles should have different permissions, and you need to correlate an application user or role with fine-grained data access permission.
52
57
  - In multi-tenant applications, you need to ensure that each tenant can access only their data, which requires row-level security and often cannot be done using the database permissions model.
53
58
 
54
- sql-guard does not replace the database permissions model. Instead, it adds an extra layer of security, which is crucial when implementing fine-grained, column-level, and row-level security is challenging or impossible.
55
- Data restrictions are often complex and cannot be expressed by the database permissions model. For instance, you may need to restrict access to specific columns or rows based on intricate business logic, which many database implementations do not support. Instead of relying on the database to enforce these restrictions, sql-guard helps you overcome vendor-specific limitations by verifying and modifying queries before they are executed.
59
+ sql-data-guard does not replace the database permissions model. Instead, it adds an extra layer of security, which is crucial when implementing fine-grained, column-level, and row-level security is challenging or impossible.
60
+ Data restrictions are often complex and cannot be expressed by the database permissions model. For instance, you may need to restrict access to specific columns or rows based on intricate business logic, which many database implementations do not support. Instead of relying on the database to enforce these restrictions, sql-data-guard helps you overcome vendor-specific limitations by verifying and modifying queries before they are executed.
56
61
 
57
62
  ## How It Works
58
63
 
@@ -73,7 +78,7 @@ from sql_data_guard import verify_sql
73
78
  config = {
74
79
  "tables": [
75
80
  {
76
- "name": "orders",
81
+ "table_name": "orders",
77
82
  "columns": ["id", "product_name", "account_id"],
78
83
  "restrictions": [{"column": "account_id", "value": 123}]
79
84
  }
@@ -88,23 +93,32 @@ Output:
88
93
  ```json
89
94
  {
90
95
  "allowed": false,
91
- "errors": ["Column name not allowed. Column removed from SELECT clause",
92
- "Always-True expression is not allowed", "Missing restriction for table: orders column: account_id value: 123"],
93
- "fixed": "SELECT id, product_name, account_id FROM orders WHERE account_id = 123"
96
+ "errors": [
97
+ "Column name is not allowed. Column removed from SELECT clause",
98
+ "Missing restriction for table: orders column: account_id value: 123",
99
+ "Static expression is not allowed: 1 = 1"
100
+ ],
101
+ "fixed": "SELECT id FROM orders WHERE account_id = 123",
102
+ "risk": 0.53
94
103
  }
95
104
  ```
96
- For more details on restriction rules and validation, see the [manual.](docs/manual.md)
105
+ `errors` is a sorted list. `risk` is between 0 (no risk) and 1 (high risk).
106
+ For more details on restriction rules and validation, see the [manual.](https://raw.githubusercontent.com/ThalesGroup/sql-data-guard/main/docs/manual.md)
97
107
 
98
108
 
99
109
  Here is a table with more examples of SQL queries and their corresponding JSON outputs:
100
110
 
101
- | SQL Query | JSON Output |
102
- |---------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
103
- | SELECT id, product_name FROM orders WHERE account_id = 123 | { "allowed": true, "errors": [], "fixed": null } |
104
- | SELECT id FROM orders WHERE account_id = 456 | { "allowed": false, "errors": ["Missing restriction for table: orders column: account_id value: 123"], "fixed": "SELECT id FROM orders WHERE account_id = 456 AND account_id = 123" } |
105
- | SELECT id, col FROM orders WHERE account_id = 123 | { "allowed": false, "errors": ["Column col is not allowed. Column removed from SELECT clause"], "fixed": "SELECT id FROM orders WHERE account_id = 123" } ``` |
106
- | SELECT id FROM orders WHERE account_id = 123 OR 1 = 1 | { "allowed": false, "errors": ["Always-True expression is not allowed"], "fixed": "SELECT id FROM orders WHERE account_id = 123" } |
107
- |SELECT * FROM orders WHERE account_id = 123| {"allowed": false, "errors": ["SELECT * is not allowed"], "fixed": "SELECT id, product_name, account_id FROM orders WHERE account_id = 123"} |
111
+ | SQL Query | JSON Output |
112
+ |-----------|-------------|
113
+ | `SELECT id, product_name FROM orders WHERE account_id = 123` | `{ "allowed": true, "errors": [], "fixed": null, "risk": 0 }` |
114
+ | `SELECT id FROM orders WHERE account_id = 456` | `{ "allowed": false, "errors": ["Missing restriction for table: orders column: account_id value: 123"], "fixed": "SELECT id FROM orders WHERE (account_id = 456) AND account_id = 123", "risk": 0.5 }` |
115
+ | `SELECT id, col FROM orders WHERE account_id = 123` | `{ "allowed": false, "errors": ["Column col is not allowed. Column removed from SELECT clause"], "fixed": "SELECT id FROM orders WHERE account_id = 123", "risk": 0.3 }` |
116
+ | `SELECT id FROM orders WHERE account_id = 123 OR 1 = 1` | `{ "allowed": false, "errors": ["Static expression is not allowed: 1 = 1"], "fixed": "SELECT id FROM orders WHERE account_id = 123", "risk": 0.8 }` |
117
+ | `SELECT * FROM orders WHERE account_id = 123` | `{ "allowed": false, "errors": ["SELECT * is not allowed"], "fixed": "SELECT id, product_name, account_id FROM orders WHERE account_id = 123", "risk": 0.1 }` |
118
+ | `SELECT id FROM orders WHERE account_id = 123 AND secret = 1` | `{ "allowed": false, "errors": ["Column secret is not allowed"], "fixed": null, "risk": 0.8 }` |
119
+ | `SELECT id FROM orders WHERE account_id = 123; DROP TABLE orders` | `{ "allowed": false, "errors": ["Multiple statements are not allowed"], "fixed": null, "risk": 1.0 }` |
120
+
121
+ When `fixed` is `null` the query cannot be repaired and must not be run.
108
122
 
109
123
  This table provides a variety of SQL queries and their corresponding JSON outputs, demonstrating how `sql-data-guard` handles different scenarios.
110
124
 
@@ -127,6 +141,25 @@ To run the sql-data-guard Docker container, use the following command:
127
141
  docker run -d --name sql-data-guard -p 5000:5000 ghcr.io/thalesgroup/sql-data-guard
128
142
  ```
129
143
 
144
+ The image runs the service with [gunicorn](https://gunicorn.org/) (2 worker processes with 4 threads each), not the Flask development server, and installs `sql-data-guard` from PyPI. Release images are tagged with the version (for example `0.1.10`) and `latest`, and contain exactly that release. It runs as a non-root user and listens on port 5000.
145
+
146
+ Settings (environment variables):
147
+
148
+ | Variable | Default | Meaning |
149
+ |----------|---------|---------|
150
+ | `APP_API_TOKEN` | not set | When set, every request must send `Authorization: Bearer <token>`. **Set it unless the service is only reachable by trusted callers.** |
151
+ | `APP_PORT` | `5000` | Port to listen on. |
152
+ | `APP_HOST` | `127.0.0.1` (`0.0.0.0` in the image) | Address to bind. |
153
+ | `APP_MAX_REQUEST_BYTES` | `1000000` | Maximum request size. |
154
+ | `WEB_CONCURRENCY` | `2` | gunicorn worker processes (image only). |
155
+ | `APP_THREADS` | `4` | Threads per worker (image only). |
156
+
157
+ The caller sends the policy (`config`) with every request, so the service is not an enforcement point against callers who must not be able to choose their own policy.
158
+
159
+ ```bash
160
+ docker run -d --name sql-data-guard -p 5000:5000 -e APP_API_TOKEN=change-me ghcr.io/thalesgroup/sql-data-guard
161
+ ```
162
+
130
163
  ### Calling the Docker Container Using REST API
131
164
 
132
165
  Once the `sql-data-guard` Docker container is running, you can interact with it using its REST API. Below is an example of how to verify an SQL query using `curl`:
@@ -134,6 +167,7 @@ Once the `sql-data-guard` Docker container is running, you can interact with it
134
167
  ```bash
135
168
  curl -X POST http://localhost:5000/verify-sql \
136
169
  -H "Content-Type: application/json" \
170
+ -H "Authorization: Bearer change-me" \
137
171
  -d '{
138
172
  "sql": "SELECT * FROM orders WHERE account_id = 123",
139
173
  "config": {
@@ -148,6 +182,10 @@ curl -X POST http://localhost:5000/verify-sql \
148
182
  }'
149
183
  ```
150
184
 
185
+ ## MCP Wrapper
186
+
187
+ sql-data-guard can also protect an MCP server that talks to a database: the wrapper verifies the SQL of every tool call before the server sees it, and refuses tools that could run SQL without the guard. See [the MCP wrapper guide](https://raw.githubusercontent.com/ThalesGroup/sql-data-guard/main/docs/mcp-wrapper.md).
188
+
151
189
  ## Contributing
152
190
  We welcome contributions! Please see our [CONTRIBUTING.md](https://raw.githubusercontent.com/ThalesGroup/sql-data-guard/main/CONTRIBUTING.md) for more details.
153
191
 
@@ -8,7 +8,7 @@
8
8
  SQL is the go-to language for performing queries on databases, and for a good reason - it’s well known, easy to use and pretty simple. However, it seems that it’s as easy to use as it is to exploit, and SQL injection is still one of the most targeted vulnerabilities - especially nowadays with the proliferation of “natural language queries” harnessing Large Language Models (LLMs) power to generate and run SQL queries.
9
9
 
10
10
 
11
- To help solve this problem, we developed sql-data-guard, an open-source project designed to verify that SQL queries access only the data they are allowed to. It takes a query and a restriction configuration, and returns whether the query is allowed to run or not. Additionally, it can modify the query to ensure it complies with the restrictions. sql-data-guard has also a built-in module for detection of malicious payloads, allowing it to report on and remove malicious expressions before query execution.
11
+ To help solve this problem, we developed sql-data-guard, an open-source project designed to verify that SQL queries access only the data they are allowed to. It takes a query and a restriction configuration, and returns whether the query is allowed to run or not. Additionally, it can modify the query to ensure it complies with the restrictions. sql-data-guard also detects "always-true" (static) expressions such as `OR 1 = 1`, a common injection payload, and removes them before query execution.
12
12
 
13
13
 
14
14
  sql-data-guard is particularly useful when constructing SQL queries with LLMs, as such queries can’t run as prepared statements. Prepared statements secure a query’s structure, but LLM-generated queries are dynamic and lack this fixed form, increasing SQL injection risk. sql-data-guard mitigates this by inspecting and validating the query's content.
@@ -21,14 +21,14 @@ Connecting LLMs to SQL databases without strict controls can risk accidental dat
21
21
 
22
22
  ## Why Use sql-data-guard?
23
23
 
24
- Consider using sql-guard if your application constructs SQL queries, and you need to ensure that only permitted data is accessed. This is particularly beneficial if:
24
+ Consider using sql-data-guard if your application constructs SQL queries, and you need to ensure that only permitted data is accessed. This is particularly beneficial if:
25
25
  - Your application generates complex SQL queries.
26
26
  - Your application employs LLM (Large Language Models) to create SQL queries, making it difficult to fully control the queries.
27
27
  - Different application users and roles should have different permissions, and you need to correlate an application user or role with fine-grained data access permission.
28
28
  - In multi-tenant applications, you need to ensure that each tenant can access only their data, which requires row-level security and often cannot be done using the database permissions model.
29
29
 
30
- sql-guard does not replace the database permissions model. Instead, it adds an extra layer of security, which is crucial when implementing fine-grained, column-level, and row-level security is challenging or impossible.
31
- Data restrictions are often complex and cannot be expressed by the database permissions model. For instance, you may need to restrict access to specific columns or rows based on intricate business logic, which many database implementations do not support. Instead of relying on the database to enforce these restrictions, sql-guard helps you overcome vendor-specific limitations by verifying and modifying queries before they are executed.
30
+ sql-data-guard does not replace the database permissions model. Instead, it adds an extra layer of security, which is crucial when implementing fine-grained, column-level, and row-level security is challenging or impossible.
31
+ Data restrictions are often complex and cannot be expressed by the database permissions model. For instance, you may need to restrict access to specific columns or rows based on intricate business logic, which many database implementations do not support. Instead of relying on the database to enforce these restrictions, sql-data-guard helps you overcome vendor-specific limitations by verifying and modifying queries before they are executed.
32
32
 
33
33
  ## How It Works
34
34
 
@@ -49,7 +49,7 @@ from sql_data_guard import verify_sql
49
49
  config = {
50
50
  "tables": [
51
51
  {
52
- "name": "orders",
52
+ "table_name": "orders",
53
53
  "columns": ["id", "product_name", "account_id"],
54
54
  "restrictions": [{"column": "account_id", "value": 123}]
55
55
  }
@@ -64,23 +64,32 @@ Output:
64
64
  ```json
65
65
  {
66
66
  "allowed": false,
67
- "errors": ["Column name not allowed. Column removed from SELECT clause",
68
- "Always-True expression is not allowed", "Missing restriction for table: orders column: account_id value: 123"],
69
- "fixed": "SELECT id, product_name, account_id FROM orders WHERE account_id = 123"
67
+ "errors": [
68
+ "Column name is not allowed. Column removed from SELECT clause",
69
+ "Missing restriction for table: orders column: account_id value: 123",
70
+ "Static expression is not allowed: 1 = 1"
71
+ ],
72
+ "fixed": "SELECT id FROM orders WHERE account_id = 123",
73
+ "risk": 0.53
70
74
  }
71
75
  ```
72
- For more details on restriction rules and validation, see the [manual.](docs/manual.md)
76
+ `errors` is a sorted list. `risk` is between 0 (no risk) and 1 (high risk).
77
+ For more details on restriction rules and validation, see the [manual.](https://raw.githubusercontent.com/ThalesGroup/sql-data-guard/main/docs/manual.md)
73
78
 
74
79
 
75
80
  Here is a table with more examples of SQL queries and their corresponding JSON outputs:
76
81
 
77
- | SQL Query | JSON Output |
78
- |---------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
79
- | SELECT id, product_name FROM orders WHERE account_id = 123 | { "allowed": true, "errors": [], "fixed": null } |
80
- | SELECT id FROM orders WHERE account_id = 456 | { "allowed": false, "errors": ["Missing restriction for table: orders column: account_id value: 123"], "fixed": "SELECT id FROM orders WHERE account_id = 456 AND account_id = 123" } |
81
- | SELECT id, col FROM orders WHERE account_id = 123 | { "allowed": false, "errors": ["Column col is not allowed. Column removed from SELECT clause"], "fixed": "SELECT id FROM orders WHERE account_id = 123" } ``` |
82
- | SELECT id FROM orders WHERE account_id = 123 OR 1 = 1 | { "allowed": false, "errors": ["Always-True expression is not allowed"], "fixed": "SELECT id FROM orders WHERE account_id = 123" } |
83
- |SELECT * FROM orders WHERE account_id = 123| {"allowed": false, "errors": ["SELECT * is not allowed"], "fixed": "SELECT id, product_name, account_id FROM orders WHERE account_id = 123"} |
82
+ | SQL Query | JSON Output |
83
+ |-----------|-------------|
84
+ | `SELECT id, product_name FROM orders WHERE account_id = 123` | `{ "allowed": true, "errors": [], "fixed": null, "risk": 0 }` |
85
+ | `SELECT id FROM orders WHERE account_id = 456` | `{ "allowed": false, "errors": ["Missing restriction for table: orders column: account_id value: 123"], "fixed": "SELECT id FROM orders WHERE (account_id = 456) AND account_id = 123", "risk": 0.5 }` |
86
+ | `SELECT id, col FROM orders WHERE account_id = 123` | `{ "allowed": false, "errors": ["Column col is not allowed. Column removed from SELECT clause"], "fixed": "SELECT id FROM orders WHERE account_id = 123", "risk": 0.3 }` |
87
+ | `SELECT id FROM orders WHERE account_id = 123 OR 1 = 1` | `{ "allowed": false, "errors": ["Static expression is not allowed: 1 = 1"], "fixed": "SELECT id FROM orders WHERE account_id = 123", "risk": 0.8 }` |
88
+ | `SELECT * FROM orders WHERE account_id = 123` | `{ "allowed": false, "errors": ["SELECT * is not allowed"], "fixed": "SELECT id, product_name, account_id FROM orders WHERE account_id = 123", "risk": 0.1 }` |
89
+ | `SELECT id FROM orders WHERE account_id = 123 AND secret = 1` | `{ "allowed": false, "errors": ["Column secret is not allowed"], "fixed": null, "risk": 0.8 }` |
90
+ | `SELECT id FROM orders WHERE account_id = 123; DROP TABLE orders` | `{ "allowed": false, "errors": ["Multiple statements are not allowed"], "fixed": null, "risk": 1.0 }` |
91
+
92
+ When `fixed` is `null` the query cannot be repaired and must not be run.
84
93
 
85
94
  This table provides a variety of SQL queries and their corresponding JSON outputs, demonstrating how `sql-data-guard` handles different scenarios.
86
95
 
@@ -103,6 +112,25 @@ To run the sql-data-guard Docker container, use the following command:
103
112
  docker run -d --name sql-data-guard -p 5000:5000 ghcr.io/thalesgroup/sql-data-guard
104
113
  ```
105
114
 
115
+ The image runs the service with [gunicorn](https://gunicorn.org/) (2 worker processes with 4 threads each), not the Flask development server, and installs `sql-data-guard` from PyPI. Release images are tagged with the version (for example `0.1.10`) and `latest`, and contain exactly that release. It runs as a non-root user and listens on port 5000.
116
+
117
+ Settings (environment variables):
118
+
119
+ | Variable | Default | Meaning |
120
+ |----------|---------|---------|
121
+ | `APP_API_TOKEN` | not set | When set, every request must send `Authorization: Bearer <token>`. **Set it unless the service is only reachable by trusted callers.** |
122
+ | `APP_PORT` | `5000` | Port to listen on. |
123
+ | `APP_HOST` | `127.0.0.1` (`0.0.0.0` in the image) | Address to bind. |
124
+ | `APP_MAX_REQUEST_BYTES` | `1000000` | Maximum request size. |
125
+ | `WEB_CONCURRENCY` | `2` | gunicorn worker processes (image only). |
126
+ | `APP_THREADS` | `4` | Threads per worker (image only). |
127
+
128
+ The caller sends the policy (`config`) with every request, so the service is not an enforcement point against callers who must not be able to choose their own policy.
129
+
130
+ ```bash
131
+ docker run -d --name sql-data-guard -p 5000:5000 -e APP_API_TOKEN=change-me ghcr.io/thalesgroup/sql-data-guard
132
+ ```
133
+
106
134
  ### Calling the Docker Container Using REST API
107
135
 
108
136
  Once the `sql-data-guard` Docker container is running, you can interact with it using its REST API. Below is an example of how to verify an SQL query using `curl`:
@@ -110,6 +138,7 @@ Once the `sql-data-guard` Docker container is running, you can interact with it
110
138
  ```bash
111
139
  curl -X POST http://localhost:5000/verify-sql \
112
140
  -H "Content-Type: application/json" \
141
+ -H "Authorization: Bearer change-me" \
113
142
  -d '{
114
143
  "sql": "SELECT * FROM orders WHERE account_id = 123",
115
144
  "config": {
@@ -124,6 +153,10 @@ curl -X POST http://localhost:5000/verify-sql \
124
153
  }'
125
154
  ```
126
155
 
156
+ ## MCP Wrapper
157
+
158
+ sql-data-guard can also protect an MCP server that talks to a database: the wrapper verifies the SQL of every tool call before the server sees it, and refuses tools that could run SQL without the guard. See [the MCP wrapper guide](https://raw.githubusercontent.com/ThalesGroup/sql-data-guard/main/docs/mcp-wrapper.md).
159
+
127
160
  ## Contributing
128
161
  We welcome contributions! Please see our [CONTRIBUTING.md](https://raw.githubusercontent.com/ThalesGroup/sql-data-guard/main/CONTRIBUTING.md) for more details.
129
162
 
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [tool.setuptools]
6
+ include-package-data = false
7
+ package-dir = {"" = "src"}
8
+ [tool.pip]
9
+ extra-index-url = ["https://pypi.org/simple"]
10
+ [project]
11
+ name = "sql-data-guard"
12
+ version = "0.2.0"
13
+ dependencies = [
14
+ # lower bound: oldest version the test-suite passes on; upper bound: next untested major
15
+ "sqlglot>=25.20.1,<31"
16
+ ]
17
+ authors = [
18
+ { name="Imperva - Threat Research Infra", email="ww.dis.imperva.threat-research-infra@thalesgroup.com" },
19
+ ]
20
+ description = "Safety Layer for LLM Database Interactions"
21
+ readme = "README.md"
22
+ requires-python = ">=3.10"
23
+ classifiers = [
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3.10",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Programming Language :: Python :: 3.13",
29
+ "Programming Language :: Python :: 3.14",
30
+ "Operating System :: OS Independent",
31
+ ]
32
+ license = {file = "LICENSE.md"}
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/ThalesGroup/sql-data-guard"
36
+ Issues = "https://github.com/ThalesGroup/sql-data-guard/issues"
37
+
38
+ [tool.ruff.lint]
39
+ select = ["F", "E9"] # pyflakes + syntax errors
40
+
41
+ [tool.mypy]
42
+ files = ["src/sql_data_guard"]
43
+ ignore_missing_imports = true
44
+
45
+ [tool.coverage.run]
46
+ source = ["sql_data_guard"]
47
+
48
+ [tool.coverage.report]
49
+ fail_under = 90
50
+ show_missing = true
@@ -0,0 +1,4 @@
1
+ from .config_suggestion import merge_config, suggest_config
2
+ from .sql_data_guard import verify_sql
3
+
4
+ __all__ = ["verify_sql", "suggest_config", "merge_config"]
@@ -0,0 +1,127 @@
1
+ """
2
+ Column masking.
3
+
4
+ Instead of removing a sensitive column from the SELECT list (which changes the shape of the
5
+ result and can break the caller), a table can declare it as *masked*: the column is still
6
+ returned under the same name, but its value is replaced, so the real value never leaves the
7
+ database.
8
+
9
+ {
10
+ "table_name": "users",
11
+ "columns": ["id", "name", "ssn"],
12
+ "column_masks": [{"column": "ssn", "policy": "redact", "replacement": "****"}]
13
+ }
14
+
15
+ A masked column can only be selected directly (`ssn`, `u.ssn`, `ssn AS x`, or through `SELECT *`).
16
+ Any other use (WHERE, JOIN, GROUP BY, ORDER BY, inside a function or expression...) would let a
17
+ caller learn the real value one comparison at a time, and cannot be masked, so it is rejected.
18
+ """
19
+
20
+ from typing import Dict, List, Optional
21
+
22
+ import sqlglot.expressions as expr
23
+
24
+ from .verification_utils import names_equal, same_name, table_matches
25
+
26
+ # policy name -> builds the replacement expression of a mask. More policies (for example a
27
+ # partial mask) need functions whose names differ per database, and can be registered here.
28
+ _DEFAULT_REPLACEMENT = "****"
29
+
30
+
31
+ def _redact(mask: dict) -> expr.Expression:
32
+ return expr.Literal.string(mask.get("replacement", _DEFAULT_REPLACEMENT))
33
+
34
+
35
+ MASK_POLICIES = {"redact": _redact}
36
+
37
+
38
+ def validate_column_masks(config: dict) -> None:
39
+ """
40
+ Validates the `column_masks` of every table.
41
+
42
+ Raises:
43
+ ValueError: if a mask is malformed, uses an unknown policy, masks a column that is not in
44
+ the table's `columns`, masks a column twice, or masks a column used by a restriction.
45
+ """
46
+ for table in config.get("tables", []):
47
+ if "column_masks" not in table:
48
+ continue
49
+ name = table.get("table_name")
50
+ masks = table["column_masks"]
51
+ if not isinstance(masks, list):
52
+ raise ValueError(f"'column_masks' of table '{name}' must be a list.")
53
+ allowed = [str(c).lower() for c in table.get("columns", [])]
54
+ restricted = [str(r.get("column")).lower() for r in table.get("restrictions") or []]
55
+ seen = set()
56
+ for mask in masks:
57
+ if not isinstance(mask, dict) or not isinstance(mask.get("column"), str) or not mask["column"]:
58
+ raise ValueError(f"Each column mask of table '{name}' must be an object with a 'column'.")
59
+ column = mask["column"].lower()
60
+ if column not in allowed:
61
+ raise ValueError(
62
+ f"Masked column '{mask['column']}' must also be in the 'columns' of table '{name}'."
63
+ )
64
+ if column in seen:
65
+ raise ValueError(f"Column '{mask['column']}' of table '{name}' is masked more than once.")
66
+ seen.add(column)
67
+ if column in restricted:
68
+ raise ValueError(
69
+ f"Column '{mask['column']}' of table '{name}' cannot be both masked and restricted."
70
+ )
71
+ policy = mask.get("policy")
72
+ if policy not in MASK_POLICIES:
73
+ raise ValueError(
74
+ f"Unsupported mask policy {policy!r} for column '{mask['column']}'. "
75
+ f"Supported policies: {sorted(MASK_POLICIES)}."
76
+ )
77
+ replacement = mask.get("replacement", _DEFAULT_REPLACEMENT)
78
+ if not isinstance(replacement, str):
79
+ raise ValueError(f"'replacement' for column '{mask['column']}' must be a string.")
80
+
81
+
82
+ def find_mask(col: expr.Column, scopes: List[List[expr.Table]], config: dict) -> Optional[dict]:
83
+ """
84
+ The mask that applies to a column reference, or None.
85
+
86
+ `scopes` are the tables of the query being verified and of its enclosing queries, innermost
87
+ first, so a correlated sub-query that refers to a table of the outer query is found too. A
88
+ qualified column is resolved to the innermost table with that name or alias; an unqualified
89
+ column to the innermost query that has a configured table with that column. If several tables
90
+ of that query could provide it, it is masked when any of them masks it (the safe choice).
91
+ """
92
+ for tables in scopes:
93
+ candidates = []
94
+ for table in tables:
95
+ if col.table and not same_name(col.table, table.alias_or_name):
96
+ continue
97
+ for config_table in config["tables"]:
98
+ if table_matches(table, config_table) and (
99
+ col.table or any(names_equal(col, c) for c in config_table["columns"])
100
+ ):
101
+ candidates.append(config_table)
102
+ if candidates:
103
+ return next(
104
+ (
105
+ mask
106
+ for config_table in candidates
107
+ for mask in config_table.get("column_masks") or []
108
+ if names_equal(col, mask["column"])
109
+ ),
110
+ None,
111
+ )
112
+ return None
113
+
114
+
115
+ def mask_value(mask: dict) -> expr.Expression:
116
+ """The expression that replaces the value of a masked column."""
117
+ return MASK_POLICIES[mask["policy"]](mask)
118
+
119
+
120
+ def masked_select_item(mask: dict, name: str) -> expr.Expression:
121
+ """`<mask value> AS <name>`: what a masked column is rewritten to in the SELECT list."""
122
+ return expr.alias_(mask_value(mask), expr.to_identifier(name))
123
+
124
+
125
+ def table_masks(config_table: dict) -> Dict[str, dict]:
126
+ """Masks of a configured table, by lower case column name."""
127
+ return {m["column"].lower(): m for m in config_table.get("column_masks") or []}
@@ -0,0 +1,169 @@
1
+ """
2
+ Suggesting and applying allow-list changes to a configuration.
3
+
4
+ ``suggest_config`` answers "what would this query need to be allowed?" and ``merge_config``
5
+ applies such an answer to a configuration. Both only deal with the table/column allow-list
6
+ (``tables``); restrictions, masks, function lists and limits are never touched.
7
+ """
8
+
9
+ import copy
10
+ import logging
11
+ from typing import List, Optional
12
+
13
+ from .sql_data_guard import _verify_sql
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # Every configured table needs at least one column, so a table that is being added is seeded
18
+ # with this name to let the verification go on and find the columns the query really uses.
19
+ _PLACEHOLDER_COLUMN = "__sql_data_guard_placeholder__"
20
+ _MAX_ROUNDS = 25
21
+ _DELTA_TABLE_KEYS = {"table_name", "database_name", "columns"}
22
+
23
+
24
+ def _identity(table_name: str, database_name: Optional[str]) -> tuple:
25
+ return str(table_name).lower(), str(database_name or "").lower()
26
+
27
+
28
+ def _find_entry(tables: List[dict], table_name: str, database_name: Optional[str]) -> Optional[dict]:
29
+ """The configured table a query reference refers to (same rule as table_matches)."""
30
+ for entry in tables:
31
+ if str(entry["table_name"]).lower() != str(table_name).lower():
32
+ continue
33
+ configured_db = entry.get("database_name")
34
+ if database_name and configured_db and str(database_name).lower() != str(configured_db).lower():
35
+ continue
36
+ return entry
37
+ return None
38
+
39
+
40
+ def _has_column(entry: dict, column: str) -> bool:
41
+ return str(column).lower() in {str(c).lower() for c in entry["columns"]}
42
+
43
+
44
+ def validate_delta(delta: dict) -> None:
45
+ """Raises ValueError unless ``delta`` has the shape produced by ``suggest_config``."""
46
+ if not isinstance(delta, dict) or set(delta) != {"tables"} or not isinstance(delta["tables"], list):
47
+ raise ValueError("A config delta must be an object with a single 'tables' list.")
48
+ for table in delta["tables"]:
49
+ if not isinstance(table, dict) or not set(table) <= _DELTA_TABLE_KEYS:
50
+ raise ValueError(f"A delta table may only have the keys {sorted(_DELTA_TABLE_KEYS)}.")
51
+ if not isinstance(table.get("table_name"), str) or not table["table_name"]:
52
+ raise ValueError("Each delta table must have a 'table_name'.")
53
+ db = table.get("database_name")
54
+ if db is not None and not isinstance(db, str):
55
+ raise ValueError("'database_name' must be a string.")
56
+ columns = table.get("columns")
57
+ if not isinstance(columns, list) or not columns or not all(isinstance(c, str) and c for c in columns):
58
+ raise ValueError("Each delta table must have a non-empty 'columns' list of names.")
59
+
60
+
61
+ def merge_config(config: dict, delta: dict) -> dict:
62
+ """
63
+ Returns a copy of ``config`` with the tables and columns of ``delta`` added.
64
+
65
+ A delta table with the same name (and database) as a configured one adds its missing columns
66
+ to it; any other delta table is appended. Nothing else in the configuration changes, and the
67
+ arguments are not modified. Raises ValueError if ``delta`` is malformed.
68
+ """
69
+ validate_delta(delta)
70
+ merged = copy.deepcopy(config)
71
+ tables = merged.setdefault("tables", [])
72
+ for table in delta["tables"]:
73
+ existing = next(
74
+ (
75
+ t
76
+ for t in tables
77
+ if _identity(t["table_name"], t.get("database_name"))
78
+ == _identity(table["table_name"], table.get("database_name"))
79
+ ),
80
+ None,
81
+ )
82
+ if existing is None:
83
+ tables.append(copy.deepcopy(table))
84
+ continue
85
+ for column in table["columns"]:
86
+ if not _has_column(existing, column):
87
+ existing["columns"].append(column)
88
+ return merged
89
+
90
+
91
+ def suggest_config(sql: str, config: dict, dialect: Optional[str] = None) -> Optional[dict]:
92
+ """
93
+ Suggests the smallest allow-list addition that lets ``sql`` run as written.
94
+
95
+ Returns ``{"tables": [{"table_name", "database_name"?, "columns": [...]}, ...]}``, to be
96
+ applied with ``merge_config``; ``{"tables": []}`` if the query needs nothing added. Returns
97
+ None when widening the allow-list cannot make the query acceptable:
98
+
99
+ - the query is rejected for another reason (DML/DDL, several statements, a blocked function,
100
+ a column that cannot be attributed to one table, ...);
101
+ - it needs a table without naming any column (``SELECT *`` or ``COUNT(*)`` on a table the
102
+ configuration does not know), because a configured table must list its columns.
103
+
104
+ The suggestion is always checked: ``verify_sql`` on ``merge_config(config, delta)`` accepts
105
+ the query (allowed, or fixable). It never raises.
106
+ """
107
+ try:
108
+ return _suggest(sql, config, dialect)
109
+ except Exception as e: # noqa: BLE001 - never suggest anything on unexpected errors
110
+ logger.error(f"Unexpected error while suggesting a config ({type(e).__name__})")
111
+ return None
112
+
113
+
114
+ def _suggest(sql: str, config: dict, dialect: Optional[str]) -> Optional[dict]:
115
+ if not isinstance(config, dict) or not isinstance(config.get("tables"), list):
116
+ return None
117
+ working = copy.deepcopy(config)
118
+ tables = working["tables"]
119
+ delta_tables: dict = {} # identity -> delta table
120
+
121
+ def delta_for(entry: dict) -> dict:
122
+ key = _identity(entry["table_name"], entry.get("database_name"))
123
+ if key not in delta_tables:
124
+ table = {"table_name": entry["table_name"], "columns": []}
125
+ if entry.get("database_name"):
126
+ table["database_name"] = entry["database_name"]
127
+ delta_tables[key] = table
128
+ return delta_tables[key]
129
+
130
+ for _ in range(_MAX_ROUNDS):
131
+ violations: List[dict] = []
132
+ _verify_sql(sql, working, dialect, violations)
133
+ progress = False
134
+ for v in violations:
135
+ if v["table_name"] is None:
136
+ return None # a column that belongs to no single table: nothing to suggest
137
+ entry = _find_entry(tables, v["table_name"], v["database_name"])
138
+ if v["type"] == "table":
139
+ if entry is None:
140
+ entry = {
141
+ "table_name": v["table_name"],
142
+ **({"database_name": v["database_name"]} if v["database_name"] else {}),
143
+ "columns": [_PLACEHOLDER_COLUMN],
144
+ }
145
+ tables.append(entry)
146
+ delta_for(entry) # listed even if no real column is found (rejected below)
147
+ progress = True
148
+ else:
149
+ if entry is None:
150
+ return None
151
+ if not _has_column(entry, v["column"]):
152
+ entry["columns"].append(v["column"])
153
+ delta_for(entry)["columns"].append(v["column"])
154
+ progress = True
155
+ if not progress:
156
+ break
157
+ else:
158
+ return None # did not settle
159
+
160
+ # a table can only be configured with columns: drop what was found for none
161
+ result = [t for t in delta_tables.values() if t["columns"]]
162
+ if len(result) != len(delta_tables):
163
+ return None
164
+ delta = {"tables": result}
165
+ merged = merge_config(config, delta)
166
+ verdict = _verify_sql(sql, merged, dialect)
167
+ if not verdict["allowed"] and verdict["fixed"] is None:
168
+ return None
169
+ return delta