Keep plaintext out of refresh and group-load logs by tracking encrypted keys and replacing values with length plus a per-process salted fingerprint. Co-authored-by: Cursor <cursoragent@cursor.com>pull/1818/head
parent
49bae9ff89
commit
333d575215
@ -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.
|
||||
*
|
||||
* <p>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<ListAppender<ILoggingEvent>> appenders = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
PolarisPropertySourceManager.clearPropertySources();
|
||||
PolarisConfigPropertyAutoRefresher.clearEncryptedPropertyKeys();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void tearDown() {
|
||||
PolarisConfigPropertyAutoRefresher.clearEncryptedPropertyKeys();
|
||||
for (ListAppender<ILoggingEvent> 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<ILoggingEvent> 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<ILoggingEvent> 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<ILoggingEvent> 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<ILoggingEvent> 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<ILoggingEvent> 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<SpringValue> 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<String, Object> source = new HashMap<>(contentOf(changedKey, changeInfo.getOldValue()));
|
||||
PolarisPropertySource propertySource = new PolarisPropertySource(file.getNamespace(), file.getFileGroup(),
|
||||
file.getFileName(), file, source);
|
||||
refresher.registerPolarisConfigPublishChangeListener(propertySource);
|
||||
|
||||
Map<String, ConfigPropertyChangeInfo> 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<String, Object> contentOf(String key, Object value) {
|
||||
Map<String, Object> content = new HashMap<>();
|
||||
content.put(key, value);
|
||||
return content;
|
||||
}
|
||||
|
||||
private ListAppender<ILoggingEvent> attachAppender(Class<?> loggerClass) {
|
||||
ch.qos.logback.classic.Logger logger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(loggerClass);
|
||||
ListAppender<ILoggingEvent> appender = new ListAppender<>();
|
||||
appender.setContext(logger.getLoggerContext());
|
||||
appender.start();
|
||||
logger.addAppender(appender);
|
||||
appenders.add(appender);
|
||||
return appender;
|
||||
}
|
||||
|
||||
private String renderLogs(ListAppender<ILoggingEvent> appender) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (ILoggingEvent event : appender.list) {
|
||||
builder.append(event.getFormattedMessage()).append('\n');
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue