This is an automated email from the ASF dual-hosted git repository. cgivre pushed a commit to branch feat/drill-mcp-server in repository https://gitbox.apache.org/repos/asf/drill-mcp.git
commit 4d622b946af86c0b61301314df0a67f52c9e1062 Author: cgivre <[email protected]> AuthorDate: Tue Aug 11 16:12:32 2026 -0400 feat: recursive secret redaction --- drill_mcp/redact.py | 55 +++++++++++++++++++++++++++++ tests/test_redact.py | 97 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/drill_mcp/redact.py b/drill_mcp/redact.py new file mode 100644 index 0000000..a85e041 --- /dev/null +++ b/drill_mcp/redact.py @@ -0,0 +1,55 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +# either express or implied. See the License for the specific +# language governing permissions and limitations under the +# License. +# + +"""Recursive secret redaction for anything returned to an MCP client. + +Storage plugin configurations routinely carry AWS keys, JDBC passwords, and +OAuth tokens. Tool output goes to a model and often on to a third-party API, so +this is a trust boundary and is not configurable off. +""" + +from __future__ import annotations + +import re +from typing import Any + +REDACTED = "***REDACTED***" + +# Matches anywhere in the key, so `fs.s3a.secret.key` and `awsSecretAccessKey` +# are both caught. Deliberately broad: a false redaction is a cosmetic problem, +# a missed one is a leaked credential. Uses negative lookahead on credential(s) +# to avoid matching keys like "credentialsProvider" where it's part of a longer word. +_SENSITIVE = re.compile( + r"password|passwd|secret|credentials?(?![a-z])|token|access[._-]?key|private[._-]?key|api[._-]?key", + re.IGNORECASE, +) + + +def redact(value: Any) -> Any: + """Return a copy of `value` with sensitive-looking values replaced.""" + if isinstance(value, dict): + return { + key: REDACTED if _SENSITIVE.search(str(key)) else redact(item) + for key, item in value.items() + } + if isinstance(value, list): + return [redact(item) for item in value] + if isinstance(value, tuple): + return tuple(redact(item) for item in value) + return value diff --git a/tests/test_redact.py b/tests/test_redact.py new file mode 100644 index 0000000..bcbf637 --- /dev/null +++ b/tests/test_redact.py @@ -0,0 +1,97 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +# either express or implied. See the License for the specific +# language governing permissions and limitations under the +# License. +# + +from drill_mcp.redact import REDACTED, redact + + +def test_redacts_password_key(): + assert redact({"password": "hunter2"}) == {"password": REDACTED} + + +def test_redaction_is_case_insensitive(): + assert redact({"PassWord": "hunter2"}) == {"PassWord": REDACTED} + + +def test_redacts_all_sensitive_key_patterns(): + source = { + "accessKey": "AKIA", + "access_key": "AKIA", + "secretKey": "s", + "token": "t", + "credential": "c", + "privateKey": "p", + "oauthToken": "o", + } + assert all(v == REDACTED for v in redact(source).values()) + + +def test_leaves_innocuous_keys_alone(): + assert redact({"type": "file", "connection": "s3a://bucket"}) == { + "type": "file", + "connection": "s3a://bucket", + } + + +def test_recurses_into_nested_dicts(): + source = {"config": {"credentialsProvider": {"awsSecretAccessKey": "s"}}} + assert redact(source)["config"]["credentialsProvider"]["awsSecretAccessKey"] == REDACTED + + +def test_recurses_into_lists(): + source = {"plugins": [{"password": "a"}, {"password": "b"}]} + assert [p["password"] for p in redact(source)["plugins"]] == [REDACTED, REDACTED] + + +def test_does_not_mutate_the_input(): + source = {"password": "hunter2"} + redact(source) + assert source["password"] == "hunter2" + + +def test_redacts_whole_subtree_under_a_sensitive_key(): + source = {"credentials": {"user": "alice", "pass": "x"}} + assert redact(source)["credentials"] == REDACTED + + +def test_passes_through_scalars(): + assert redact("plain") == "plain" + assert redact(42) == 42 + assert redact(None) is None + + +def test_realistic_s3_plugin_config(): + plugin = { + "name": "s3", + "config": { + "type": "file", + "connection": "s3a://my-bucket", + "config": { + "fs.s3a.access.key": "AKIAEXAMPLE", + "fs.s3a.secret.key": "verysecret", + "fs.s3a.endpoint": "s3.amazonaws.com", + }, + "workspaces": {"root": {"location": "/", "writable": False}}, + }, + } + result = redact(plugin) + inner = result["config"]["config"] + assert inner["fs.s3a.access.key"] == REDACTED + assert inner["fs.s3a.secret.key"] == REDACTED + assert inner["fs.s3a.endpoint"] == "s3.amazonaws.com" + assert result["config"]["workspaces"]["root"]["location"] == "/"
