This is an automated email from the ASF dual-hosted git repository.

dongjoon-hyun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/spark-kubernetes-operator.git


The following commit(s) were added to refs/heads/main by this push:
     new f3551e1  [SPARK-57950] Support `spark.redaction.regex` and redact 
sensitive values in operator logs
f3551e1 is described below

commit f3551e102cfff92233d8662b3ab4d180f550ba8b
Author: Dongjoon Hyun <[email protected]>
AuthorDate: Sun Jul 5 21:30:17 2026 -0700

    [SPARK-57950] Support `spark.redaction.regex` and redact sensitive values 
in operator logs
    
    ### What changes were proposed in this pull request?
    
    This PR aims to redact sensitive configuration values in operator logs.
    
    - Add a new config option, `spark.redaction.regex`, reusing Apache Spark's 
redaction key and default pattern (`(?i)secret|password|token|access[.]?key`).
    - Add `Utils.redactSensitiveInfo` which replaces a value with 
`*********(redacted)` when its key or value matches the pattern.
    - Apply redaction to the configuration logging on startup (`spark.logConf`).
    - Escape pipes in `DocTable` cells and regenerate 
`docs/config_properties.md`.
    
    ### Why are the changes needed?
    
    When `spark.logConf` is enabled, the operator prints all configuration 
properties without filtering, exposing sensitive values like `*.password` or 
`spark.hadoop.fs.s3a.access.key` in plain text.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes, sensitive configuration values are now printed as 
`*********(redacted)`, and the pattern is customizable via 
`spark.redaction.regex`.
    
    ### How was this patch tested?
    
    Pass the CIs with the newly added test cases.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Fable 5
    
    Closes #746 from dongjoon-hyun/SPARK-57950.
    
    Authored-by: Dongjoon Hyun <[email protected]>
    Signed-off-by: Dongjoon Hyun <[email protected]>
---
 .../apache/spark/k8s/operator/utils/DocTable.java  |   3 +-
 docs/config_properties.md                          |   1 +
 .../apache/spark/k8s/operator/SparkOperator.java   |   3 +-
 .../k8s/operator/config/SparkOperatorConf.java     |  16 +++
 .../org/apache/spark/k8s/operator/utils/Utils.java |  34 +++++++
 .../spark/k8s/operator/SparkOperatorTest.java      | 107 ++++++++++++++++++++
 .../apache/spark/k8s/operator/utils/UtilsTest.java | 108 +++++++++++++++++++++
 7 files changed, 270 insertions(+), 2 deletions(-)

diff --git 
a/build-tools/docs-utils/src/main/java/org/apache/spark/k8s/operator/utils/DocTable.java
 
b/build-tools/docs-utils/src/main/java/org/apache/spark/k8s/operator/utils/DocTable.java
index bb511d9..57faf3d 100644
--- 
a/build-tools/docs-utils/src/main/java/org/apache/spark/k8s/operator/utils/DocTable.java
+++ 
b/build-tools/docs-utils/src/main/java/org/apache/spark/k8s/operator/utils/DocTable.java
@@ -63,7 +63,8 @@ public class DocTable {
   private String joinRow(List<String> elements) {
     StringBuilder stringBuilder = new StringBuilder(ROW_SEPARATOR);
     for (String element : elements) {
-      stringBuilder.append(element).append(ROW_SEPARATOR);
+      // Escape pipes in cell content so that they do not break the Markdown 
table layout
+      stringBuilder.append(element.replace("|", "\\|")).append(ROW_SEPARATOR);
     }
     if (elements.size() < columns) {
       // Append empty cells to end if needed
diff --git a/docs/config_properties.md b/docs/config_properties.md
index f82f2b9..9c14468 100644
--- a/docs/config_properties.md
+++ b/docs/config_properties.md
@@ -51,4 +51,5 @@
  | spark.kubernetes.operator.reconciler.trimStateTransitionHistoryEnabled | 
Boolean | true | true | When enabled, operator would trim state transition 
history when a new attempt starts, keeping previous attempt summary only. | 
  | spark.kubernetes.operator.watchedNamespaces | String | default | true | 
Comma-separated list of namespaces that the operator would be watching for 
Spark resources. If set to '*', operator would watch all namespaces. | 
  | spark.logConf | Boolean | false | true | When enabled, operator will print 
configurations | 
+ | spark.redaction.regex | String | (?i)secret\|password\|token\|access[.]?key 
| true | Regex to decide which parts of configuration properties contain 
sensitive information, whose values would be redacted in operator logs. This 
reuses Spark's redaction configuration key and default pattern. | 
 
diff --git 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/SparkOperator.java 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/SparkOperator.java
index 3dcb9ad..911117a 100644
--- 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/SparkOperator.java
+++ 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/SparkOperator.java
@@ -106,7 +106,8 @@ public class SparkOperator {
     this.registeredOperators = new ArrayList<>();
     this.registeredOperators.add(registerSparkOperator());
     if (SparkOperatorConf.LOG_CONF.getValue()) {
-      for (var entry : SparkOperatorConfManager.INSTANCE.getAll().entrySet()) {
+      for (var entry :
+          
Utils.redactSensitiveInfo(SparkOperatorConfManager.INSTANCE.getAll()).entrySet())
 {
         log.info("{} = {}", entry.getKey(), entry.getValue());
       }
     }
diff --git 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/config/SparkOperatorConf.java
 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/config/SparkOperatorConf.java
index 8b95d0f..03ab285 100644
--- 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/config/SparkOperatorConf.java
+++ 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/config/SparkOperatorConf.java
@@ -40,6 +40,22 @@ public final class SparkOperatorConf {
           .defaultValue(false)
           .build();
 
+  /**
+   * Regex to decide which parts of configuration properties contain sensitive 
information, whose
+   * values are redacted in operator logs. Reuses Spark's {@code 
spark.redaction.regex}
+   * configuration key and default pattern.
+   */
+  public static final ConfigOption<String> REDACTION_REGEX =
+      ConfigOption.<String>builder()
+          .key("spark.redaction.regex")
+          .description(
+              "Regex to decide which parts of configuration properties contain 
sensitive "
+                  + "information, whose values would be redacted in operator 
logs. This reuses "
+                  + "Spark's redaction configuration key and default pattern.")
+          .typeParameterClass(String.class)
+          .defaultValue("(?i)secret|password|token|access[.]?key")
+          .build();
+
   /**
    * Interval (in seconds) between periodic System.gc() invocations. Set to 0 
or a negative value
    * to disable.
diff --git 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/Utils.java 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/Utils.java
index 024a0e2..8921234 100644
--- 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/Utils.java
+++ 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/Utils.java
@@ -35,6 +35,7 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 
 import io.fabric8.kubernetes.api.model.HasMetadata;
@@ -51,6 +52,9 @@ import 
org.apache.spark.k8s.operator.listeners.SparkClusterStatusListener;
 /** Utility class for common operations. */
 public final class Utils {
 
+  /** Replacement text for redacted sensitive values, same as Spark's 
redaction utility. */
+  public static final String REDACTION_REPLACEMENT_TEXT = 
"*********(redacted)";
+
   private Utils() {}
 
   /**
@@ -232,6 +236,36 @@ public final class Utils {
     return commonLabels;
   }
 
+  /**
+   * Redacts sensitive values (e.g. secrets, passwords, tokens) in the given 
properties for safe
+   * logging, following the semantics of Spark's {@code spark.redaction.regex} 
based redaction. The
+   * redaction pattern is resolved from the {@code spark.redaction.regex} 
entry of the given
+   * properties, falling back to the operator configuration ({@link
+   * SparkOperatorConf#REDACTION_REGEX}). An entry is redacted when its key or 
value matches the
+   * pattern.
+   *
+   * @param props The properties to redact.
+   * @return A Map containing the given entries with sensitive values redacted.
+   */
+  public static Map<String, String> redactSensitiveInfo(Map<?, ?> props) {
+    Map<String, String> conf = new HashMap<>();
+    props.forEach((k, v) -> conf.put(String.valueOf(k), String.valueOf(v)));
+    String regex =
+        conf.getOrDefault(
+            SparkOperatorConf.REDACTION_REGEX.getKey(),
+            SparkOperatorConf.REDACTION_REGEX.getValue());
+    Pattern pattern = Pattern.compile(regex);
+    Map<String, String> redacted = new HashMap<>();
+    for (Map.Entry<String, String> entry : conf.entrySet()) {
+      if (pattern.matcher(entry.getKey()).find() || 
pattern.matcher(entry.getValue()).find()) {
+        redacted.put(entry.getKey(), REDACTION_REPLACEMENT_TEXT);
+      } else {
+        redacted.put(entry.getKey(), entry.getValue());
+      }
+    }
+    return redacted;
+  }
+
   /**
    * Creates a SecondaryToPrimaryMapper that maps secondary resources to their 
primary resources
    * based on a common label.
diff --git 
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/SparkOperatorTest.java
 
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/SparkOperatorTest.java
index a110fdd..7c55c33 100644
--- 
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/SparkOperatorTest.java
+++ 
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/SparkOperatorTest.java
@@ -29,12 +29,24 @@ import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 import static org.mockito.Mockito.when;
 
+import java.util.List;
+import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.function.Consumer;
 
 import io.fabric8.kubernetes.client.KubernetesClient;
 import io.javaoperatorsdk.operator.Operator;
 import io.javaoperatorsdk.operator.RegisteredController;
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.core.Filter;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.LoggerContext;
+import org.apache.logging.log4j.core.appender.AbstractAppender;
+import org.apache.logging.log4j.core.config.Configuration;
+import org.apache.logging.log4j.core.config.Property;
+import org.apache.logging.log4j.core.layout.PatternLayout;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 import org.mockito.MockedConstruction;
@@ -43,6 +55,7 @@ import org.mockito.MockedStatic;
 import org.apache.spark.k8s.operator.client.KubernetesClientFactory;
 import org.apache.spark.k8s.operator.config.DynamicConfigMonitor;
 import org.apache.spark.k8s.operator.config.SparkOperatorConf;
+import org.apache.spark.k8s.operator.config.SparkOperatorConfManager;
 import org.apache.spark.k8s.operator.metrics.MetricsService;
 import org.apache.spark.k8s.operator.metrics.MetricsSystem;
 import org.apache.spark.k8s.operator.metrics.MetricsSystemFactory;
@@ -252,4 +265,98 @@ class SparkOperatorTest {
       setConfigKey(SparkOperatorConf.DYNAMIC_CONFIG_SOURCE, 
dynamicConfigSource);
     }
   }
+
+  @Test
+  void testLogConfRedactsSensitiveValues() {
+    MetricsSystem mockMetricsSystem = mock(MetricsSystem.class);
+    KubernetesClient mockClient = mock(KubernetesClient.class);
+    TestLogAppender appender = installAppender();
+    try (MockedStatic<MetricsSystemFactory> mockMetricsSystemFactory =
+            mockStatic(MetricsSystemFactory.class);
+        MockedStatic<KubernetesClientFactory> mockKubernetesClientFactory =
+            mockStatic(KubernetesClientFactory.class);
+        MockedConstruction<Operator> operatorConstruction = 
mockConstruction(Operator.class);
+        MockedConstruction<SparkAppReconciler> sparkAppReconcilerConstruction =
+            mockConstruction(SparkAppReconciler.class);
+        MockedConstruction<ProbeService> probeServiceConstruction =
+            mockConstruction(ProbeService.class);
+        MockedConstruction<MetricsService> metricsServiceConstruction =
+            mockConstruction(MetricsService.class);
+        MockedConstruction<KubernetesMetricsInterceptor> 
interceptorMockedConstruction =
+            mockConstruction(KubernetesMetricsInterceptor.class)) {
+      setConfigKey(SparkOperatorConf.LOG_CONF, true);
+      SparkOperatorConfManager.INSTANCE.refresh(
+          Map.of("spark.dummy.db.password", "super-sensitive-value"));
+      mockMetricsSystemFactory
+          .when(MetricsSystemFactory::createMetricsSystem)
+          .thenReturn(mockMetricsSystem);
+      mockKubernetesClientFactory
+          .when(() -> KubernetesClientFactory.buildKubernetesClient(any()))
+          .thenReturn(mockClient);
+
+      SparkOperator sparkOperator = new SparkOperator();
+      Assertions.assertNotNull(sparkOperator);
+      Assertions.assertEquals(1, operatorConstruction.constructed().size());
+      Assertions.assertEquals(1, 
sparkAppReconcilerConstruction.constructed().size());
+      Assertions.assertEquals(1, 
probeServiceConstruction.constructed().size());
+      Assertions.assertEquals(1, 
metricsServiceConstruction.constructed().size());
+      Assertions.assertEquals(1, 
interceptorMockedConstruction.constructed().size());
+
+      String redactedText = 
org.apache.spark.util.Utils$.MODULE$.REDACTION_REPLACEMENT_TEXT();
+      Assertions.assertTrue(
+          appender.messages.stream()
+              .anyMatch(m -> m.equals("spark.dummy.db.password = " + 
redactedText)),
+          "Expected redacted log for the sensitive key, got: " + 
appender.messages);
+      Assertions.assertTrue(
+          appender.messages.stream().noneMatch(m -> 
m.contains("super-sensitive-value")),
+          "Sensitive value must not appear in operator logs");
+    } finally {
+      setConfigKey(SparkOperatorConf.LOG_CONF, false);
+      SparkOperatorConfManager.INSTANCE.refresh(Map.of());
+      removeAppender(appender);
+    }
+  }
+
+  private static TestLogAppender installAppender() {
+    TestLogAppender appender = new 
TestLogAppender("SparkOperatorTestLogAppender");
+    appender.start();
+    LoggerContext ctx = (LoggerContext) LogManager.getContext(false);
+    Configuration config = ctx.getConfiguration();
+    // The test log4j2 configuration has a global WARN threshold filter; 
detach it so that the
+    // INFO level configuration logging can reach the appender.
+    appender.removedGlobalFilter = config.getFilter();
+    if (appender.removedGlobalFilter != null) {
+      config.removeFilter(appender.removedGlobalFilter);
+    }
+    config.getRootLogger().addAppender(appender, Level.ALL, null);
+    ctx.updateLoggers();
+    return appender;
+  }
+
+  private static void removeAppender(TestLogAppender appender) {
+    LoggerContext ctx = (LoggerContext) LogManager.getContext(false);
+    Configuration config = ctx.getConfiguration();
+    config.getRootLogger().removeAppender(appender.getName());
+    if (appender.removedGlobalFilter != null) {
+      config.addFilter(appender.removedGlobalFilter);
+    }
+    ctx.updateLoggers();
+    appender.stop();
+  }
+
+  private static final class TestLogAppender extends AbstractAppender {
+    private final List<String> messages = new CopyOnWriteArrayList<>();
+    private Filter removedGlobalFilter;
+
+    private TestLogAppender(String name) {
+      super(name, null, PatternLayout.createDefaultLayout(), false, 
Property.EMPTY_ARRAY);
+    }
+
+    @Override
+    public void append(LogEvent event) {
+      if (SparkOperator.class.getName().equals(event.getLoggerName())) {
+        messages.add(event.getMessage().getFormattedMessage());
+      }
+    }
+  }
 }
diff --git 
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/utils/UtilsTest.java
 
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/utils/UtilsTest.java
new file mode 100644
index 0000000..40e7e50
--- /dev/null
+++ 
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/utils/UtilsTest.java
@@ -0,0 +1,108 @@
+/*
+ * 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.
+ */
+
+package org.apache.spark.k8s.operator.utils;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.Map;
+import java.util.Properties;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import org.apache.spark.k8s.operator.config.SparkOperatorConf;
+import org.apache.spark.k8s.operator.config.SparkOperatorConfManager;
+
+class UtilsTest {
+  private static final String REDACTED_TEXT =
+      org.apache.spark.util.Utils$.MODULE$.REDACTION_REPLACEMENT_TEXT();
+  private static final String REDACTION_REGEX_KEY = 
SparkOperatorConf.REDACTION_REGEX.getKey();
+
+  @Test
+  void redactionRegexConfigReusesSparkRedactionConfig() {
+    assertEquals(
+        
org.apache.spark.internal.config.package$.MODULE$.SECRET_REDACTION_PATTERN().key(),
+        SparkOperatorConf.REDACTION_REGEX.getKey());
+    assertEquals(
+        org.apache.spark.internal.config.package$
+            .MODULE$
+            .SECRET_REDACTION_PATTERN()
+            .defaultValueString(),
+        SparkOperatorConf.REDACTION_REGEX.getDefaultValue());
+  }
+
+  @AfterEach
+  void resetConfigOverrides() {
+    SparkOperatorConfManager.INSTANCE.refresh(Map.of());
+  }
+
+  @Test
+  void redactSensitiveInfoMasksSensitiveKeysWithSparkDefaultPattern() {
+    Map<String, String> props =
+        Map.of(
+            "spark.hadoop.fs.s3a.access.key", "sensitive-access-key",
+            "spark.ssl.keyStorePassword", "sensitive-keystore-value",
+            "my.service.token", "sensitive-service-value",
+            "custom.api.secret", "sensitive-api-value",
+            "spark.kubernetes.operator.name", "spark-kubernetes-operator");
+    Map<String, String> redacted = Utils.redactSensitiveInfo(props);
+    assertEquals(REDACTED_TEXT, 
redacted.get("spark.hadoop.fs.s3a.access.key"));
+    assertEquals(REDACTED_TEXT, redacted.get("spark.ssl.keyStorePassword"));
+    assertEquals(REDACTED_TEXT, redacted.get("my.service.token"));
+    assertEquals(REDACTED_TEXT, redacted.get("custom.api.secret"));
+    assertEquals("spark-kubernetes-operator", 
redacted.get("spark.kubernetes.operator.name"));
+    assertEquals(props.keySet(), redacted.keySet());
+  }
+
+  @Test
+  void redactSensitiveInfoAcceptsProperties() {
+    Properties props = new Properties();
+    props.setProperty("my.db.password", "sensitive-db-value");
+    props.setProperty("spark.kubernetes.operator.namespace", "default");
+    Map<String, String> redacted = Utils.redactSensitiveInfo(props);
+    assertEquals(REDACTED_TEXT, redacted.get("my.db.password"));
+    assertEquals("default", 
redacted.get("spark.kubernetes.operator.namespace"));
+  }
+
+  @Test
+  void redactSensitiveInfoHonorsRedactionRegexFromGivenProperties() {
+    Map<String, String> props =
+        Map.of(
+            REDACTION_REGEX_KEY, "(?i)confidential",
+            "custom.confidential.conf", "custom-value",
+            "spark.kubernetes.operator.namespace", "default");
+    Map<String, String> redacted = Utils.redactSensitiveInfo(props);
+    assertEquals(REDACTED_TEXT, redacted.get("custom.confidential.conf"));
+    assertEquals("default", 
redacted.get("spark.kubernetes.operator.namespace"));
+  }
+
+  @Test
+  void redactSensitiveInfoHonorsRedactionRegexFromOperatorConfig() {
+    SparkOperatorConfManager.INSTANCE.refresh(Map.of(REDACTION_REGEX_KEY, 
"(?i)confidential"));
+    Map<String, String> props =
+        Map.of(
+            "custom.confidential.conf", "custom-value",
+            "spark.kubernetes.operator.namespace", "default");
+    Map<String, String> redacted = Utils.redactSensitiveInfo(props);
+    assertEquals(REDACTED_TEXT, redacted.get("custom.confidential.conf"));
+    assertEquals("default", 
redacted.get("spark.kubernetes.operator.namespace"));
+    assertEquals(props.keySet(), redacted.keySet());
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to