From c9717b4ed53417f20a8c35747d8797d2d0ea974b Mon Sep 17 00:00:00 2001 From: Stefan Miklosovic Date: Tue, 26 Aug 2025 16:17:24 +0200 Subject: [PATCH] Redact security-sensitive information in system_views.settings patch by Stefan Miklosovic; reviewed by Maxwell Guo, Francisco Guerrero for CASSANDRA-20856 --- CHANGES.txt | 4 ++ .../cassandra/config/EncryptionOptions.java | 4 ++ .../apache/cassandra/config/Properties.java | 21 ++++++++ .../org/apache/cassandra/config/Redacted.java | 42 +++++++++++++++ .../cassandra/db/virtual/SettingsTable.java | 22 ++++++++ .../db/virtual/SettingsTableTest.java | 54 +++++++++++++++++++ 6 files changed, 147 insertions(+) create mode 100644 src/java/org/apache/cassandra/config/Redacted.java diff --git a/CHANGES.txt b/CHANGES.txt index 6b239e6547..75f12f8f0b 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,7 @@ +4.1.11 + * Redact security-sensitive information in system_views.settings (CASSANDRA-20856) + + 4.1.10 * Improve CommitLogSegmentReader to skip SyncBlocks correctly in case of CRC errors (CASSANDRA-20664) * Do not crash on first boot with data_disk_usage_max_disk_size set when data directory is not created yet (CASSANDRA-20787) diff --git a/src/java/org/apache/cassandra/config/EncryptionOptions.java b/src/java/org/apache/cassandra/config/EncryptionOptions.java index a51e56551b..2380261dbc 100644 --- a/src/java/org/apache/cassandra/config/EncryptionOptions.java +++ b/src/java/org/apache/cassandra/config/EncryptionOptions.java @@ -74,11 +74,15 @@ public class EncryptionOptions * truststore_passwords configurations as they are in plaintext format. */ public final ParameterizedClass ssl_context_factory; + @Redacted public final String keystore; @Nullable + @Redacted public final String keystore_password; + @Redacted public final String truststore; @Nullable + @Redacted public final String truststore_password; public final List cipher_suites; protected String protocol; diff --git a/src/java/org/apache/cassandra/config/Properties.java b/src/java/org/apache/cassandra/config/Properties.java index 79852d4af6..b57b4a23d6 100644 --- a/src/java/org/apache/cassandra/config/Properties.java +++ b/src/java/org/apache/cassandra/config/Properties.java @@ -17,6 +17,7 @@ */ package org.apache.cassandra.config; +import java.lang.annotation.Annotation; import java.lang.reflect.Constructor; import java.util.ArrayDeque; import java.util.Collection; @@ -192,5 +193,25 @@ public final class Properties throw e; } } + + /** + * If there is a hierarchy of settings, like + *

+ * {@code a.b.c.{d,e,f,g,h}} + *

+ * and we put e.g. {@link Redacted} on {@code c}, + * then all {@code d,e,f,g,h} will be redacted as well automatically. + * This is handy for cases when we want to redact whole family of properties by one shot. + * + * @param aClass annotation to get + * @return found annotation of given type on root or on leaf, null when not present. + * @param type of annotation + */ + @Override + public A getAnnotation(Class aClass) + { + A annotation = root.getAnnotation(aClass); + return annotation != null ? annotation : leaf.getAnnotation(aClass); + } } } diff --git a/src/java/org/apache/cassandra/config/Redacted.java b/src/java/org/apache/cassandra/config/Redacted.java new file mode 100644 index 0000000000..5b832484cc --- /dev/null +++ b/src/java/org/apache/cassandra/config/Redacted.java @@ -0,0 +1,42 @@ +/* + * 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.cassandra.config; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation supposed to be placed on any field in Config (and its subproperties in embedded configuration classes) + * which will be redacted e.g. password or similar. + *

+ * User querying system_views.settings will be shown redacted values on such fields. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.FIELD }) +public @interface Redacted +{ + String REDACTED_STRING = ""; + + /** + * @return redacted value, defaults to {@code }. + */ + String redactedValue() default REDACTED_STRING; +} diff --git a/src/java/org/apache/cassandra/db/virtual/SettingsTable.java b/src/java/org/apache/cassandra/db/virtual/SettingsTable.java index 473cb88bb2..c4e7d95698 100644 --- a/src/java/org/apache/cassandra/db/virtual/SettingsTable.java +++ b/src/java/org/apache/cassandra/db/virtual/SettingsTable.java @@ -26,6 +26,7 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableMap; import org.apache.cassandra.config.Config; +import org.apache.cassandra.config.Redacted; import org.apache.cassandra.config.DatabaseDescriptor; import org.apache.cassandra.config.Loader; import org.apache.cassandra.config.Properties; @@ -89,6 +90,10 @@ final class SettingsTable extends AbstractVirtualTable @VisibleForTesting String getValue(Property prop) { + Redacted maybeCredential = prop.getAnnotation(Redacted.class); + if (maybeCredential != null) + return maybeCredential.redactedValue(); + Object value = prop.get(config); if (value == null) return null; @@ -96,6 +101,23 @@ final class SettingsTable extends AbstractVirtualTable if (value.getClass().isArray()) return Arrays.asList((Object[]) value).toString(); + if (value instanceof Map) + { + Map map = new HashMap<>(); + for (Map.Entry entry : ((Map) value).entrySet()) + { + // this is done on best-effort basis as we do not have names in parameters + // inherently under control as this is what a user is responsible for + // when dealing with custom implementations + if (entry.getKey().endsWith("_password") || entry.getKey().equals("password")) + map.put(entry.getKey(), Redacted.REDACTED_STRING); + else + map.put(entry.getKey(), entry.getValue()); + } + + return map.toString(); + } + return value.toString(); } diff --git a/test/unit/org/apache/cassandra/db/virtual/SettingsTableTest.java b/test/unit/org/apache/cassandra/db/virtual/SettingsTableTest.java index 32b85e24cc..7cf9aef1d8 100644 --- a/test/unit/org/apache/cassandra/db/virtual/SettingsTableTest.java +++ b/test/unit/org/apache/cassandra/db/virtual/SettingsTableTest.java @@ -18,8 +18,11 @@ package org.apache.cassandra.db.virtual; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.TreeMap; import java.util.stream.Collectors; import com.google.common.collect.ImmutableList; @@ -31,13 +34,18 @@ import org.junit.Test; import com.datastax.driver.core.ResultSet; import com.datastax.driver.core.Row; import org.apache.cassandra.config.Config; +import org.apache.cassandra.config.Redacted; +import org.apache.cassandra.config.DefaultLoader; import org.apache.cassandra.config.DurationSpec; import org.apache.cassandra.config.EncryptionOptions.ServerEncryptionOptions.InternodeEncryption; import org.apache.cassandra.config.ParameterizedClass; +import org.apache.cassandra.config.TransparentDataEncryptionOptions; import org.apache.cassandra.cql3.CQLTester; import org.apache.cassandra.security.SSLFactory; import org.yaml.snakeyaml.introspector.Property; +import static java.util.stream.Collectors.toMap; + public class SettingsTableTest extends CQLTester { private static final String KS_NAME = "vts"; @@ -63,6 +71,16 @@ public class SettingsTableTest extends CQLTester config.commitlog_sync_group_window = new DurationSpec.IntMillisecondsBound(0); config.credentials_update_interval = null; config.data_file_directories = new String[] {"/my/data/directory", "/another/data/directory"}; + + Map params = new LinkedHashMap<>(); + params.put("keystore_password", "password"); + params.put("key_password", "password"); + params.put("keystore", "conf/.keystore"); + config.transparent_data_encryption_options = new TransparentDataEncryptionOptions(false, + "AES/CBC/PKCS5Padding", + "alias", + new ParameterizedClass("SomeClass", + params)); table = new SettingsTable(KS_NAME, config); VirtualKeyspaceRegistry.instance.register(new VirtualKeyspace(KS_NAME, ImmutableList.of(table))); disablePreparedReuseForTest(); @@ -299,4 +317,40 @@ public class SettingsTableTest extends CQLTester config.transparent_data_encryption_options.iv_length = 7; check(pre + "iv_length", "7"); } + + @Test + public void testRedaction() throws Throwable + { + assertValue("transparent_data_encryption_options.key_provider.parameters", + String.format("{keystore_password=%s, keystore=conf/.keystore, key_password=%s}", + Redacted.REDACTED_STRING, + Redacted.REDACTED_STRING)); + + Set> entries = new DefaultLoader().flatten(Config.class) + .entrySet() + .stream() + .filter(e -> e.getValue().getAnnotation(Redacted.class) != null) + .collect(toMap(Map.Entry::getKey, Map.Entry::getValue, (e, r) -> e, TreeMap::new)) + .entrySet(); + + Assert.assertFalse(entries.isEmpty()); + + for (Map.Entry entry : entries) + { + logger.info("redacted {}", entry.getKey()); + assertValue(entry.getKey(), entry.getValue().getAnnotation(Redacted.class).redactedValue()); + } + } + + private void assertValue(String settingName, String expectedValue) throws Throwable + { + List all = executeNet(String.format("SELECT * from vts.settings WHERE name = '%s'", settingName)).all(); + Assert.assertFalse(all.isEmpty()); + Row row = all.get(0); + String name = row.getString("name"); + String value = row.getString("value"); + + Assert.assertEquals(settingName, name); + Assert.assertEquals(expectedValue, value); + } }