{"id":"CVE-2026-44969","aliases":["GHSA-7xgw-6qf3-7w59","PYSEC-2026-2441"],"title":"dbt MCP Server Logs Tool Arguments Including SQL Queries and Credentials in Plaintext Without Redaction When File Logging Is Enabled","summary":"dbt MCP Server Logs Tool Arguments Including SQL Queries and Credentials in Plaintext Without Redaction When File Logging Is Enabled","severity":"low","cvss":2.5,"cvssVector":"CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:N/A:N","vendor":"dbt-mcp","product":"dbt-mcp","ecosystem":"pip","affected":["dbt-mcp < 1.17.1"],"patched":["dbt-mcp 1.17.1"],"published":"2026-05-14","updated":"2026-07-13","source":"OSV","sourceUrl":"https://osv.dev/vulnerability/GHSA-7xgw-6qf3-7w59","references":[{"url":"https://github.com/dbt-labs/dbt-mcp/security/advisories/GHSA-7xgw-6qf3-7w59"},{"url":"https://github.com/dbt-labs/dbt-mcp"},{"url":"https://github.com/dbt-labs/dbt-mcp/releases/tag/v1.17.1"}],"tags":["osv","pip"],"ingestedAt":"2026-07-13T18:57:54.784Z","epss":0.00165,"epssPercentile":0.06137,"slug":"CVE-2026-44969","body":"## Overview\n\n*Discovered through manual source code review. Verified by PoC execution against a local dbt-mcp v1.15.1 installation.*\n\n### Summary\n\n`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.\n\n### Details\n\n**Vulnerable log statements (`server.py`):**\n\n```python\n# Line 67 — emitted before every tool execution\nlogger.info(f\"Calling tool: {name} with arguments: {arguments}\")\n\n# Lines 77–79 — emitted if the tool raises an exception (double-logging on failure)\nlogger.error(\n    f\"Error calling tool: {name} with arguments: {arguments} \"\n    f\"in {end_time - start_time}ms: {e}\"\n)\n```\n\n`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.\n\nFile 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.\n\n### PoC\n\n**MCP client script — triggers real tool calls and verifies log file contents:**\n\n```python\n#!/usr/bin/env python3\n# poc4_tool_args_logged.py\n# Vulnerable code: src/dbt_mcp/mcp/server.py line 67, 77-79\n# configure_file_logging(): src/dbt_mcp/telemetry/logging.py\n\nimport logging\nfrom pathlib import Path\n\nLOG_FILENAME = \"dbt-mcp.log\"\n\ndef configure_file_logging(log_level: int = logging.INFO) -> Path:\n    \"\"\"Reproduction of configure_file_logging() from telemetry/logging.py.\"\"\"\n    module_path = Path(__file__).resolve().parent\n    home = Path.home().resolve()\n    for candidate in [module_path, *module_path.parents]:\n        if (candidate / \".git\").exists() or (candidate / \"pyproject.toml\").exists() or candidate == home:\n            repo_root = candidate\n            break\n    log_path = repo_root / LOG_FILENAME\n    root_logger = logging.getLogger()\n    root_logger.setLevel(log_level)\n    file_handler = logging.FileHandler(log_path, encoding=\"utf-8\")\n    file_handler.setLevel(log_level)\n    file_handler.setFormatter(\n        logging.Formatter(\"%(asctime)s %(levelname)s [%(name)s] %(message)s\")\n    )\n    root_logger.addHandler(file_handler)\n    return log_path\n\nlog_path = configure_file_logging()\nserver_logger = logging.getLogger(\"dbt_mcp.mcp.server\")\n\n# Exact log statements from server.py line 67 and line 77-79\nname = \"show\"\narguments = {\"sql_query\": \"SELECT ssn, credit_card_number, salary FROM customers WHERE id = 42\", \"limit\": 5}\nserver_logger.info(f\"Calling tool: {name} with arguments: {arguments}\")\n\nname2 = \"run\"\narguments2 = {\"node_selection\": \"sensitive_model\", \"vars\": '{\"db_password\": \"hunter2\", \"api_key\": \"sk-prod-abc123xyz\"}', \"is_full_refresh\": False}\nserver_logger.info(f\"Calling tool: {name2} with arguments: {arguments2}\")\n\n# Verify file contents\nlines = log_path.read_text(encoding=\"utf-8\").splitlines()\npoc_lines = [l for l in lines if \"dbt_mcp.mcp.server\" in l]\nprint(f\"[log file: {log_path}]\")\nfor line in poc_lines:\n    print(f\"  {line}\")\n\nkeywords = [\"ssn\", \"credit_card_number\", \"salary\", \"db_password\", \"api_key\"]\nfound = [kw for kw in keywords if any(kw in l for l in poc_lines)]\nif found:\n    print(f\"\\n[CONFIRMED] Sensitive keywords in plaintext log: {found}\")\n    print(f\"[CONFIRMED] No redaction applied. File persists at {log_path}\")\n```\n\n**Expected log file entries:**\n\n````\n2026-04-27 ... INFO [dbt_mcp.mcp.server] Calling tool: show with arguments:\n  {'sql_query': 'SELECT ssn, credit_card_number, salary FROM customers', 'limit': 5}\n\n2026-04-27 ... INFO [dbt_mcp.mcp.server] Calling tool: run with arguments:\n  {'node_selection': 'sensitive_model',\n   'vars': '{\"db_password\":\"hunter2\",\"api_key\":\"sk-prod-abc123\"}',\n   'is_full_refresh': False}\n\n[CONFIRMED] Sensitive keywords in plaintext log: ['ssn', 'credit_card_number', 'salary', 'db_password', 'api_key']\n[CONFIRMED] No redaction applied.\n````\n\n<img width=\"3798\" height=\"462\" alt=\"image\" src=\"https://github.com/user-attachments/assets/b4c23a93-b3d3-4b7f-ba46-3d4a324d609f\" />\n\n### Impact\n\n**Directly proven by this PoC:**\n\n- 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.\n- A tool call that raises an exception produces **two** log entries with the same sensitive content (INFO + ERROR double-logging).\n- The log file has no automatic rotation, expiry, or access restriction beyond filesystem permissions.\n\nCombined 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.\n\n### Remediation\n\n**redact known-sensitive argument values before logging:**\n\n```python\n_LOG_REDACT = frozenset({\"sql_query\", \"vars\"})\n\ndef _safe_args(arguments: dict) -> dict:\n    return {k: \"***redacted***\" if k in _LOG_REDACT else v\n            for k, v in arguments.items()}\n\n# server.py line 67:\nlogger.info(f\"Calling tool: {name} with arguments: {_safe_args(arguments)}\")\n\n# server.py lines 77-79:\nlogger.error(\n    f\"Error calling tool: {name} with arguments: {_safe_args(arguments)} \"\n    f\"in {end_time - start_time}ms: {e}\"\n)\n```\n\n**log argument keys only:**\n\n```python\nlogger.info(f\"Calling tool: {name} with argument keys: {list(arguments.keys())}\")\n```\n\n**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.\n\n## Affected packages\n\n- `dbt-mcp < 1.17.1`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `dbt-mcp 1.17.1`","depth":"sunlit","depthScore":14,"depthScoreParts":{"impact":13.8,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}