This is an automated email from the ASF dual-hosted git repository.
oscerd pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 68914926ebc1 camel-jbang - Redact secrets in structured MCP tool
results, not only String results (#25093)
68914926ebc1 is described below
commit 68914926ebc1a3a8694690a82e3612bfb8944416
Author: Andrea Cosentino <[email protected]>
AuthorDate: Tue Jul 28 09:19:49 2026 +0200
camel-jbang - Redact secrets in structured MCP tool results, not only
String results (#25093)
* camel-jbang - Redact secrets in structured MCP tool results, not only
String results
The MCP security layer's secret redaction only inspected String tool
results,
so tools returning a JsonObject (the runtime tools, for example
camel_runtime_properties which dumps a running application's configuration)
bypassed redaction even when it was enabled. The value regex also cannot
match
JSON-quoted values, so structured output was doubly unprotected.
Add McpSecretRedactor.redactStructured() which walks Map/List results in
place,
blanking the value of any entry whose key name denotes a secret and running
the
regex over string leaf values. The result is mutated in place so a tool's
declared return type is preserved. Strengthen the default patterns to cover
client_secret, secrets carried as a URL query parameter (jdbc:...?password=,
which has no userinfo @), PEM private-key blocks and passphrase. Route
Map/List
results through the new path in McpSecurityInterceptor.
Plain record-returning tools remain a follow-up: redacting them type-safely
needs reflective record rebuilding or a framework-level output filter once
the
Quarkus MCP server exposes global guardrails.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* camel-jbang - Use camel-util SensitiveUtils for MCP secret redaction
Address review feedback (davsclaus): drop the hand-rolled regex word list
and
reuse Camel's own knowledge of what a secret is.
Structured (Map/List) results are now redacted by key name via
SensitiveUtils.containsSensitive(), the same helper the jbang ListProperties
command already uses. Free-text results are masked with
DefaultMaskingFormatter,
which blanks key=value, XML and JSON values keyed off
SensitiveUtils.getSensitiveKeys(). The hand-maintained DEFAULT_PATTERNS and
SECRET_KEY_NAME are removed; operator-supplied custom patterns still apply
on
top.
Connection-string userinfo (user:pass@host) and PEM blocks are no longer
special-cased here; if wanted they belong in Camel's masker / SensitiveUtils
rather than in the MCP layer.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* camel-jbang-mcp: redact strings once instead of containsSecret + redact
Addresses the gnodet/davsclaus review: the String branch called
containsSecret(s) (which internally runs redact(s)) and then redact(s)
again, running the full masking pipeline twice for every secret-bearing
result. Use redact() as the single source of truth and compare its output
to the input. containsSecret() stays as a tested public API on the redactor.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Andrea Cosentino <[email protected]>
---------
Signed-off-by: Andrea Cosentino <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
---
.../jbang/core/commands/mcp/McpSecretRedactor.java | 102 +++++++++++++----
.../jbang/core/commands/mcp/McpSecurityConfig.java | 4 +-
.../core/commands/mcp/McpSecurityInterceptor.java | 21 +++-
.../core/commands/mcp/McpSecretRedactorTest.java | 126 +++++++++++++--------
.../core/commands/mcp/McpSecurityConfigTest.java | 10 +-
5 files changed, 184 insertions(+), 79 deletions(-)
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecretRedactor.java
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecretRedactor.java
index d9df90aa21ed..75030e2dfc4e 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecretRedactor.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecretRedactor.java
@@ -17,44 +17,57 @@
package org.apache.camel.dsl.jbang.core.commands.mcp;
import java.util.List;
+import java.util.Map;
import java.util.regex.Pattern;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
+import org.apache.camel.support.processor.DefaultMaskingFormatter;
+import org.apache.camel.util.SensitiveUtils;
+
/**
- * Regex-based secret redaction engine for MCP tool responses.
+ * Secret redaction engine for MCP tool responses.
* <p>
- * Applies configurable patterns to detect and replace credentials, tokens,
API keys, and connection strings in tool
- * output text.
+ * Both modes reuse Camel's own knowledge of what a secret is, rather than a
hand-maintained word list. Free-text output
+ * is masked with {@link DefaultMaskingFormatter}, which blanks the value of
any {@code key=value}, XML element or JSON
+ * field whose name is one of {@link SensitiveUtils#getSensitiveKeys()}.
Structured results ({@link Map}/{@link List},
+ * such as the {@code JsonObject} returned by the runtime tools) are walked
and any entry whose key
+ * {@link SensitiveUtils#containsSensitive(String) is sensitive} has its value
blanked, which is what catches
+ * JSON-quoted values inside an object tree. Operators may add extra regex
patterns via
+ * {@code camel.mcp.security.redaction.patterns}.
*/
@ApplicationScoped
public class McpSecretRedactor {
static final String REDACTED = "***REDACTED***";
- static final List<Pattern> DEFAULT_PATTERNS = List.of(
- // password/passwd/pwd key-value pairs
-
Pattern.compile("(?i)(password|passwd|pwd)\\s*[=:]\\s*[^\\s,;}'\"\\]]+"),
- // API keys, secret keys, access keys
-
Pattern.compile("(?i)(api[_-]?key|apikey|secret[_-]?key|access[_-]?key)\\s*[=:]\\s*[^\\s,;}'\"\\]]+"),
- // tokens and bearer authorization
-
Pattern.compile("(?i)(token|bearer|authorization)\\s*[=:]\\s*[^\\s,;}'\"\\]]+"),
- // AWS Access Key IDs (AKIA prefix + 16 alphanumeric)
- Pattern.compile("AKIA[0-9A-Z]{16}"),
- // Connection strings with embedded credentials
-
Pattern.compile("(?i)(mongodb(\\+srv)?://|amqp://|redis://|jdbc:)[^\\s\"']+@[^\\s\"']+"));
-
@Inject
McpSecurityConfig config;
+ private volatile DefaultMaskingFormatter maskingFormatter;
+
+ private DefaultMaskingFormatter maskingFormatter() {
+ DefaultMaskingFormatter mf = maskingFormatter;
+ if (mf == null) {
+ // Masks key=value, XML and JSON using
SensitiveUtils.getSensitiveKeys() as the keyword set.
+ mf = new DefaultMaskingFormatter();
+ mf.setMaskString(REDACTED);
+ maskingFormatter = mf;
+ }
+ return mf;
+ }
+
+ /**
+ * Masks secrets in free-text tool output using Camel's masking formatter,
then applies any operator-supplied custom
+ * patterns.
+ */
public String redact(String text) {
if (text == null || text.isEmpty()) {
return text;
}
- List<Pattern> patterns = config.getRedactionPatterns();
- String result = text;
- for (Pattern pattern : patterns) {
+ String result = maskingFormatter().format(text);
+ for (Pattern pattern : config.getRedactionPatterns()) {
result = pattern.matcher(result).replaceAll(REDACTED);
}
return result;
@@ -64,12 +77,55 @@ public class McpSecretRedactor {
if (text == null || text.isEmpty()) {
return false;
}
- List<Pattern> patterns = config.getRedactionPatterns();
- for (Pattern pattern : patterns) {
- if (pattern.matcher(text).find()) {
- return true;
+ return !redact(text).equals(text);
+ }
+
+ /**
+ * Redacts a structured tool result in place. In a {@link Map} (such as
the {@code JsonObject} returned by the
+ * runtime tools) any entry whose key {@link
SensitiveUtils#containsSensitive(String) is sensitive} has its value
+ * blanked, and every string value is passed through {@link
#redact(String)}; {@link List} elements are recursed.
+ * The same instance is mutated and returned, so a tool's declared return
type is preserved.
+ *
+ * @param value the result to scrub (a {@code Map}, {@code List}, or
anything else which is returned untouched)
+ * @return {@code true} if anything was changed
+ */
+ @SuppressWarnings("unchecked")
+ public boolean redactStructured(Object value) {
+ boolean changed = false;
+ if (value instanceof Map<?, ?> map) {
+ Map<Object, Object> mutable = (Map<Object, Object>) map;
+ for (Object key : List.copyOf(mutable.keySet())) {
+ Object current = mutable.get(key);
+ if (key instanceof String name &&
SensitiveUtils.containsSensitive(name)) {
+ if (current != null && !REDACTED.equals(current)) {
+ mutable.put(key, REDACTED);
+ changed = true;
+ }
+ } else if (current instanceof String s) {
+ String scrubbed = redact(s);
+ if (!scrubbed.equals(s)) {
+ mutable.put(key, scrubbed);
+ changed = true;
+ }
+ } else if (current instanceof Map<?, ?> || current instanceof
List<?>) {
+ changed |= redactStructured(current);
+ }
+ }
+ } else if (value instanceof List<?> list) {
+ List<Object> mutable = (List<Object>) list;
+ for (int i = 0; i < mutable.size(); i++) {
+ Object current = mutable.get(i);
+ if (current instanceof String s) {
+ String scrubbed = redact(s);
+ if (!scrubbed.equals(s)) {
+ mutable.set(i, scrubbed);
+ changed = true;
+ }
+ } else if (current instanceof Map<?, ?> || current instanceof
List<?>) {
+ changed |= redactStructured(current);
+ }
}
}
- return false;
+ return changed;
}
}
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecurityConfig.java
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecurityConfig.java
index 25f21a5bf2f1..0648afa5dbac 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecurityConfig.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecurityConfig.java
@@ -93,7 +93,9 @@ public class McpSecurityConfig {
if (cached != null) {
return cached;
}
- List<Pattern> patterns = new
ArrayList<>(McpSecretRedactor.DEFAULT_PATTERNS);
+ // Built-in secret detection is handled by McpSecretRedactor via
Camel's DefaultMaskingFormatter and
+ // SensitiveUtils. These are only the extra, operator-supplied
patterns.
+ List<Pattern> patterns = new ArrayList<>();
if (redactionPatterns.isPresent()) {
for (String p : redactionPatterns.get().split(",")) {
String trimmed = p.trim();
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecurityInterceptor.java
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecurityInterceptor.java
index 2d0fc19f3e1b..bbc59e988379 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecurityInterceptor.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecurityInterceptor.java
@@ -17,6 +17,8 @@
package org.apache.camel.dsl.jbang.core.commands.mcp;
import java.lang.reflect.Method;
+import java.util.List;
+import java.util.Map;
import jakarta.annotation.Priority;
import jakarta.inject.Inject;
@@ -84,11 +86,20 @@ public class McpSecurityInterceptor {
try {
Object result = ctx.proceed();
- // Secret redaction on string results
- if (config.isRedactionEnabled() && result instanceof String s) {
- if (redactor.containsSecret(s)) {
- result = redactor.redact(s);
- wasRedacted = true;
+ // Secret redaction. Free-text (String) results go through the
regex redactor; structured results
+ // (Map/List, such as the JsonObject returned by the runtime
tools) are scrubbed by key name in place,
+ // which is what catches JSON-quoted values the value regex cannot
match.
+ if (config.isRedactionEnabled() && result != null) {
+ if (result instanceof String s) {
+ // redact() is the single source of truth: if it changes
the string, a secret was masked.
+ // Calling containsSecret() first would run the full
masking pipeline a second time.
+ String redacted = redactor.redact(s);
+ if (!redacted.equals(s)) {
+ result = redacted;
+ wasRedacted = true;
+ }
+ } else if (result instanceof Map<?, ?> || result instanceof
List<?>) {
+ wasRedacted = redactor.redactStructured(result);
}
}
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecretRedactorTest.java
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecretRedactorTest.java
index f958b45bc81e..e009972be071 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecretRedactorTest.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecretRedactorTest.java
@@ -18,6 +18,8 @@ package org.apache.camel.dsl.jbang.core.commands.mcp;
import java.util.Optional;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@@ -40,97 +42,129 @@ class McpSecretRedactorTest {
}
@Test
- void redactsPasswordKeyValue() {
+ void redactsKeyValueUsingCamelSensitiveKeywords() {
McpSecretRedactor redactor = createRedactor(null);
+ // The value is masked and the key is preserved (Camel's
DefaultMaskingFormatter behaviour).
assertThat(redactor.redact("password=secret123"))
- .isEqualTo(McpSecretRedactor.REDACTED);
- assertThat(redactor.redact("Password: myP@ssw0rd"))
- .isEqualTo(McpSecretRedactor.REDACTED);
- assertThat(redactor.redact("pwd=abc"))
- .isEqualTo(McpSecretRedactor.REDACTED);
+ .isEqualTo("password=" + McpSecretRedactor.REDACTED);
+ assertThat(redactor.redact("apikey=sk-12345"))
+ .isEqualTo("apikey=" + McpSecretRedactor.REDACTED);
+ assertThat(redactor.redact("accessToken=abc123"))
+ .isEqualTo("accessToken=" + McpSecretRedactor.REDACTED);
+ // A compound property name is matched via the embedded keyword.
+ assertThat(redactor.redact("datasource.password=hunter2"))
+ .doesNotContain("hunter2")
+ .contains(McpSecretRedactor.REDACTED);
}
@Test
- void redactsApiKeys() {
+ void redactsSecretCarriedAsUrlQueryParameter() {
McpSecretRedactor redactor = createRedactor(null);
- assertThat(redactor.redact("api_key=sk-12345"))
- .isEqualTo(McpSecretRedactor.REDACTED);
- assertThat(redactor.redact("apiKey: abc123"))
- .isEqualTo(McpSecretRedactor.REDACTED);
- assertThat(redactor.redact("secret-key=xyz789"))
- .isEqualTo(McpSecretRedactor.REDACTED);
+ assertThat(redactor.redact("uri:
jdbc:postgresql://db:5432/app?ssl=true&password=hunter2"))
+ .doesNotContain("hunter2")
+ .contains(McpSecretRedactor.REDACTED);
}
@Test
- void redactsTokensAndBearer() {
+ void redactsSensitiveJsonField() {
McpSecretRedactor redactor = createRedactor(null);
- assertThat(redactor.redact("token=eyJhbGciOiJIUzI1NiJ9"))
- .isEqualTo(McpSecretRedactor.REDACTED);
- assertThat(redactor.redact("bearer: some-bearer-token"))
- .isEqualTo(McpSecretRedactor.REDACTED);
+
assertThat(redactor.redact("{\"password\":\"hunter2\",\"name\":\"orders\"}"))
+ .doesNotContain("hunter2")
+ .contains("orders");
}
@Test
- void redactsAwsAccessKeyIds() {
+ void doesNotRedactNonSensitiveKeyValue() {
McpSecretRedactor redactor = createRedactor(null);
- assertThat(redactor.redact("key is AKIAIOSFODNN7EXAMPLE"))
- .isEqualTo("key is " + McpSecretRedactor.REDACTED);
+
assertThat(redactor.redact("route=timer-1")).isEqualTo("route=timer-1");
+ assertThat(redactor.redact("Route started successfully with 3
endpoints"))
+ .isEqualTo("Route started successfully with 3 endpoints");
}
@Test
- void redactsConnectionStringsWithCredentials() {
+ void handlesNullAndEmpty() {
McpSecretRedactor redactor = createRedactor(null);
- assertThat(redactor.redact("uri: mongodb://user:pass@host:27017/db"))
- .isEqualTo("uri: " + McpSecretRedactor.REDACTED);
-
assertThat(redactor.redact("url=amqp://admin:secret@broker:5672/vhost"))
- .isEqualTo("url=" + McpSecretRedactor.REDACTED);
+ assertThat(redactor.redact(null)).isNull();
+ assertThat(redactor.redact("")).isEmpty();
}
@Test
- void doesNotRedactNormalText() {
+ void containsSecretDetectsSecrets() {
McpSecretRedactor redactor = createRedactor(null);
- String normalText = "Route started successfully with 3 endpoints";
- assertThat(redactor.redact(normalText)).isEqualTo(normalText);
+ assertThat(redactor.containsSecret("password=secret")).isTrue();
+ assertThat(redactor.containsSecret("normal text")).isFalse();
+ assertThat(redactor.containsSecret(null)).isFalse();
+ assertThat(redactor.containsSecret("")).isFalse();
}
@Test
- void handlesMultipleSecretsInSameString() {
- McpSecretRedactor redactor = createRedactor(null);
+ void customPatternsAreApplied() {
+ McpSecretRedactor redactor = createRedactor("CUSTOM_\\d+");
- String input = "password=secret, apiKey=abc123";
- String result = redactor.redact(input);
- assertThat(result).doesNotContain("secret").doesNotContain("abc123");
+ assertThat(redactor.redact("value: CUSTOM_12345 here"))
+ .isEqualTo("value: " + McpSecretRedactor.REDACTED + " here");
}
@Test
- void handlesNullAndEmpty() {
+ void redactsStructuredJsonObjectByKeyName() {
McpSecretRedactor redactor = createRedactor(null);
- assertThat(redactor.redact(null)).isNull();
- assertThat(redactor.redact("")).isEmpty();
+ // The case the String-only path missed: a JsonObject (as returned by
the runtime tools) whose value is
+ // JSON-quoted. Structured redaction blanks it by key name using
SensitiveUtils.containsSensitive, which
+ // strips a compound property to its last segment (datasource.password
-> password).
+ JsonObject properties = new JsonObject();
+ properties.put("datasource.url", "jdbc:postgresql://db:5432/app");
+ properties.put("datasource.password", "hunter2");
+ properties.put("app.name", "orders");
+ JsonObject nested = new JsonObject();
+ nested.put("client-secret", "s3cr3t");
+ properties.put("oauth", nested);
+
+ boolean changed = redactor.redactStructured(properties);
+
+ assertThat(changed).isTrue();
+
assertThat(properties.getString("datasource.password")).isEqualTo(McpSecretRedactor.REDACTED);
+ assertThat(properties.getString("app.name")).isEqualTo("orders");
+
assertThat(properties.getString("datasource.url")).isEqualTo("jdbc:postgresql://db:5432/app");
+ assertThat(((JsonObject)
properties.get("oauth")).getString("client-secret"))
+ .isEqualTo(McpSecretRedactor.REDACTED);
}
@Test
- void containsSecretDetectsSecrets() {
+ void redactsSecretsEmbeddedInStructuredStringValues() {
McpSecretRedactor redactor = createRedactor(null);
- assertThat(redactor.containsSecret("password=secret")).isTrue();
- assertThat(redactor.containsSecret("normal text")).isFalse();
- assertThat(redactor.containsSecret(null)).isFalse();
- assertThat(redactor.containsSecret("")).isFalse();
+ // A non-secret key whose string value nonetheless embeds a key=value
credential.
+ JsonObject config = new JsonObject();
+ config.put("name", "orders");
+ JsonArray endpoints = new JsonArray();
+ endpoints.add("timer://foo");
+ endpoints.add("sql://bar?dataSource=#ds&password=leaked");
+ config.put("endpoints", endpoints);
+
+ boolean changed = redactor.redactStructured(config);
+
+ assertThat(changed).isTrue();
+ assertThat(config.getString("name")).isEqualTo("orders");
+ assertThat(endpoints.getString(0)).isEqualTo("timer://foo");
+
assertThat(endpoints.getString(1)).doesNotContain("leaked").contains(McpSecretRedactor.REDACTED);
}
@Test
- void customPatternsAreApplied() {
- McpSecretRedactor redactor = createRedactor("CUSTOM_\\d+");
+ void structuredRedactionReportsNoChangeForCleanData() {
+ McpSecretRedactor redactor = createRedactor(null);
- assertThat(redactor.redact("value: CUSTOM_12345 here"))
- .isEqualTo("value: " + McpSecretRedactor.REDACTED + " here");
+ JsonObject clean = new JsonObject();
+ clean.put("name", "orders");
+ clean.put("routes", 3);
+
+ assertThat(redactor.redactStructured(clean)).isFalse();
+ assertThat(clean.getString("name")).isEqualTo("orders");
}
}
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecurityConfigTest.java
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecurityConfigTest.java
index b1a9e793b4fe..99574a8f03e1 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecurityConfigTest.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/McpSecurityConfigTest.java
@@ -99,21 +99,23 @@ class McpSecurityConfigTest {
// ---- Redaction patterns ----
@Test
- void defaultRedactionPatternsIncludeBuiltins() {
+ void redactionPatternsAreEmptyWithoutCustomPatterns() {
McpSecurityConfig config = createConfig(true, "admin", false, true,
true, null);
+ // Built-in secret detection is handled by McpSecretRedactor
(DefaultMaskingFormatter + SensitiveUtils),
+ // so getRedactionPatterns() returns only the operator-supplied extra
patterns.
List<Pattern> patterns = config.getRedactionPatterns();
-
assertThat(patterns).hasSizeGreaterThanOrEqualTo(McpSecretRedactor.DEFAULT_PATTERNS.size());
+ assertThat(patterns).isEmpty();
}
@Test
- void customRedactionPatternsAppended() {
+ void customRedactionPatternsAreReturned() {
McpSecurityConfig config = createConfig(true, "admin", false, true,
true, "SECRET_\\d+,TOKEN_[A-Z]+");
List<Pattern> patterns = config.getRedactionPatterns();
-
assertThat(patterns.size()).isEqualTo(McpSecretRedactor.DEFAULT_PATTERNS.size()
+ 2);
+ assertThat(patterns).hasSize(2);
}
// ---- Helper ----