CVE-2026-44969
LOW2.5dbt MCP Server Logs Tool Arguments Including SQL Queries and Credentials in Plaintext Without Redaction When File Logging Is Enabled
Description
*Discovered through manual source code review. Verified by PoC execution against a local dbt-mcp v1.15.1 installation.* ### Summary `DbtMCP.call_tool()` in `src/dbt_mcp/mcp/server.py` logs the complete raw `arguments` dictionary at `INFO` level on every tool invocation (line 67) and again at `ERROR` level if the call raises an exception (lines 77–79). No field is redacted before logging. When the documented `DBT_MCP_SERVER_FILE_LOGGING=true` feature is enabled, these log records are written to `dbt-mcp.log` in the project root directory as plaintext. Sensitive data — raw SQL queries, `--vars` payloads carrying credentials, node selectors — persists on disk indefinitely with no automatic rotation or deletion. ### Details **Vulnerable log statements (`server.py`):** ```python # Line 67 — emitted before every tool execution logger.info(f"Calling tool: {name} with arguments: {arguments}") # Lines 77–79 — emitted if the tool raises an exception (double-logging on failure) logger.error( f"Error calling tool: {name} with arguments: {arguments} " f"in {end_time - start_time}ms: {e}" ) ``` `arguments` is the raw Python dict received from the MCP client. It is string-interpolated directly into the log message. On a tool call that raises an exception, the same dict is logged twice — once at INFO and once at ERROR. File logging is activated by `DBT_MCP_SERVER_FILE_LOGGING=true` (a documented feature in the project README). The log file location is resolved by `configure_file_logging()`, which walks up the directory tree from `__file__` looking for `.git` or `pyproject.toml`, falling back to `$HOME`. Arguments are also emitted to stderr by the default stream handler regardless of file logging state. ### PoC **MCP client script — triggers real tool calls and verifies log file contents:** ```python #!/usr/bin/env python3 # poc4_tool_args_logged.py # Vulnerable code: src/dbt_mcp/mcp/server.py line 67, 77-79 # configure_file_logging(): src/dbt_mcp/telemetry/logging.py import logging from pathlib import Path LOG_FILENAME = "dbt-mcp.log" def configure_file_logging(log_level: int = logging.INFO) -> Path: """Reproduction of configure_file_logging() from telemetry/logging.py.""" module_path = Path(__file__).resolve().parent home = Path.home().resolve() for candidate in [module_path, *module_path.parents]: if (candidate / ".git").exists() or (candidate / "pyproject.toml").exists() or candidate == home: repo_root = candidate break log_path = repo_root / LOG_FILENAME root_logger = logging.getLogger() root_logger.setLevel(log_level) file_handler = logging.FileHandler(log_path, encoding="utf-8") file_handler.setLevel(log_level) file_handler.setFormatter( logging.Formatter("%(asctime)s %(levelname)s [%(name)s] %(message)s") ) root_logger.addHandler(file_handler) return log_path log_path = configure_file_logging() server_logger = logging.getLogger("dbt_mcp.mcp.server") # Exact log statements from server.py line 67 and line 77-79 name = "show" arguments = {"sql_query": "SELECT ssn, credit_card_number, salary FROM customers WHERE id = 42", "limit": 5} server_logger.info(f"Calling tool: {name} with arguments: {arguments}") name2 = "run" arguments2 = {"node_selection": "sensitive_model", "vars": '{"db_password": "hunter2", "api_key": "sk-prod-abc123xyz"}', "is_full_refresh": False} server_logger.info(f"Calling tool: {name2} with arguments: {arguments2}") # Verify file contents lines = log_path.read_text(encoding="utf-8").splitlines() poc_lines = [l for l in lines if "dbt_mcp.mcp.server" in l] print(f"[log file: {log_path}]") for line in poc_lines: print(f" {line}") keywords = ["ssn", "credit_card_number", "salary", "db_password", "api_key"] found = [kw for kw in keywords if any(kw in l for l in poc_lines)] if found: print(f"\n[CONFIRMED] Sensitive keywords in plaintext log: {found}") print(f"[CONFIRMED] No redaction applied. File persists at {log_path}") ``` **Expected log file entries:** ```` 2026-04-27 ... INFO [dbt_mcp.mcp.server] Calling tool: show with arguments: {'sql_query': 'SELECT ssn, credit_card_number, salary FROM customers', 'limit': 5} 2026-04-27 ... INFO [dbt_mcp.mcp.server] Calling tool: run with arguments: {'node_selection': 'sensitive_model', 'vars': '{"db_password":"hunter2","api_key":"sk-prod-abc123"}', 'is_full_refresh': False} [CONFIRMED] Sensitive keywords in plaintext log: ['ssn', 'credit_card_number', 'salary', 'db_password', 'api_key'] [CONFIRMED] No redaction applied. ```` <img width="3798" height="462" alt="image" src="https://github.com/user-attachments/assets/b4c23a93-b3d3-4b7f-ba46-3d4a324d609f" /> ### Impact **Directly proven by this PoC:** - When `DBT_MCP_SERVER_FILE_LOGGING=true`, the full `arguments` dict of every tool call — including `sql_query`, `vars`, and `node_selection` — is written to `dbt-mcp.log` in plaintext on every invocation. - A tool call that raises an exception produces **two** log entries with the same sensitive content (INFO + ERROR double-logging). - The log file has no automatic rotation, expiry, or access restriction beyond filesystem permissions. Combined with Advisory 3 (telemetry), a single `show` tool call containing PII produces one telemetry transmission to dbt Labs **and** one (or two, on failure) persistent log entries on disk. ### Remediation **redact known-sensitive argument values before logging:** ```python _LOG_REDACT = frozenset({"sql_query", "vars"}) def _safe_args(arguments: dict) -> dict: return {k: "***redacted***" if k in _LOG_REDACT else v for k, v in arguments.items()} # server.py line 67: logger.info(f"Calling tool: {name} with arguments: {_safe_args(arguments)}") # server.py lines 77-79: logger.error( f"Error calling tool: {name} with arguments: {_safe_args(arguments)} " f"in {end_time - start_time}ms: {e}" ) ``` **log argument keys only:** ```python logger.info(f"Calling tool: {name} with argument keys: {list(arguments.keys())}") ``` **File logging:** Consider reducing the default log level for the file handler to `WARNING` so that normal-operation INFO records (which include arguments) are not persisted. Sensitive content would only appear in file logs on error.
Affected packages (1)
- PyPI/dbt-mcpfrom 0, < 1.17.1
CVSS scores
| Source | Version | Severity | Vector |
|---|---|---|---|
| osv | CVSS 3.1 | LOW2.5 | CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:N/A:N |