sql-safe-mcp 1.5.2__tar.gz → 2.0.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.
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/CHANGELOG.md +61 -0
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/PKG-INFO +6 -4
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/README.md +3 -1
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/pyproject.toml +4 -4
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/server.json +2 -2
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/sql-safe-mcp.example-multi.yaml +6 -2
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/sql-safe-mcp.example-shared.yaml +18 -0
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/__init__.py +1 -1
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/__main__.py +21 -1
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/config.py +215 -31
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/diagnostics.py +18 -2
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/mcp_server.py +8 -1
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/models.py +3 -1
- sql_safe_mcp-2.0.0/src/sql_safe_mcp/pii_audit.py +200 -0
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/security/executor.py +15 -2
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/security/lineage.py +17 -12
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/security/parser.py +42 -1
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/security/pipeline.py +1 -0
- sql_safe_mcp-2.0.0/src/sql_safe_mcp/security/policy.py +147 -0
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/security/reasons.py +3 -0
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/security/schema.py +51 -5
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/security/tokens.py +102 -54
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/security/validated_query.py +43 -3
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/service.py +67 -3
- sql_safe_mcp-1.5.2/src/sql_safe_mcp/security/policy.py +0 -104
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/.gitignore +0 -0
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/LICENSE +0 -0
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/sql-safe-mcp.example-simple.yaml +0 -0
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/db/__init__.py +0 -0
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/db/extras.py +0 -0
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/db/mysql.py +0 -0
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/db/reflection.py +0 -0
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/db/registry.py +0 -0
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/db/sqlserver.py +0 -0
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/errors.py +0 -0
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/security/__init__.py +0 -0
- {sql_safe_mcp-1.5.2 → sql_safe_mcp-2.0.0}/src/sql_safe_mcp/security/dialect.py +0 -0
|
@@ -7,6 +7,67 @@ categories `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, and `Security`.
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.0.0] - 2026-11-07
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `sql-safe-mcp --verify-pii-rules [--strict]` connects to each `all_pii_safe` alias, reports how
|
|
15
|
+
many tables and columns every PII rule matches, and warns about rules that match nothing
|
|
16
|
+
(exit 2 with `--strict`) and about unprotected columns whose names look personal.
|
|
17
|
+
- `logging.audit: true` writes one `sql_safe_mcp.audit` line per `execute_sql` call with the server,
|
|
18
|
+
database, tables and columns read, protected column count, row count, truncation, and elapsed
|
|
19
|
+
time (or the error `code`). It never contains SQL, bind values, rows, or tokens. Off by default;
|
|
20
|
+
the tool response is unchanged.
|
|
21
|
+
- `--verify-pii-rules` also checks `pii.exposed_columns` and `pii.deny` entries (unmatched ones
|
|
22
|
+
warn and fail `--strict`) and, in `deny-by-default`, advises only on exposed columns.
|
|
23
|
+
- `pii.mode: deny-by-default` protects every column not listed in `pii.exposed_columns`, and
|
|
24
|
+
`pii.deny` rejects any reference to a column with `QUERY_REJECTED` ("a denied column cannot be
|
|
25
|
+
referenced"). Existing configurations are unchanged. See `docs/configuration.md`.
|
|
26
|
+
- `runtime.token_ttl_seconds` (off by default) rejects tokens older than the limit with
|
|
27
|
+
`INVALID_PII_TOKEN`. Enabling it also rejects tokens issued before it was set. The token format
|
|
28
|
+
stays `pii:v1:`.
|
|
29
|
+
- `runtime.min_count_cell` (off by default) makes `execute_sql` return a `COUNT(*)` value from 1
|
|
30
|
+
to the threshold minus one as `null`, with the new column encoding `suppressed`. `0` is not
|
|
31
|
+
suppressed.
|
|
32
|
+
- `pii_key_file` reads an alias key from a file instead of `pii_key_env`; a relative path is
|
|
33
|
+
resolved against the configuration file's directory. On POSIX the file must be owned by the
|
|
34
|
+
server user or root, with no access for others and no group write.
|
|
35
|
+
|
|
36
|
+
### Security
|
|
37
|
+
|
|
38
|
+
- With `logging.level: DEBUG`, the MCP SDK no longer writes a malformed protocol message to
|
|
39
|
+
stderr; its logger is held at `INFO` or above, because that message could carry SQL, tokens,
|
|
40
|
+
or rows.
|
|
41
|
+
- `all_pii_safe` aliases now require verified TLS in `connection_url`: SQL Server needs
|
|
42
|
+
`Encrypt=yes` and rejects `TrustServerCertificate=yes` and `odbc_connect`; MySQL and MariaDB
|
|
43
|
+
need `ssl_ca` or `ssl_capath` and reject options that turn certificate checks off. A
|
|
44
|
+
configuration without them fails at startup with `CONFIG_ERROR`. `require_tls: false` on the
|
|
45
|
+
alias turns the check off and logs a warning at every start.
|
|
46
|
+
- After loading the configuration, the server removes `pii_key_env` variables from its environment,
|
|
47
|
+
so child processes such as ODBC drivers no longer inherit PII keys.
|
|
48
|
+
- `pii.rules` entries accept an optional `domain`. A token returned for a column of one domain is
|
|
49
|
+
rejected with `INVALID_PII_TOKEN` when compared with a column of another domain, so protected
|
|
50
|
+
values cannot be replayed across unrelated columns. Without `domain` the domain is the server
|
|
51
|
+
alias. Rules of different domains matching one column reject queries touching it with
|
|
52
|
+
`QUERY_REJECTED`.
|
|
53
|
+
- PII tokens now pad their payload to a multiple of 32 bytes, so token length reveals only a
|
|
54
|
+
32-byte bucket of the plaintext length.
|
|
55
|
+
- Tokens issued before padding and domains are rejected with `INVALID_PII_TOKEN`; agents must
|
|
56
|
+
request new tokens.
|
|
57
|
+
- `execute_sql` now allows each SQL construct only in the positions the supported subset uses.
|
|
58
|
+
A bare column or literal used as a condition (`WHERE a`, `ON 1`, `WHERE a AND b = 1`) and any
|
|
59
|
+
other node in an unlisted position is rejected with `QUERY_REJECTED` before the database runs
|
|
60
|
+
the query.
|
|
61
|
+
- `execute_sql` now checks a PII token against the type of the protected column it is compared
|
|
62
|
+
with. A string token compared with an integer or decimal column, or any token compared with a
|
|
63
|
+
column of an unrecognised type, is rejected with `QUERY_REJECTED` before the database runs the
|
|
64
|
+
query. Before, SQL Server could echo the decrypted value in a conversion error (for example
|
|
65
|
+
error 245).
|
|
66
|
+
- Driver error messages in logs no longer carry values. Quoted literals (`'…'`, `N'…'`, `"…"`)
|
|
67
|
+
become `'***'` and digit runs of six or more become `***`, since SQL Server and MySQL echo the
|
|
68
|
+
offending value in conversion and duplicate-key errors. A failed `execute_sql` logs only the
|
|
69
|
+
driver error class and its SQLSTATE or numeric code, with no message text.
|
|
70
|
+
|
|
10
71
|
## [1.5.2] - 2026-10-05
|
|
11
72
|
|
|
12
73
|
### Security
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sql-safe-mcp
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: Minimal, read-only, PII-safe MCP server for SQL databases.
|
|
5
5
|
License: MIT License
|
|
6
6
|
|
|
@@ -26,10 +26,10 @@ License: MIT License
|
|
|
26
26
|
License-File: LICENSE
|
|
27
27
|
Requires-Python: <3.15,>=3.12
|
|
28
28
|
Requires-Dist: anyio<5,>=4.8
|
|
29
|
-
Requires-Dist: cryptography<
|
|
29
|
+
Requires-Dist: cryptography<51,>=48.0.1
|
|
30
30
|
Requires-Dist: mcp<3,>=2
|
|
31
31
|
Requires-Dist: pydantic<3,>=2.10
|
|
32
|
-
Requires-Dist: pymysql
|
|
32
|
+
Requires-Dist: pymysql<2,>=1.2.3
|
|
33
33
|
Requires-Dist: pyodbc<6,>=5.2
|
|
34
34
|
Requires-Dist: pyyaml<7,>=6
|
|
35
35
|
Requires-Dist: sqlalchemy<3,>=2.0
|
|
@@ -133,7 +133,7 @@ or
|
|
|
133
133
|
pip install sql-safe-mcp
|
|
134
134
|
```
|
|
135
135
|
|
|
136
|
-
Pin a version when you want a fixed surface: `uvx sql-safe-mcp==
|
|
136
|
+
Pin a version when you want a fixed surface: `uvx sql-safe-mcp==2.0.0`.
|
|
137
137
|
|
|
138
138
|
Requires Python 3.12–3.14, [uv](https://docs.astral.sh/uv/) (or `pip`), and
|
|
139
139
|
[Microsoft ODBC Driver 18 for SQL Server](https://learn.microsoft.com/sql/connect/odbc/download-odbc-driver-for-sql-server)
|
|
@@ -278,6 +278,8 @@ Protection covers the columns you list, so list every column that holds personal
|
|
|
278
278
|
limits, and logging.
|
|
279
279
|
- [Tool reference](docs/tools.md) - arguments, responses, errors, and the accepted SQL subset.
|
|
280
280
|
- [Security model](docs/security-model.md) - trust boundaries, guarantees, limitations, and verification.
|
|
281
|
+
- [Health data of minors](docs/health-data.md) - requirements, mechanisms, and an operator
|
|
282
|
+
checklist for medical deployments.
|
|
281
283
|
- [Checks](docs/checks.md) - local, integration, and security verification commands.
|
|
282
284
|
|
|
283
285
|
## Security
|
|
@@ -95,7 +95,7 @@ or
|
|
|
95
95
|
pip install sql-safe-mcp
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
-
Pin a version when you want a fixed surface: `uvx sql-safe-mcp==
|
|
98
|
+
Pin a version when you want a fixed surface: `uvx sql-safe-mcp==2.0.0`.
|
|
99
99
|
|
|
100
100
|
Requires Python 3.12–3.14, [uv](https://docs.astral.sh/uv/) (or `pip`), and
|
|
101
101
|
[Microsoft ODBC Driver 18 for SQL Server](https://learn.microsoft.com/sql/connect/odbc/download-odbc-driver-for-sql-server)
|
|
@@ -240,6 +240,8 @@ Protection covers the columns you list, so list every column that holds personal
|
|
|
240
240
|
limits, and logging.
|
|
241
241
|
- [Tool reference](docs/tools.md) - arguments, responses, errors, and the accepted SQL subset.
|
|
242
242
|
- [Security model](docs/security-model.md) - trust boundaries, guarantees, limitations, and verification.
|
|
243
|
+
- [Health data of minors](docs/health-data.md) - requirements, mechanisms, and an operator
|
|
244
|
+
checklist for medical deployments.
|
|
243
245
|
- [Checks](docs/checks.md) - local, integration, and security verification commands.
|
|
244
246
|
|
|
245
247
|
## Security
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sql-safe-mcp"
|
|
3
|
-
version = "
|
|
3
|
+
version = "2.0.0"
|
|
4
4
|
description = "Minimal, read-only, PII-safe MCP server for SQL databases."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12,<3.15"
|
|
7
7
|
license = { file = "LICENSE" }
|
|
8
8
|
dependencies = [
|
|
9
9
|
"anyio>=4.8,<5",
|
|
10
|
-
"cryptography>=
|
|
10
|
+
"cryptography>=48.0.1,<51",
|
|
11
11
|
"mcp>=2,<3",
|
|
12
12
|
"pydantic>=2.10,<3",
|
|
13
|
-
"pymysql>=1.2.3",
|
|
13
|
+
"pymysql>=1.2.3,<2",
|
|
14
14
|
"pyodbc>=5.2,<6",
|
|
15
15
|
"pyyaml>=6,<7",
|
|
16
16
|
"sqlalchemy>=2.0,<3",
|
|
@@ -63,7 +63,7 @@ markers = [
|
|
|
63
63
|
[tool.mutmut]
|
|
64
64
|
source_paths = ["src/sql_safe_mcp"]
|
|
65
65
|
also_copy = ["server.json", "README.md", "tools", "scripts", "compose.sqlserver.yml", "compose.mysql.yml"]
|
|
66
|
-
only_mutate = ["*security/*.py", "*config.py"]
|
|
66
|
+
only_mutate = ["*security/*.py", "*config.py", "*diagnostics.py", "*service.py"]
|
|
67
67
|
mutate_only_covered_lines = true
|
|
68
68
|
pytest_add_cli_args = ["-q", "-m", "not integration and not deep", "-p", "no:cacheprovider"]
|
|
69
69
|
pytest_add_cli_args_test_selection = ["tests/unit", "tests/security", "tests/contract"]
|
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"url": "https://github.com/proprock/sql-safe-mcp",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "
|
|
10
|
+
"version": "2.0.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "pypi",
|
|
14
14
|
"identifier": "sql-safe-mcp",
|
|
15
|
-
"version": "
|
|
15
|
+
"version": "2.0.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
}
|
|
@@ -19,11 +19,14 @@ servers:
|
|
|
19
19
|
billing:
|
|
20
20
|
# `sqlserver` uses an mssql+pyodbc URL; the URL dialect must match this engine.
|
|
21
21
|
engine: sqlserver
|
|
22
|
-
# all_pii_safe requires
|
|
22
|
+
# all_pii_safe requires a key (`pii_key_env` or `pii_key_file`) and at least one effective PII rule.
|
|
23
23
|
access_level: all_pii_safe
|
|
24
|
+
# all_pii_safe requires verified TLS: the URL must set Encrypt=yes and must not set
|
|
25
|
+
# TrustServerCertificate=yes. `require_tls: false` turns the check off for a dev server.
|
|
24
26
|
connection_url: "${BILLING_SQL_URL}"
|
|
25
27
|
# Environment-variable name for one base64-encoded 32-byte key. Names must match
|
|
26
|
-
# ^[A-Z_][A-Z0-9_]*$; each all_pii_safe alias needs a different key.
|
|
28
|
+
# ^[A-Z_][A-Z0-9_]*$; each all_pii_safe alias needs a different key. Instead of
|
|
29
|
+
# pii_key_env you may set `pii_key_file: secrets/billing.key` (relative to this file).
|
|
27
30
|
pii_key_env: BILLING_PII_KEY
|
|
28
31
|
pii:
|
|
29
32
|
# Only local rules are used in this file; `include` is intentionally absent.
|
|
@@ -42,6 +45,7 @@ servers:
|
|
|
42
45
|
# mysql and mariadb both use mysql+pymysql URLs. Select the engine matching the server.
|
|
43
46
|
engine: mysql
|
|
44
47
|
access_level: all_pii_safe
|
|
48
|
+
# Verified TLS for MySQL/MariaDB: the URL must set ssl_ca (or ssl_capath).
|
|
45
49
|
connection_url: "${SHOP_MYSQL_URL}"
|
|
46
50
|
pii_key_env: SHOP_PII_KEY
|
|
47
51
|
pii:
|
|
@@ -38,6 +38,9 @@ pii_rules:
|
|
|
38
38
|
schema: dbo # Optional for SQL Server; omit it to match every schema.
|
|
39
39
|
table: "Users[0-9]?"
|
|
40
40
|
columns: [Email, FirstName, LastName]
|
|
41
|
+
# Optional token domain (alias character set). Tokens of one domain are accepted only on
|
|
42
|
+
# columns of that domain; without it the domain is the server alias.
|
|
43
|
+
domain: users_identity
|
|
41
44
|
|
|
42
45
|
servers:
|
|
43
46
|
legacy-prod:
|
|
@@ -65,6 +68,21 @@ servers:
|
|
|
65
68
|
table: Cards
|
|
66
69
|
columns: [HolderName]
|
|
67
70
|
|
|
71
|
+
peds-deny-by-default:
|
|
72
|
+
engine: sqlserver
|
|
73
|
+
access_level: all_pii_safe
|
|
74
|
+
connection_url: "${PEDS_SQL_URL}"
|
|
75
|
+
pii_key_env: PEDS_PII_KEY
|
|
76
|
+
pii:
|
|
77
|
+
# `deny-by-default` protects every column that exposed_columns does not list.
|
|
78
|
+
mode: deny-by-default
|
|
79
|
+
exposed_columns: # `database` is optional here and defaults to "*".
|
|
80
|
+
- table: Visits
|
|
81
|
+
columns: [VisitId, DepartmentCode, VisitYear]
|
|
82
|
+
deny: # Unreachable even as a token; columns: ["*"] denies a whole table.
|
|
83
|
+
- table: "*"
|
|
84
|
+
columns: [Notes, Anamnesis, Attachment]
|
|
85
|
+
|
|
68
86
|
reporting-metadata:
|
|
69
87
|
engine: sqlserver
|
|
70
88
|
access_level: metadata
|
|
@@ -10,6 +10,7 @@ from pathlib import Path
|
|
|
10
10
|
from sql_safe_mcp.config import load_config
|
|
11
11
|
from sql_safe_mcp.errors import DomainError
|
|
12
12
|
from sql_safe_mcp.mcp_server import create_server
|
|
13
|
+
from sql_safe_mcp.pii_audit import collect_catalog, verify_pii_rules
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
def _positive_int(value: str) -> int:
|
|
@@ -41,11 +42,24 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
41
42
|
metavar="N",
|
|
42
43
|
help="Print N base64-encoded 32-byte PII keys (default: 1) and exit.",
|
|
43
44
|
)
|
|
45
|
+
actions.add_argument(
|
|
46
|
+
"--verify-pii-rules",
|
|
47
|
+
action="store_true",
|
|
48
|
+
help="Connect to each all_pii_safe alias and report PII rules that match nothing.",
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--strict",
|
|
52
|
+
action="store_true",
|
|
53
|
+
help="With --verify-pii-rules, exit 2 when any rule matches nothing.",
|
|
54
|
+
)
|
|
44
55
|
return parser
|
|
45
56
|
|
|
46
57
|
|
|
47
58
|
def main() -> None:
|
|
48
|
-
|
|
59
|
+
parser = _parser()
|
|
60
|
+
args = parser.parse_args()
|
|
61
|
+
if args.strict and not args.verify_pii_rules:
|
|
62
|
+
parser.error("--strict requires --verify-pii-rules")
|
|
49
63
|
if args.gen_pii_key is not None:
|
|
50
64
|
for _ in range(args.gen_pii_key):
|
|
51
65
|
print(base64.b64encode(secrets.token_bytes(32)).decode("ascii"))
|
|
@@ -58,6 +72,12 @@ def main() -> None:
|
|
|
58
72
|
if args.check_config:
|
|
59
73
|
print(f"Configuration valid: {len(config.servers)} server alias(es).")
|
|
60
74
|
return
|
|
75
|
+
if args.verify_pii_rules:
|
|
76
|
+
report = verify_pii_rules(config, collect_catalog)
|
|
77
|
+
print("\n".join(report.lines), file=sys.stderr)
|
|
78
|
+
if report.errors or (args.strict and report.unmatched_rules):
|
|
79
|
+
raise SystemExit(2)
|
|
80
|
+
return
|
|
61
81
|
create_server(config).run("stdio")
|
|
62
82
|
|
|
63
83
|
|
|
@@ -4,6 +4,8 @@ import base64
|
|
|
4
4
|
import binascii
|
|
5
5
|
import os
|
|
6
6
|
import re
|
|
7
|
+
import stat
|
|
8
|
+
import sys
|
|
7
9
|
from collections.abc import Mapping
|
|
8
10
|
from copy import deepcopy
|
|
9
11
|
from pathlib import Path
|
|
@@ -32,6 +34,10 @@ class RuntimeConfig(BaseModel):
|
|
|
32
34
|
pool_timeout_seconds: int = Field(default=10, ge=1, le=300)
|
|
33
35
|
statement_timeout_seconds: int = Field(default=30, ge=1, le=3600)
|
|
34
36
|
max_definition_chars: int = Field(default=262_144, ge=1024, le=10_000_000)
|
|
37
|
+
# None keeps tokens valid until the key changes; otherwise a token expires this many whole
|
|
38
|
+
# seconds after it was issued.
|
|
39
|
+
token_ttl_seconds: int | None = Field(default=None, ge=1, le=315_360_000, strict=True)
|
|
40
|
+
min_count_cell: int = Field(default=0, ge=0, le=1000, strict=True)
|
|
35
41
|
default_max_rows: int = Field(default=200, ge=1, le=100_000)
|
|
36
42
|
hard_max_rows: int = Field(default=1000, ge=1, le=100_000)
|
|
37
43
|
max_sql_chars: int = Field(default=65_536, ge=256, le=1_000_000)
|
|
@@ -50,6 +56,7 @@ class LoggingConfig(BaseModel):
|
|
|
50
56
|
model_config = ConfigDict(extra="forbid")
|
|
51
57
|
|
|
52
58
|
level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
|
59
|
+
audit: bool = False
|
|
53
60
|
|
|
54
61
|
|
|
55
62
|
class PiiRule(BaseModel):
|
|
@@ -59,6 +66,7 @@ class PiiRule(BaseModel):
|
|
|
59
66
|
schema_: str | None = Field(default=None, alias="schema")
|
|
60
67
|
table: str = Field(min_length=1)
|
|
61
68
|
columns: list[str] = Field(min_length=1)
|
|
69
|
+
domain: str | None = Field(default=None, pattern=rf"^{_SERVER_ALIAS_PATTERN}$")
|
|
62
70
|
|
|
63
71
|
@model_validator(mode="after")
|
|
64
72
|
def validate_wildcards(self) -> PiiRule:
|
|
@@ -72,10 +80,41 @@ class PiiRule(BaseModel):
|
|
|
72
80
|
return self
|
|
73
81
|
|
|
74
82
|
|
|
83
|
+
class PiiScopeRule(PiiRule):
|
|
84
|
+
"""An `exposed_columns` or `deny` entry; the database defaults to any."""
|
|
85
|
+
|
|
86
|
+
database: str = Field(default="*", min_length=1)
|
|
87
|
+
|
|
88
|
+
@model_validator(mode="after")
|
|
89
|
+
def validate_no_domain(self) -> PiiScopeRule:
|
|
90
|
+
if self.domain is not None:
|
|
91
|
+
raise ValueError("domain is allowed only on pii rules")
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _check_column_wildcards(
|
|
96
|
+
rules: list[PiiRule], exposed: list[PiiScopeRule], deny: list[PiiScopeRule]
|
|
97
|
+
) -> None:
|
|
98
|
+
if any("*" in column for rule in [*rules, *exposed] for column in rule.columns):
|
|
99
|
+
raise ValueError("pii rules and exposed_columns cannot list '*' columns; only deny can")
|
|
100
|
+
if any("*" in column and rule.columns != ["*"] for rule in deny for column in rule.columns):
|
|
101
|
+
raise ValueError("a deny column wildcard must be exactly ['*']")
|
|
102
|
+
|
|
103
|
+
|
|
75
104
|
class PiiConfig(BaseModel):
|
|
76
105
|
model_config = ConfigDict(extra="forbid")
|
|
77
106
|
|
|
78
|
-
|
|
107
|
+
mode: Literal["protect", "deny-by-default"] = "protect"
|
|
108
|
+
rules: list[PiiRule] = Field(default_factory=list)
|
|
109
|
+
exposed_columns: list[PiiScopeRule] = Field(default_factory=list)
|
|
110
|
+
deny: list[PiiScopeRule] = Field(default_factory=list)
|
|
111
|
+
|
|
112
|
+
@model_validator(mode="after")
|
|
113
|
+
def validate_shape(self) -> PiiConfig:
|
|
114
|
+
_check_column_wildcards(self.rules, self.exposed_columns, self.deny)
|
|
115
|
+
if self.mode == "protect" and not (self.rules or self.deny):
|
|
116
|
+
raise ValueError("pii requires at least one rule or deny entry")
|
|
117
|
+
return self
|
|
79
118
|
|
|
80
119
|
|
|
81
120
|
class PiiConfigInput(BaseModel):
|
|
@@ -83,15 +122,26 @@ class PiiConfigInput(BaseModel):
|
|
|
83
122
|
|
|
84
123
|
model_config = ConfigDict(extra="forbid")
|
|
85
124
|
|
|
125
|
+
mode: Literal["protect", "deny-by-default"] = "protect"
|
|
86
126
|
include: list[str] = Field(default_factory=list)
|
|
87
127
|
rules: list[PiiRule] = Field(default_factory=list)
|
|
128
|
+
exposed_columns: list[PiiScopeRule] = Field(default_factory=list)
|
|
129
|
+
deny: list[PiiScopeRule] = Field(default_factory=list)
|
|
88
130
|
|
|
89
131
|
|
|
90
132
|
class PiiRuleSet(BaseModel):
|
|
91
133
|
model_config = ConfigDict(extra="forbid")
|
|
92
134
|
|
|
93
135
|
name: str | None = Field(default=None, pattern=rf"^{_SERVER_ALIAS_PATTERN}$")
|
|
94
|
-
rules: list[PiiRule] = Field(
|
|
136
|
+
rules: list[PiiRule] = Field(default_factory=list)
|
|
137
|
+
exposed_columns: list[PiiScopeRule] = Field(default_factory=list)
|
|
138
|
+
deny: list[PiiScopeRule] = Field(default_factory=list)
|
|
139
|
+
|
|
140
|
+
@model_validator(mode="after")
|
|
141
|
+
def validate_not_empty(self) -> PiiRuleSet:
|
|
142
|
+
if not (self.rules or self.exposed_columns or self.deny):
|
|
143
|
+
raise ValueError("a pii rule set needs rules, exposed_columns or deny")
|
|
144
|
+
return self
|
|
95
145
|
|
|
96
146
|
|
|
97
147
|
_PERCENT_ESCAPE = re.compile(r"%[0-9A-Fa-f]{2}")
|
|
@@ -110,7 +160,9 @@ class ServerConfigBase(BaseModel):
|
|
|
110
160
|
access_level: Literal["metadata", "meta_and_code", "all_pii_safe"] = "metadata"
|
|
111
161
|
connection_url: SecretStr
|
|
112
162
|
pii_key_env: str | None = Field(default=None, pattern=r"^[A-Z_][A-Z0-9_]*$")
|
|
163
|
+
pii_key_file: str | None = Field(default=None, min_length=1)
|
|
113
164
|
pii_key: SecretStr | None = Field(default=None, exclude=True)
|
|
165
|
+
require_tls: bool = Field(default=True, strict=True)
|
|
114
166
|
|
|
115
167
|
@model_validator(mode="after")
|
|
116
168
|
def validate_connection_url(self) -> ServerConfigBase:
|
|
@@ -136,20 +188,84 @@ class ServerConfigBase(BaseModel):
|
|
|
136
188
|
return base64.b64decode(self.pii_key.get_secret_value(), validate=True)
|
|
137
189
|
|
|
138
190
|
|
|
191
|
+
_TLS_ERROR = "all_pii_safe servers require verified TLS: "
|
|
192
|
+
# Allowlists: only these values prove an encrypted connection with a checked certificate.
|
|
193
|
+
_MSSQL_ENCRYPT_ON = frozenset({"yes", "true", "mandatory", "strict"})
|
|
194
|
+
_MSSQL_TRUST_OFF = frozenset({"no", "false"})
|
|
195
|
+
# PyMySQL options that replace or weaken the certificate checks SQLAlchemy builds from ssl_ca.
|
|
196
|
+
_MYSQL_TLS_OVERRIDES = ("ssl_verify_cert", "ssl_verify_identity", "ssl_disabled")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _tls_options(url: str) -> dict[str, str]:
|
|
200
|
+
"""Casefolded query options; a repeated option (in any case) is refused."""
|
|
201
|
+
options: dict[str, str] = {}
|
|
202
|
+
for name, value in make_url(url).query.items():
|
|
203
|
+
key = name.casefold()
|
|
204
|
+
if key in options or not isinstance(value, str):
|
|
205
|
+
raise ValueError(f"{_TLS_ERROR}connection_url sets {key!r} more than once")
|
|
206
|
+
options[key] = value
|
|
207
|
+
return options
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _weakened(option: str) -> ValueError:
|
|
211
|
+
return ValueError(
|
|
212
|
+
f"{_TLS_ERROR}connection_url option {option!r} disables certificate checks; "
|
|
213
|
+
"remove it, or set require_tls: false"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _check_verified_tls(engine: str, url: str) -> None:
|
|
218
|
+
options = _tls_options(url)
|
|
219
|
+
if engine == "sqlserver":
|
|
220
|
+
if "odbc_connect" in options:
|
|
221
|
+
raise ValueError(
|
|
222
|
+
f"{_TLS_ERROR}odbc_connect cannot be checked; use URL query options, "
|
|
223
|
+
"or set require_tls: false"
|
|
224
|
+
)
|
|
225
|
+
if options.get("encrypt", "").casefold() not in _MSSQL_ENCRYPT_ON:
|
|
226
|
+
raise ValueError(
|
|
227
|
+
f"{_TLS_ERROR}set Encrypt=yes in connection_url, or require_tls: false"
|
|
228
|
+
)
|
|
229
|
+
trust = options.get("trustservercertificate")
|
|
230
|
+
if trust is not None and trust.casefold() not in _MSSQL_TRUST_OFF:
|
|
231
|
+
raise _weakened("trustservercertificate")
|
|
232
|
+
return
|
|
233
|
+
if not options.get("ssl_ca") and not options.get("ssl_capath"):
|
|
234
|
+
raise ValueError(
|
|
235
|
+
f"{_TLS_ERROR}set ssl_ca or ssl_capath in connection_url, or require_tls: false"
|
|
236
|
+
)
|
|
237
|
+
for option in _MYSQL_TLS_OVERRIDES:
|
|
238
|
+
if option in options:
|
|
239
|
+
raise _weakened(option)
|
|
240
|
+
hostname = options.get("ssl_check_hostname")
|
|
241
|
+
if hostname is not None and hostname.casefold() != "true":
|
|
242
|
+
raise _weakened("ssl_check_hostname")
|
|
243
|
+
|
|
244
|
+
|
|
139
245
|
class ServerConfig(ServerConfigBase):
|
|
140
246
|
pii: PiiConfig | None = None
|
|
141
247
|
|
|
142
248
|
@model_validator(mode="after")
|
|
143
249
|
def validate_security_shape(self) -> ServerConfig:
|
|
144
250
|
if self.access_level == "all_pii_safe":
|
|
145
|
-
if
|
|
146
|
-
raise ValueError("
|
|
147
|
-
if self.
|
|
251
|
+
if self.pii_key_env and self.pii_key_file:
|
|
252
|
+
raise ValueError("set either pii_key_env or pii_key_file, not both")
|
|
253
|
+
if not (self.pii_key_env or self.pii_key_file) or self.pii is None:
|
|
254
|
+
raise ValueError(
|
|
255
|
+
"all_pii_safe servers require pii_key_env or pii_key_file, and pii rules"
|
|
256
|
+
)
|
|
257
|
+
scoped = [*self.pii.rules, *self.pii.exposed_columns, *self.pii.deny]
|
|
258
|
+
if self.engine != "sqlserver" and any(rule.schema_ for rule in scoped):
|
|
148
259
|
raise ValueError(f"pii rules for engine {self.engine!r} cannot set schema")
|
|
149
|
-
|
|
260
|
+
if self.require_tls:
|
|
261
|
+
_check_verified_tls(self.engine, self.connection_url.get_secret_value())
|
|
262
|
+
elif self.pii_key_env is not None or self.pii_key_file is not None or self.pii is not None:
|
|
150
263
|
raise ValueError(
|
|
151
|
-
f"{self.access_level} servers cannot configure
|
|
264
|
+
f"{self.access_level} servers cannot configure "
|
|
265
|
+
"pii_key_env, pii_key_file or pii rules"
|
|
152
266
|
)
|
|
267
|
+
elif "require_tls" in self.model_fields_set:
|
|
268
|
+
raise ValueError(f"{self.access_level} servers cannot configure require_tls")
|
|
153
269
|
return self
|
|
154
270
|
|
|
155
271
|
|
|
@@ -227,17 +343,17 @@ def _validation_summary(error: ValidationError) -> str:
|
|
|
227
343
|
def _resolve_pii_rules(raw: dict[object, object], config: AppConfigInput) -> dict[object, object]:
|
|
228
344
|
"""Flatten shared PII rules before runtime configuration is constructed."""
|
|
229
345
|
|
|
230
|
-
|
|
231
|
-
|
|
346
|
+
default_set: PiiRuleSet | None = None
|
|
347
|
+
named_sets: dict[str, PiiRuleSet] = {}
|
|
232
348
|
for rule_set in config.pii_rules or []:
|
|
233
349
|
if rule_set.name is None:
|
|
234
|
-
if
|
|
350
|
+
if default_set is not None:
|
|
235
351
|
raise ValueError("at most one unnamed pii rule set is allowed")
|
|
236
|
-
|
|
352
|
+
default_set = rule_set
|
|
237
353
|
continue
|
|
238
|
-
if rule_set.name in
|
|
354
|
+
if rule_set.name in named_sets:
|
|
239
355
|
raise ValueError(f"duplicate pii rule set name {rule_set.name!r}")
|
|
240
|
-
|
|
356
|
+
named_sets[rule_set.name] = rule_set
|
|
241
357
|
|
|
242
358
|
resolved = deepcopy(raw)
|
|
243
359
|
resolved.pop("pii_rules", None)
|
|
@@ -249,31 +365,94 @@ def _resolve_pii_rules(raw: dict[object, object], config: AppConfigInput) -> dic
|
|
|
249
365
|
if server.access_level != "all_pii_safe":
|
|
250
366
|
if server.pii is not None:
|
|
251
367
|
raise ValueError(
|
|
252
|
-
f"{server.access_level} servers cannot configure
|
|
368
|
+
f"{server.access_level} servers cannot configure "
|
|
369
|
+
"pii_key_env, pii_key_file or pii rules"
|
|
253
370
|
)
|
|
254
371
|
continue
|
|
255
372
|
|
|
256
|
-
|
|
257
|
-
|
|
373
|
+
sets: list[PiiRuleSet | PiiConfigInput] = [default_set] if default_set is not None else []
|
|
374
|
+
mode = "protect"
|
|
375
|
+
local = server.pii
|
|
376
|
+
if local is not None:
|
|
377
|
+
mode = local.mode
|
|
258
378
|
included: set[str] = set()
|
|
259
|
-
for name in
|
|
260
|
-
if name not in
|
|
379
|
+
for name in local.include:
|
|
380
|
+
if name not in named_sets:
|
|
261
381
|
raise ValueError(f"server {alias!r} includes unknown pii rule set {name!r}")
|
|
262
382
|
if name in included:
|
|
263
383
|
raise ValueError(
|
|
264
384
|
f"server {alias!r} includes pii rule set {name!r} more than once"
|
|
265
385
|
)
|
|
266
386
|
included.add(name)
|
|
267
|
-
|
|
268
|
-
|
|
387
|
+
sets.append(named_sets[name])
|
|
388
|
+
sets.append(local)
|
|
389
|
+
merged = {
|
|
390
|
+
field: [
|
|
391
|
+
rule.model_dump(by_alias=True) for item in sets for rule in getattr(item, field)
|
|
392
|
+
]
|
|
393
|
+
for field in ("rules", "exposed_columns", "deny")
|
|
394
|
+
}
|
|
269
395
|
server_data["pii"] = (
|
|
270
|
-
{"
|
|
271
|
-
if effective_rules
|
|
272
|
-
else None
|
|
396
|
+
{"mode": mode, **merged} if any(merged.values()) or mode != "protect" else None
|
|
273
397
|
)
|
|
274
398
|
return resolved
|
|
275
399
|
|
|
276
400
|
|
|
401
|
+
_KEY_FILE_LIMIT = 1024
|
|
402
|
+
# Group write/execute and every "other" bit: a key file may be shared with the group read-only.
|
|
403
|
+
_KEY_FILE_FORBIDDEN_BITS = 0o037
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _decode_key(alias: object, text: str) -> str:
|
|
407
|
+
try:
|
|
408
|
+
decoded = base64.b64decode(text, validate=True)
|
|
409
|
+
except (binascii.Error, ValueError) as exc:
|
|
410
|
+
raise ValueError(f"PII key for server {alias!r} is not valid base64") from exc
|
|
411
|
+
if len(decoded) != 32:
|
|
412
|
+
raise ValueError(f"PII key for server {alias!r} must decode to 32 bytes")
|
|
413
|
+
return text
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _check_key_file_owner(alias: object, info: os.stat_result) -> None:
|
|
417
|
+
if sys.platform != "win32":
|
|
418
|
+
if info.st_uid not in (0, os.geteuid()):
|
|
419
|
+
raise ValueError(
|
|
420
|
+
f"PII key file for server {alias!r} must be owned by the server user or root"
|
|
421
|
+
)
|
|
422
|
+
if stat.S_IMODE(info.st_mode) & _KEY_FILE_FORBIDDEN_BITS:
|
|
423
|
+
raise ValueError(
|
|
424
|
+
f"PII key file for server {alias!r} must not be writable by group or accessible "
|
|
425
|
+
"by others (use mode 0600, 0640 or 0440)"
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _read_key_file(alias: object, path: Path) -> str:
|
|
430
|
+
"""Read a base64 key file; permissions are checked on the opened descriptor."""
|
|
431
|
+
try:
|
|
432
|
+
if sys.platform == "win32":
|
|
433
|
+
descriptor = os.open(path, os.O_RDONLY)
|
|
434
|
+
else:
|
|
435
|
+
# O_NONBLOCK keeps a FIFO from blocking startup; it is rejected as non-regular below.
|
|
436
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
|
|
437
|
+
try:
|
|
438
|
+
info = os.fstat(descriptor)
|
|
439
|
+
if not stat.S_ISREG(info.st_mode):
|
|
440
|
+
raise ValueError(f"PII key file for server {alias!r} is not a regular file")
|
|
441
|
+
_check_key_file_owner(alias, info)
|
|
442
|
+
data = os.read(descriptor, _KEY_FILE_LIMIT + 1)
|
|
443
|
+
finally:
|
|
444
|
+
os.close(descriptor)
|
|
445
|
+
except OSError as exc:
|
|
446
|
+
raise ValueError(f"PII key file for server {alias!r} cannot be read") from exc
|
|
447
|
+
if len(data) > _KEY_FILE_LIMIT:
|
|
448
|
+
raise ValueError(f"PII key file for server {alias!r} is larger than 1024 bytes")
|
|
449
|
+
try:
|
|
450
|
+
text = data.decode("ascii").strip()
|
|
451
|
+
except UnicodeDecodeError as exc:
|
|
452
|
+
raise ValueError(f"PII key for server {alias!r} is not valid base64") from exc
|
|
453
|
+
return _decode_key(alias, text)
|
|
454
|
+
|
|
455
|
+
|
|
277
456
|
def load_config(path: str | Path, environ: Mapping[str, str] | None = None) -> AppConfig:
|
|
278
457
|
env = os.environ if environ is None else environ
|
|
279
458
|
try:
|
|
@@ -291,18 +470,23 @@ def load_config(path: str | Path, environ: Mapping[str, str] | None = None) -> A
|
|
|
291
470
|
raise ValueError(f"server {alias!r} requires connection_url")
|
|
292
471
|
value["connection_url"] = _expand_connection_url(url, env)
|
|
293
472
|
key_env = value.get("pii_key_env")
|
|
473
|
+
key_file = value.get("pii_key_file")
|
|
294
474
|
if key_env is not None:
|
|
295
475
|
if not isinstance(key_env, str) or key_env not in env:
|
|
296
476
|
raise ValueError(f"missing PII key environment variable {key_env!r}")
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
if len(decoded) != 32:
|
|
302
|
-
raise ValueError(f"PII key for server {alias!r} must decode to 32 bytes")
|
|
303
|
-
value["pii_key"] = env[key_env]
|
|
477
|
+
value["pii_key"] = _decode_key(alias, env[key_env])
|
|
478
|
+
elif isinstance(key_file, str) and key_file:
|
|
479
|
+
# Relative to the YAML file, never to the working directory of the MCP host.
|
|
480
|
+
value["pii_key"] = _read_key_file(alias, Path(path).parent / key_file)
|
|
304
481
|
input_config = AppConfigInput.model_validate(raw)
|
|
305
|
-
|
|
482
|
+
config = AppConfig.model_validate(_resolve_pii_rules(raw, input_config))
|
|
483
|
+
if environ is None:
|
|
484
|
+
# Child processes (ODBC drivers, shells) must not inherit the keys.
|
|
485
|
+
for server in config.servers.values():
|
|
486
|
+
if server.pii_key_env is not None:
|
|
487
|
+
# Present: loading read it, and aliases cannot share a key variable.
|
|
488
|
+
del os.environ[server.pii_key_env]
|
|
489
|
+
return config
|
|
306
490
|
except ValidationError as exc:
|
|
307
491
|
raise DomainError(
|
|
308
492
|
ErrorCode.CONFIG_ERROR,
|