diff --git a/CHANGELOG.md b/CHANGELOG.md index 19325579e..1f12c8435 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,3 +16,4 @@ - [feat: support audit log](https://github.com/Tencent/spring-cloud-tencent/pull/1812) - [feat: support config effective value ](https://github.com/Tencent/spring-cloud-tencent/pull/1815) - [feat: support encoding and decoding TSF headers without TSF Consul](https://github.com/Tencent/spring-cloud-tencent/pull/1816) +- [feat: mask config values of encrypted config files in logs](https://github.com/Tencent/spring-cloud-tencent/pull/1817) diff --git a/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/adapter/PolarisConfigPropertyAutoRefresher.java b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/adapter/PolarisConfigPropertyAutoRefresher.java index a829dbbcb..7acaba94f 100644 --- a/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/adapter/PolarisConfigPropertyAutoRefresher.java +++ b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/adapter/PolarisConfigPropertyAutoRefresher.java @@ -17,6 +17,10 @@ package com.tencent.cloud.polaris.config.adapter; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; import java.time.LocalDateTime; import java.util.HashMap; import java.util.HashSet; @@ -66,6 +70,24 @@ public abstract class PolarisConfigPropertyAutoRefresher implements ApplicationL private static final Logger LOGGER = LoggerFactory.getLogger(PolarisConfigPropertyAutoRefresher.class); private static final Set registeredPolarisPropertySets = Sets.newConcurrentHashSet(); + /** + * Property keys contributed by encrypted config files. Values of these keys must never be + * written to logs in plain text. + *

+ * Grow-only on purpose: once a key is known to be sensitive, keep masking it even after the + * encrypted file drops it. Over-masking only costs troubleshooting convenience, while + * under-masking is a leak. + */ + private static final Set encryptedPropertyKeys = Sets.newConcurrentHashSet(); + private static final String FINGERPRINT_ALGORITHM = "SHA-256"; + /** + * Number of digest bytes kept in a fingerprint. 4 bytes are enough to tell two values apart. + */ + private static final int FINGERPRINT_BYTES = 4; + /** + * Random per JVM: see {@link #fingerprint(String)} for why the digest must be salted. + */ + private static final byte[] FINGERPRINT_SALT = newFingerprintSalt(); private final PolarisConfigProperties polarisConfigProperties; private final AtomicBoolean registered = new AtomicBoolean(false); // this class provides customized logic for some customers to configure special business group files @@ -191,6 +213,10 @@ public abstract class PolarisConfigPropertyAutoRefresher implements ApplicationL LOGGER.info("[SCT Config] received polaris config change event and will refresh spring context." + " namespace = {}, group = {}, fileName = {}", listenPolarisPropertySource.getNamespace(), listenPolarisPropertySource.getGroup(), listenPolarisPropertySource.getFileName()); + // the change event is the only place carrying the plugin-level ConfigFile, + // which is where the server-side per-file encrypted flag can be read + markEncryptedKeys(listenPolarisPropertySource.getConfigKVFile(), configKVFileChangeEvent.getConfigFile()); + Map effectSource = effectPolarisPropertySource.getSource(); Map listenSource = listenPolarisPropertySource.getSource(); boolean isGroupRefresh = !listenPolarisPropertySource.equals(effectPolarisPropertySource); @@ -204,7 +230,15 @@ public abstract class PolarisConfigPropertyAutoRefresher implements ApplicationL for (String changedKey : configKVFileChangeEvent.changedKeys()) { ConfigPropertyChangeInfo configPropertyChangeInfo = configKVFileChangeEvent.getChangeInfo(changedKey); - LOGGER.info("[SCT Config] changed property = {}", configPropertyChangeInfo); + if (isEncryptedKey(changedKey)) { + LOGGER.info("[SCT Config] changed property = [key={}, changeType={}, oldValue={}, newValue={}]", + configPropertyChangeInfo.getPropertyName(), configPropertyChangeInfo.getChangeType(), + maskValue(configPropertyChangeInfo.getOldValue()), + maskValue(configPropertyChangeInfo.getNewValue())); + } + else { + LOGGER.info("[SCT Config] changed property = {}", configPropertyChangeInfo); + } // new ability to dynamically change log levels try { @@ -267,6 +301,96 @@ public abstract class PolarisConfigPropertyAutoRefresher implements ApplicationL polarisConfigCustomExtensionLayer.executeRegisterPublishChangeListener(listenPolarisPropertySource, effectPolarisPropertySource); } + /** + * Registers the property keys of an encrypted config file, so that their values can be masked + * in logs afterwards. + *

+ * {@code configFile} must come from {@link ConfigKVFileChangeEvent#getConfigFile()}: that + * object belongs to the response chain, where {@code encrypted} is the per-file value pushed + * by the server. The request-side object is not usable as a criterion, because the crypto + * filter unconditionally sets it to true to declare crypto support. + * + * @param kvFile the config file whose property names will be registered + * @param configFile the plugin-level config file carrying the encrypted flag, may be null + */ + private void markEncryptedKeys(ConfigKVFile kvFile, ConfigFile configFile) { + if (kvFile == null || configFile == null || !configFile.isEncrypted()) { + return; + } + Set propertyNames = kvFile.getPropertyNames(); + if (!CollectionUtils.isEmpty(propertyNames)) { + encryptedPropertyKeys.addAll(propertyNames); + } + } + + /** + * @param key the property key + * @return whether the value of the given key comes from an encrypted config file + */ + protected boolean isEncryptedKey(String key) { + return encryptedPropertyKeys.contains(key); + } + + /** + * Masks a property value of an encrypted config file. The length and a fingerprint are kept as + * hints for troubleshooting, the content is not exposed. + *

+ * Takes an Object rather than a String: both {@code ConfigPropertyChangeInfo#getOldValue()} + * and the resolved {@code @Value} result are declared as Object. + * + * @param value the raw value + * @return the masked value + */ + protected static String maskValue(Object value) { + if (value == null) { + return null; + } + String text = String.valueOf(value); + if (text.isEmpty()) { + return ""; + } + return "***(len=" + text.length() + ", fp=" + fingerprint(text) + ")"; + } + + /** + * Salted and truncated digest of a value, so that two masked values can be told apart even when + * their lengths are equal (e.g. an old and a new password of the same length). + *

+ * The salt is random per JVM on purpose. An unsalted digest of a single config value would be + * reversible by dictionary attack, since config values carry little entropy - that would defeat + * the masking. With a per-process salt the fingerprint stays comparable within one log file, + * which is what change diagnosis needs, and carries no information outside it. + *

+ * Truncated to 4 bytes: a collision only makes two different values look alike, it never + * exposes a value. + * + * @param text the raw value + * @return an 8-char hex fingerprint + */ + private static byte[] newFingerprintSalt() { + byte[] salt = new byte[16]; + new SecureRandom().nextBytes(salt); + return salt; + } + + private static String fingerprint(String text) { + try { + MessageDigest digest = MessageDigest.getInstance(FINGERPRINT_ALGORITHM); + digest.update(FINGERPRINT_SALT); + byte[] hash = digest.digest(text.getBytes(StandardCharsets.UTF_8)); + StringBuilder builder = new StringBuilder(FINGERPRINT_BYTES * 2); + for (int i = 0; i < FINGERPRINT_BYTES; i++) { + builder.append(Character.forDigit((hash[i] >> 4) & 0xF, 16)); + builder.append(Character.forDigit(hash[i] & 0xF, 16)); + } + return builder.toString(); + } + catch (NoSuchAlgorithmException e) { + // SHA-256 is mandated by the JDK spec, so this is unreachable in practice + return "unavailable"; + } + } + private Map calculateUnregister(List oldConfigFileMetadataList, List newConfigFileMetadataList) { @@ -334,4 +458,12 @@ public abstract class PolarisConfigPropertyAutoRefresher implements ApplicationL public void setRegistered(boolean registered) { this.registered.set(registered); } + + /** + * Just for junit test. {@code encryptedPropertyKeys} is static and grow-only, so it has to be + * reset between test methods. + */ + public static void clearEncryptedPropertyKeys() { + encryptedPropertyKeys.clear(); + } } diff --git a/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/adapter/PolarisRefreshAffectedContextRefresher.java b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/adapter/PolarisRefreshAffectedContextRefresher.java index 9211e696d..a7e3d8305 100644 --- a/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/adapter/PolarisRefreshAffectedContextRefresher.java +++ b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/adapter/PolarisRefreshAffectedContextRefresher.java @@ -79,7 +79,7 @@ public class PolarisRefreshAffectedContextRefresher extends PolarisConfigPropert } // update the attribute with @Value annotation for (SpringValue val : targetValues) { - updateSpringValue(val); + updateSpringValue(changedKey, val); } } @@ -101,15 +101,20 @@ public class PolarisRefreshAffectedContextRefresher extends PolarisConfigPropert } } - private void updateSpringValue(SpringValue springValue) { + private void updateSpringValue(String changedKey, SpringValue springValue) { try { Object value = resolvePropertyValue(springValue); springValue.update(value); - LOGGER.info("[SCT Config] Auto update polaris changed value successfully, new value: {}, {}", value, + // values of encrypted config files must not be logged in plain text + Object displayValue = isEncryptedKey(changedKey) ? maskValue(value) : value; + LOGGER.info("[SCT Config] Auto update polaris changed value successfully, new value: {}, {}", displayValue, springValue); } catch (Throwable ex) { + // SpringValue.toString() carries no property value, so it is safe to log as is. + // The stack trace may still embed the raw value (e.g. unresolvable placeholder), + // which is kept on purpose: losing it would make refresh failures undiagnosable. LOGGER.error("[SCT Config] Auto update polaris changed value failed, {}", springValue.toString(), ex); } } diff --git a/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/utils/PolarisPropertySourceUtils.java b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/utils/PolarisPropertySourceUtils.java index 5f9ae8404..4997486cb 100644 --- a/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/utils/PolarisPropertySourceUtils.java +++ b/spring-cloud-starter-tencent-polaris-config/src/main/java/com/tencent/cloud/polaris/config/utils/PolarisPropertySourceUtils.java @@ -82,9 +82,10 @@ public final class PolarisPropertySourceUtils { } if (LOGGER.isDebugEnabled()) { - LOGGER.debug("namespace='" + namespace + '\'' - + ", group='" + group + '\'' + ", fileName='" + compositeConfigFile + '\'' - + ", map='" + map + '\''); + // only coordinates and key names: values of encrypted config files must not be logged. + // This method cannot tell whether the group holds encrypted files, so no value is logged at all. + LOGGER.debug("[SCT Config] load group property source. namespace = {}, group = {}, propertyCount = {}, keys = {}", + namespace, group, map.size(), map.keySet()); } return new PolarisPropertySource(namespace, group, "", compositeConfigFile, map); diff --git a/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/adapter/PolarisConfigSensitiveDataMaskingTest.java b/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/adapter/PolarisConfigSensitiveDataMaskingTest.java new file mode 100644 index 000000000..fd2a615c9 --- /dev/null +++ b/spring-cloud-starter-tencent-polaris-config/src/test/java/com/tencent/cloud/polaris/config/adapter/PolarisConfigSensitiveDataMaskingTest.java @@ -0,0 +1,425 @@ +/* + * Tencent is pleased to support the open source community by making spring-cloud-tencent available. + * + * Copyright (C) 2021 Tencent. All rights reserved. + * + * Licensed under the BSD 3-Clause License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/BSD-3-Clause + * + * 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 com.tencent.cloud.polaris.config.adapter; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.tencent.cloud.polaris.config.config.PolarisConfigProperties; +import com.tencent.cloud.polaris.config.spring.property.PlaceholderHelper; +import com.tencent.cloud.polaris.config.spring.property.SpringValue; +import com.tencent.cloud.polaris.config.spring.property.SpringValueRegistry; +import com.tencent.cloud.polaris.config.utils.PolarisPropertySourceUtils; +import com.tencent.polaris.api.plugin.common.ValueContext; +import com.tencent.polaris.api.plugin.compose.Extensions; +import com.tencent.polaris.api.plugin.configuration.ConfigFile; +import com.tencent.polaris.client.api.SDKContext; +import com.tencent.polaris.configuration.api.core.ChangeType; +import com.tencent.polaris.configuration.api.core.ConfigFileService; +import com.tencent.polaris.configuration.api.core.ConfigKVFileChangeEvent; +import com.tencent.polaris.configuration.api.core.ConfigPropertyChangeInfo; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.slf4j.LoggerFactory; + +import org.springframework.beans.TypeConverter; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.cloud.context.refresh.ContextRefresher; +import org.springframework.context.ConfigurableApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Test for sensitive data masking of encrypted config files. + * + *

Covers the log-masking behaviour of {@link PolarisConfigPropertyAutoRefresher}, + * {@link PolarisRefreshAffectedContextRefresher} and {@link PolarisPropertySourceUtils}. + * + * @author evelynwei + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class PolarisConfigSensitiveDataMaskingTest { + + private static final String SENSITIVE_VALUE = "root-password-1234"; + + private static final String PLAIN_VALUE = "plain-value"; + + private final String testNamespace = "testNamespace"; + + private final String testFileGroup = "testFileGroup"; + + private final String testFileName = "application.properties"; + + @Mock + private PolarisConfigProperties polarisConfigProperties; + + @Mock + private SpringValueRegistry springValueRegistry; + + @Mock + private PlaceholderHelper placeholderHelper; + + @Mock + private ConfigFileService configFileService; + + @Mock + private ContextRefresher contextRefresher; + + @Mock + private SDKContext sdkContext; + + @Mock + private Extensions extensions; + + @Mock + private ValueContext valueContext; + + private final List> appenders = new ArrayList<>(); + + @BeforeEach + public void setUp() { + PolarisPropertySourceManager.clearPropertySources(); + PolarisConfigPropertyAutoRefresher.clearEncryptedPropertyKeys(); + } + + @AfterEach + public void tearDown() { + PolarisConfigPropertyAutoRefresher.clearEncryptedPropertyKeys(); + for (ListAppender appender : appenders) { + appender.stop(); + } + appenders.clear(); + } + + /** + * Item 5: maskValue keeps the length and fingerprint hints and never exposes the content. + */ + @Test + public void testMaskValue() { + assertThat(PolarisConfigPropertyAutoRefresher.maskValue(null)).isNull(); + assertThat(PolarisConfigPropertyAutoRefresher.maskValue("")).isEmpty(); + assertThat(PolarisConfigPropertyAutoRefresher.maskValue(SENSITIVE_VALUE)) + .matches("\\*\\*\\*\\(len=" + SENSITIVE_VALUE.length() + ", fp=[0-9a-f]{8}\\)") + .doesNotContain(SENSITIVE_VALUE); + // non-String Object must not blow up + assertThat(PolarisConfigPropertyAutoRefresher.maskValue(12345)).matches("\\*\\*\\*\\(len=5, fp=[0-9a-f]{8}\\)"); + assertThat(PolarisConfigPropertyAutoRefresher.maskValue(Boolean.TRUE)) + .matches("\\*\\*\\*\\(len=4, fp=[0-9a-f]{8}\\)"); + } + + /** + * Item 10: the fingerprint is stable for the same value and differs for different values of the + * same length. This is what makes an old/new pair distinguishable when their lengths match. + */ + @Test + public void testFingerprintDistinguishesEqualLengthValues() { + String oldValue = "passwordAAAA"; + String newValue = "passwordBBBB"; + assertThat(oldValue).hasSameSizeAs(newValue); + + String maskedOld = PolarisConfigPropertyAutoRefresher.maskValue(oldValue); + String maskedNew = PolarisConfigPropertyAutoRefresher.maskValue(newValue); + + assertThat(maskedOld).isNotEqualTo(maskedNew); + // stable within the same process, so repeated logging of one value reads consistently + assertThat(PolarisConfigPropertyAutoRefresher.maskValue(oldValue)).isEqualTo(maskedOld); + } + + /** + * Item 3: keys of an encrypted file are registered, keys of a plain file are not. + */ + @Test + public void testEncryptedKeysRegisteredOnlyForEncryptedFile() { + PolarisConfigPropertyAutoRefresher refresher = buildRefresher(); + + MockedConfigKVFile encryptedFile = new MockedConfigKVFile(contentOf("encrypted.key", SENSITIVE_VALUE)); + fireChange(refresher, encryptedFile, encryptedConfigFile(), "encrypted.key", + new ConfigPropertyChangeInfo("encrypted.key", "old", SENSITIVE_VALUE, ChangeType.MODIFIED)); + assertThat(refresher.isEncryptedKey("encrypted.key")).isTrue(); + + MockedConfigKVFile plainFile = new MockedConfigKVFile(contentOf("plain.key", PLAIN_VALUE)); + fireChange(refresher, plainFile, plainConfigFile(), "plain.key", + new ConfigPropertyChangeInfo("plain.key", "old", PLAIN_VALUE, ChangeType.MODIFIED)); + assertThat(refresher.isEncryptedKey("plain.key")).isFalse(); + } + + /** + * Item 4: a null ConfigFile (ConfigKVFileChangeEvent#getConfigFile may be null) must neither + * throw nor register anything. + */ + @Test + public void testNullConfigFileNeitherThrowsNorRegisters() { + PolarisConfigPropertyAutoRefresher refresher = buildRefresher(); + + MockedConfigKVFile file = new MockedConfigKVFile(contentOf("some.key", PLAIN_VALUE)); + fireChange(refresher, file, null, "some.key", + new ConfigPropertyChangeInfo("some.key", "old", PLAIN_VALUE, ChangeType.MODIFIED)); + + assertThat(refresher.isEncryptedKey("some.key")).isFalse(); + } + + /** + * Item 1: the change log of an encrypted config file carries no raw value. + */ + @Test + public void testEncryptedConfigChangeLogIsMasked() { + PolarisConfigPropertyAutoRefresher refresher = buildRefresher(); + ListAppender appender = attachAppender(PolarisConfigPropertyAutoRefresher.class); + + MockedConfigKVFile file = new MockedConfigKVFile(contentOf("db.password", SENSITIVE_VALUE)); + fireChange(refresher, file, encryptedConfigFile(), "db.password", + new ConfigPropertyChangeInfo("db.password", SENSITIVE_VALUE, SENSITIVE_VALUE + "-new", + ChangeType.MODIFIED)); + + String logs = renderLogs(appender); + assertThat(logs).doesNotContain(SENSITIVE_VALUE); + // key and change type stay observable for troubleshooting + assertThat(logs).contains("db.password").contains("MODIFIED").contains("***(len="); + } + + /** + * Item 2: a plain config file keeps the original behaviour and logs the raw value. + */ + @Test + public void testPlainConfigChangeLogKeepsRawValue() { + PolarisConfigPropertyAutoRefresher refresher = buildRefresher(); + ListAppender appender = attachAppender(PolarisConfigPropertyAutoRefresher.class); + + MockedConfigKVFile file = new MockedConfigKVFile(contentOf("app.name", PLAIN_VALUE)); + fireChange(refresher, file, plainConfigFile(), "app.name", + new ConfigPropertyChangeInfo("app.name", "old-name", PLAIN_VALUE, ChangeType.MODIFIED)); + + String logs = renderLogs(appender); + assertThat(logs).contains(PLAIN_VALUE).doesNotContain("***(len="); + } + + /** + * Item 6: updateSpringValue masks the new value when the changed key is encrypted, and keeps it + * as is otherwise. + */ + @Test + public void testSpringValueRefreshLogRespectsEncryptedKey() throws Exception { + PolarisRefreshAffectedContextRefresher refresher = buildAffectedRefresher(SENSITIVE_VALUE); + ListAppender appender = attachAppender(PolarisRefreshAffectedContextRefresher.class); + + MockedConfigKVFile encryptedFile = new MockedConfigKVFile(contentOf("db.password", SENSITIVE_VALUE)); + fireChange(refresher, encryptedFile, encryptedConfigFile(), "db.password", + new ConfigPropertyChangeInfo("db.password", "old", SENSITIVE_VALUE, ChangeType.MODIFIED)); + + String logs = renderLogs(appender); + assertThat(logs).contains("Auto update polaris changed value successfully"); + assertThat(logs).doesNotContain(SENSITIVE_VALUE); + assertThat(logs).contains("***(len=" + SENSITIVE_VALUE.length() + ", fp="); + } + + @Test + public void testSpringValueRefreshLogKeepsPlainValue() throws Exception { + PolarisRefreshAffectedContextRefresher refresher = buildAffectedRefresher(PLAIN_VALUE); + ListAppender appender = attachAppender(PolarisRefreshAffectedContextRefresher.class); + + MockedConfigKVFile plainFile = new MockedConfigKVFile(contentOf("app.name", PLAIN_VALUE)); + fireChange(refresher, plainFile, plainConfigFile(), "app.name", + new ConfigPropertyChangeInfo("app.name", "old", PLAIN_VALUE, ChangeType.MODIFIED)); + + String logs = renderLogs(appender); + assertThat(logs).contains(PLAIN_VALUE).doesNotContain("***(len="); + } + + /** + * Item 9: SpringValue#toString carries no property value, which is why the refresh failure log + * needs no masking. + */ + @Test + public void testSpringValueToStringCarriesNoValue() throws Exception { + MockedConfigChange bean = new MockedConfigChange(); + bean.setK1(SENSITIVE_VALUE); + Field field = bean.getClass().getDeclaredField("k1"); + SpringValue springValue = new SpringValue("db.password", "${db.password}", bean, "mockedConfigChange", field); + + assertThat(springValue.toString()) + .doesNotContain(SENSITIVE_VALUE) + .contains("db.password") + .contains("mockedConfigChange"); + } + + /** + * Item 7: the group-dimension DEBUG log carries key names only, never values. + */ + @Test + public void testGroupPropertySourceDebugLogCarriesNoValue() { + ch.qos.logback.classic.Logger logger = + (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(PolarisPropertySourceUtils.class); + Level originalLevel = logger.getLevel(); + logger.setLevel(Level.DEBUG); + ListAppender appender = attachAppender(PolarisPropertySourceUtils.class); + try { + MockedConfigKVFile file = new MockedConfigKVFile(contentOf("db.password", SENSITIVE_VALUE), + testFileName, testFileGroup, testNamespace); + com.tencent.polaris.configuration.api.core.ConfigFileGroup group = + new com.tencent.polaris.configuration.client.internal.RevisableConfigFileGroup( + testNamespace, testFileGroup, java.util.Collections.singletonList(file), "v1"); + when(configFileService.getConfigFileGroup(testNamespace, testFileGroup)).thenReturn(group); + when(configFileService.getConfigPropertiesFile(testNamespace, testFileGroup, testFileName)) + .thenReturn(file); + + PolarisPropertySource source = PolarisPropertySourceUtils + .loadGroupPolarisPropertySource(configFileService, testNamespace, testFileGroup); + + assertThat(source).isNotNull(); + String logs = renderLogs(appender); + assertThat(logs).doesNotContain(SENSITIVE_VALUE); + assertThat(logs).contains("db.password").contains("propertyCount = 1"); + } + finally { + logger.setLevel(originalLevel); + } + } + + /** + * Item 8: locks the known boundary of the grow-only key set. A key that has never been seen in + * a file-dimension change event is not masked on its first refresh; once registered it stays + * masked even when the very same key later arrives from a plain file. + */ + @Test + public void testGrowOnlyKeySetBoundary() { + PolarisConfigPropertyAutoRefresher refresher = buildRefresher(); + + // never seen before -> not masked + assertThat(refresher.isEncryptedKey("shared.key")).isFalse(); + + MockedConfigKVFile encryptedFile = new MockedConfigKVFile(contentOf("shared.key", SENSITIVE_VALUE)); + fireChange(refresher, encryptedFile, encryptedConfigFile(), "shared.key", + new ConfigPropertyChangeInfo("shared.key", "old", SENSITIVE_VALUE, ChangeType.MODIFIED)); + assertThat(refresher.isEncryptedKey("shared.key")).isTrue(); + + // a later plain file carrying the same key does not un-register it + MockedConfigKVFile plainFile = new MockedConfigKVFile(contentOf("shared.key", PLAIN_VALUE)); + fireChange(refresher, plainFile, plainConfigFile(), "shared.key", + new ConfigPropertyChangeInfo("shared.key", SENSITIVE_VALUE, PLAIN_VALUE, ChangeType.MODIFIED)); + assertThat(refresher.isEncryptedKey("shared.key")).isTrue(); + } + + /** + * The affected-context refresher is used throughout: it is the default implementation and the + * only one whose {@code refreshConfigurationProperties} works against a mocked context. + */ + private PolarisConfigPropertyAutoRefresher buildRefresher() { + try { + return buildAffectedRefresher(PLAIN_VALUE); + } + catch (Exception e) { + throw new IllegalStateException("failed to build refresher", e); + } + } + + private PolarisRefreshAffectedContextRefresher buildAffectedRefresher(String resolvedValue) throws Exception { + when(polarisConfigProperties.isAutoRefresh()).thenReturn(true); + when(sdkContext.getExtensions()).thenReturn(extensions); + when(extensions.getValueContext()).thenReturn(valueContext); + when(valueContext.getClientId()).thenReturn("mockClientId"); + when(valueContext.getHost()).thenReturn("mockHost"); + + PolarisRefreshAffectedContextRefresher refresher = new PolarisRefreshAffectedContextRefresher( + polarisConfigProperties, springValueRegistry, placeholderHelper, configFileService, contextRefresher, + sdkContext); + + ConfigurableApplicationContext applicationContext = mock(ConfigurableApplicationContext.class); + ConfigurableListableBeanFactory beanFactory = mock(ConfigurableListableBeanFactory.class); + TypeConverter typeConverter = mock(TypeConverter.class); + when(beanFactory.getTypeConverter()).thenReturn(typeConverter); + when(applicationContext.getBeanFactory()).thenReturn(beanFactory); + refresher.setApplicationContext(applicationContext); + when(typeConverter.convertIfNecessary(any(), any(), (Field) any())).thenReturn(resolvedValue); + + MockedConfigChange bean = new MockedConfigChange(); + Field field = bean.getClass().getDeclaredField("k1"); + Collection springValues = new ArrayList<>(); + springValues.add(new SpringValue("db.password", "${db.password}", bean, "mockedConfigChange", field)); + when(springValueRegistry.get(any(), any())).thenReturn(springValues); + + return refresher; + } + + /** + * Registers the property source, wires the change listener and fires one change event. + */ + private void fireChange(PolarisConfigPropertyAutoRefresher refresher, MockedConfigKVFile file, + ConfigFile configFile, String changedKey, ConfigPropertyChangeInfo changeInfo) { + Map source = new HashMap<>(contentOf(changedKey, changeInfo.getOldValue())); + PolarisPropertySource propertySource = new PolarisPropertySource(file.getNamespace(), file.getFileGroup(), + file.getFileName(), file, source); + refresher.registerPolarisConfigPublishChangeListener(propertySource); + + Map changeInfos = new HashMap<>(); + changeInfos.put(changedKey, changeInfo); + file.fireChangeListener(new ConfigKVFileChangeEvent(changeInfos, configFile)); + } + + private ConfigFile encryptedConfigFile() { + ConfigFile configFile = new ConfigFile(testNamespace, testFileGroup, testFileName); + configFile.setEncrypted(true); + return configFile; + } + + private ConfigFile plainConfigFile() { + ConfigFile configFile = new ConfigFile(testNamespace, testFileGroup, testFileName); + configFile.setEncrypted(false); + return configFile; + } + + private Map contentOf(String key, Object value) { + Map content = new HashMap<>(); + content.put(key, value); + return content; + } + + private ListAppender attachAppender(Class loggerClass) { + ch.qos.logback.classic.Logger logger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(loggerClass); + ListAppender appender = new ListAppender<>(); + appender.setContext(logger.getLoggerContext()); + appender.start(); + logger.addAppender(appender); + appenders.add(appender); + return appender; + } + + private String renderLogs(ListAppender appender) { + StringBuilder builder = new StringBuilder(); + for (ILoggingEvent event : appender.list) { + builder.append(event.getFormattedMessage()).append('\n'); + } + return builder.toString(); + } +}