From 253f98c3e7ae577618ca7d16d9116205f1fb4215 Mon Sep 17 00:00:00 2001 From: Madhura Bhave Date: Fri, 27 Aug 2021 16:57:18 -0700 Subject: [PATCH] Add pluggable abstraction for applying custom sanitization rules Closes gh-27840 --- ...ertiesReportEndpointAutoConfiguration.java | 7 +- .../EnvironmentEndpointAutoConfiguration.java | 7 +- ...sReportEndpointAutoConfigurationTests.java | 24 +++++ ...ronmentEndpointAutoConfigurationTests.java | 28 +++++ ...ConfigurationPropertiesReportEndpoint.java | 87 +++++++++++---- .../actuate/endpoint/SanitizableData.java | 86 +++++++++++++++ .../boot/actuate/endpoint/Sanitizer.java | 44 +++++++- .../actuate/endpoint/SanitizingFunction.java | 36 +++++++ .../boot/actuate/env/EnvironmentEndpoint.java | 47 ++++++-- ...gurationPropertiesReportEndpointTests.java | 102 ++++++++++++++++++ .../boot/actuate/endpoint/SanitizerTests.java | 18 ++++ .../actuate/env/EnvironmentEndpointTests.java | 33 ++++++ .../source/ConfigurationProperty.java | 31 +++++- .../PrefixedConfigurationPropertySource.java | 3 +- .../SpringConfigurationPropertySource.java | 4 +- ...ngIterableConfigurationPropertySource.java | 2 +- .../source/ConfigurationPropertyTests.java | 18 +++- ...pringConfigurationPropertySourceTests.java | 7 +- 18 files changed, 537 insertions(+), 47 deletions(-) create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/SanitizableData.java create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/SanitizingFunction.java diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/context/properties/ConfigurationPropertiesReportEndpointAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/context/properties/ConfigurationPropertiesReportEndpointAutoConfiguration.java index 4225492220..0c3df64d19 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/context/properties/ConfigurationPropertiesReportEndpointAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/context/properties/ConfigurationPropertiesReportEndpointAutoConfiguration.java @@ -16,9 +16,11 @@ package org.springframework.boot.actuate.autoconfigure.context.properties; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint; import org.springframework.boot.actuate.context.properties.ConfigurationPropertiesReportEndpoint; import org.springframework.boot.actuate.context.properties.ConfigurationPropertiesReportEndpointWebExtension; +import org.springframework.boot.actuate.endpoint.SanitizingFunction; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -43,8 +45,9 @@ public class ConfigurationPropertiesReportEndpointAutoConfiguration { @Bean @ConditionalOnMissingBean public ConfigurationPropertiesReportEndpoint configurationPropertiesReportEndpoint( - ConfigurationPropertiesReportEndpointProperties properties) { - ConfigurationPropertiesReportEndpoint endpoint = new ConfigurationPropertiesReportEndpoint(); + ConfigurationPropertiesReportEndpointProperties properties, + ObjectProvider sanitizingFunctions) { + ConfigurationPropertiesReportEndpoint endpoint = new ConfigurationPropertiesReportEndpoint(sanitizingFunctions); String[] keysToSanitize = properties.getKeysToSanitize(); if (keysToSanitize != null) { endpoint.setKeysToSanitize(keysToSanitize); diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/env/EnvironmentEndpointAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/env/EnvironmentEndpointAutoConfiguration.java index 30547155eb..d910dd10de 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/env/EnvironmentEndpointAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/env/EnvironmentEndpointAutoConfiguration.java @@ -16,7 +16,9 @@ package org.springframework.boot.actuate.autoconfigure.env; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint; +import org.springframework.boot.actuate.endpoint.SanitizingFunction; import org.springframework.boot.actuate.env.EnvironmentEndpoint; import org.springframework.boot.actuate.env.EnvironmentEndpointWebExtension; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; @@ -41,8 +43,9 @@ public class EnvironmentEndpointAutoConfiguration { @Bean @ConditionalOnMissingBean - public EnvironmentEndpoint environmentEndpoint(Environment environment, EnvironmentEndpointProperties properties) { - EnvironmentEndpoint endpoint = new EnvironmentEndpoint(environment); + public EnvironmentEndpoint environmentEndpoint(Environment environment, EnvironmentEndpointProperties properties, + ObjectProvider sanitizingFunctions) { + EnvironmentEndpoint endpoint = new EnvironmentEndpoint(environment, sanitizingFunctions); String[] keysToSanitize = properties.getKeysToSanitize(); if (keysToSanitize != null) { endpoint.setKeysToSanitize(keysToSanitize); diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/context/properties/ConfigurationPropertiesReportEndpointAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/context/properties/ConfigurationPropertiesReportEndpointAutoConfigurationTests.java index 731a6292a9..6a1173ee78 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/context/properties/ConfigurationPropertiesReportEndpointAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/context/properties/ConfigurationPropertiesReportEndpointAutoConfigurationTests.java @@ -22,6 +22,7 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.actuate.context.properties.ConfigurationPropertiesReportEndpoint; import org.springframework.boot.actuate.context.properties.ConfigurationPropertiesReportEndpoint.ApplicationConfigurationProperties; +import org.springframework.boot.actuate.endpoint.SanitizingFunction; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -72,6 +73,14 @@ class ConfigurationPropertiesReportEndpointAutoConfigurationTests { .run(validateTestProperties("******", "******")); } + @Test + void customSanitizingFunctionShouldBeApplied() { + this.contextRunner.withUserConfiguration(Config.class, SanitizingFunctionConfiguration.class) + .withPropertyValues("management.endpoints.web.exposure.include=configprops", + "test.my-test-property=abc") + .run(validateTestProperties("******", "$$$")); + } + @Test void runWhenNotExposedShouldNotHaveEndpointBean() { this.contextRunner @@ -129,4 +138,19 @@ class ConfigurationPropertiesReportEndpointAutoConfigurationTests { } + @Configuration(proxyBeanMethods = false) + static class SanitizingFunctionConfiguration { + + @Bean + SanitizingFunction testSanitizingFunction() { + return (data) -> { + if (data.getKey().contains("my")) { + return data.withValue("$$$"); + } + return data; + }; + } + + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/env/EnvironmentEndpointAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/env/EnvironmentEndpointAutoConfigurationTests.java index 0115bbccd6..3facaf6098 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/env/EnvironmentEndpointAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/env/EnvironmentEndpointAutoConfigurationTests.java @@ -20,6 +20,7 @@ import java.util.Map; import org.junit.jupiter.api.Test; +import org.springframework.boot.actuate.endpoint.SanitizingFunction; import org.springframework.boot.actuate.env.EnvironmentEndpoint; import org.springframework.boot.actuate.env.EnvironmentEndpoint.EnvironmentDescriptor; import org.springframework.boot.actuate.env.EnvironmentEndpoint.PropertySourceDescriptor; @@ -28,6 +29,8 @@ import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.assertj.AssertableApplicationContext; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.boot.test.context.runner.ContextConsumer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; import static org.assertj.core.api.Assertions.assertThat; @@ -67,6 +70,21 @@ class EnvironmentEndpointAutoConfigurationTests { .run(validateSystemProperties("******", "123456")); } + @Test + void sanitizingFunctionsCanBeConfiguredViaTheEnvironment() { + this.contextRunner.withUserConfiguration(SanitizingFunctionConfiguration.class) + .withPropertyValues("management.endpoints.web.exposure.include=env") + .withSystemProperties("custom=123456", "password=123456").run((context) -> { + assertThat(context).hasSingleBean(EnvironmentEndpoint.class); + EnvironmentEndpoint endpoint = context.getBean(EnvironmentEndpoint.class); + EnvironmentDescriptor env = endpoint.environment(null); + Map systemProperties = getSource("systemProperties", env) + .getProperties(); + assertThat(systemProperties.get("custom").getValue()).isEqualTo("$$$"); + assertThat(systemProperties.get("password").getValue()).isEqualTo("******"); + }); + } + @Test void additionalKeysToSanitizeCanBeConfiguredViaTheEnvironment() { this.contextRunner.withPropertyValues("management.endpoints.web.exposure.include=env") @@ -91,4 +109,14 @@ class EnvironmentEndpointAutoConfigurationTests { .get(); } + @Configuration(proxyBeanMethods = false) + static class SanitizingFunctionConfiguration { + + @Bean + SanitizingFunction testSanitizingFunction() { + return (data) -> data.withValue("$$$"); + } + + } + } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpoint.java index c79c58a060..b578584c88 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpoint.java @@ -53,7 +53,9 @@ import org.apache.commons.logging.LogFactory; import org.springframework.beans.BeanUtils; import org.springframework.beans.BeansException; +import org.springframework.boot.actuate.endpoint.SanitizableData; import org.springframework.boot.actuate.endpoint.Sanitizer; +import org.springframework.boot.actuate.endpoint.SanitizingFunction; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; import org.springframework.boot.actuate.endpoint.annotation.Selector; @@ -64,6 +66,7 @@ import org.springframework.boot.context.properties.ConstructorBinding; import org.springframework.boot.context.properties.bind.Name; import org.springframework.boot.context.properties.source.ConfigurationProperty; import org.springframework.boot.context.properties.source.ConfigurationPropertyName; +import org.springframework.boot.context.properties.source.ConfigurationPropertySource; import org.springframework.boot.origin.Origin; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; @@ -73,6 +76,7 @@ import org.springframework.core.ParameterNameDiscoverer; import org.springframework.core.annotation.MergedAnnotation; import org.springframework.core.annotation.MergedAnnotations; import org.springframework.core.annotation.MergedAnnotations.SearchStrategy; +import org.springframework.core.env.PropertySource; import org.springframework.util.ClassUtils; import org.springframework.util.StringUtils; @@ -100,12 +104,20 @@ public class ConfigurationPropertiesReportEndpoint implements ApplicationContext private static final String CONFIGURATION_PROPERTIES_FILTER_ID = "configurationPropertiesFilter"; - private final Sanitizer sanitizer = new Sanitizer(); + private final Sanitizer sanitizer; private ApplicationContext context; private ObjectMapper objectMapper; + public ConfigurationPropertiesReportEndpoint() { + this(Collections.emptyList()); + } + + public ConfigurationPropertiesReportEndpoint(Iterable sanitizingFunctions) { + this.sanitizer = new Sanitizer(sanitizingFunctions); + } + @Override public void setApplicationContext(ApplicationContext context) throws BeansException { this.context = context; @@ -236,26 +248,63 @@ public class ConfigurationPropertiesReportEndpoint implements ApplicationContext map.put(key, sanitize(qualifiedKey, (List) value)); } else { - value = this.sanitizer.sanitize(key, value); - value = this.sanitizer.sanitize(qualifiedKey, value); - map.put(key, value); + map.put(key, sanitizeWithPropertySourceIfPresent(qualifiedKey, value)); } }); return map; } + private Object sanitizeWithPropertySourceIfPresent(String qualifiedKey, Object value) { + ConfigurationPropertyName currentName = getCurrentName(qualifiedKey); + ConfigurationProperty candidate = getCandidate(currentName); + PropertySource propertySource = getPropertySource(candidate); + if (propertySource != null) { + SanitizableData data = new SanitizableData(propertySource, qualifiedKey, value); + return this.sanitizer.sanitize(data); + } + SanitizableData data = new SanitizableData(null, qualifiedKey, value); + return this.sanitizer.sanitize(data); + } + + private PropertySource getPropertySource(ConfigurationProperty configurationProperty) { + if (configurationProperty == null) { + return null; + } + ConfigurationPropertySource source = configurationProperty.getSource(); + Object underlyingSource = (source != null) ? source.getUnderlyingSource() : null; + return (underlyingSource instanceof PropertySource) ? (PropertySource) underlyingSource : null; + } + + private ConfigurationPropertyName getCurrentName(String qualifiedKey) { + return ConfigurationPropertyName.adapt(qualifiedKey, '.'); + } + + private ConfigurationProperty getCandidate(ConfigurationPropertyName currentName) { + BoundConfigurationProperties bound = BoundConfigurationProperties.get(this.context); + if (bound == null) { + return null; + } + ConfigurationProperty candidate = bound.get(currentName); + if (candidate == null && currentName.isLastElementIndexed()) { + candidate = bound.get(currentName.chop(currentName.getNumberOfElements() - 1)); + } + return candidate; + } + @SuppressWarnings("unchecked") private List sanitize(String prefix, List list) { List sanitized = new ArrayList<>(); + int index = 0; for (Object item : list) { + String name = prefix + "[" + index++ + "]"; if (item instanceof Map) { - sanitized.add(sanitize(prefix, (Map) item)); + sanitized.add(sanitize(name, (Map) item)); } else if (item instanceof List) { - sanitized.add(sanitize(prefix, (List) item)); + sanitized.add(sanitize(name, (List) item)); } else { - sanitized.add(this.sanitizer.sanitize(prefix, item)); + sanitized.add(sanitizeWithPropertySourceIfPresent(name, item)); } } return sanitized; @@ -299,24 +348,22 @@ public class ConfigurationPropertiesReportEndpoint implements ApplicationContext } private Map applyInput(String qualifiedKey) { - BoundConfigurationProperties bound = BoundConfigurationProperties.get(this.context); - if (bound == null) { - return Collections.emptyMap(); - } - ConfigurationPropertyName currentName = ConfigurationPropertyName.adapt(qualifiedKey, '.'); - ConfigurationProperty candidate = bound.get(currentName); - if (candidate == null && currentName.isLastElementIndexed()) { - candidate = bound.get(currentName.chop(currentName.getNumberOfElements() - 1)); - } - return (candidate != null) ? getInput(currentName.toString(), candidate) : Collections.emptyMap(); + ConfigurationPropertyName currentName = getCurrentName(qualifiedKey); + ConfigurationProperty candidate = getCandidate(currentName); + PropertySource propertySource = getPropertySource(candidate); + if (propertySource != null) { + Object value = stringifyIfNecessary(candidate.getValue()); + SanitizableData data = new SanitizableData(propertySource, currentName.toString(), value); + return getInput(candidate, this.sanitizer.sanitize(data)); + } + return Collections.emptyMap(); } - private Map getInput(String property, ConfigurationProperty candidate) { + private Map getInput(ConfigurationProperty candidate, Object sanitizedValue) { Map input = new LinkedHashMap<>(); - Object value = stringifyIfNecessary(candidate.getValue()); Origin origin = Origin.from(candidate); List originParents = Origin.parentsFrom(candidate); - input.put("value", this.sanitizer.sanitize(property, value)); + input.put("value", sanitizedValue); input.put("origin", (origin != null) ? origin.toString() : "none"); if (!originParents.isEmpty()) { input.put("originParents", originParents.stream().map(Object::toString).toArray(String[]::new)); diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/SanitizableData.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/SanitizableData.java new file mode 100644 index 0000000000..a5ca974187 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/SanitizableData.java @@ -0,0 +1,86 @@ +/* + * Copyright 2012-2021 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.actuate.endpoint; + +import org.springframework.core.env.PropertySource; + +/** + * Value object that represents the data that can be used by a {@link SanitizingFunction}. + * + * @author Madhura Bhave + * @since 2.6.0 + **/ +public final class SanitizableData { + + /** + * Represents a sanitized value. + */ + public static final String SANITIZED_VALUE = "******"; + + private final PropertySource propertySource; + + private final String key; + + private final Object value; + + /** + * Create a new {@link SanitizableData} instance. + * @param propertySource the property source that provided the data or {@code null}. + * @param key the data key + * @param value the data value + */ + public SanitizableData(PropertySource propertySource, String key, Object value) { + this.propertySource = propertySource; + this.key = key; + this.value = value; + } + + /** + * Return the property source that provided the data or {@code null} If the data was + * not from a {@link PropertySource}. + * @return the property source that provided the data + */ + public PropertySource getPropertySource() { + return this.propertySource; + } + + /** + * Return the key of the data. + * @return the data key + */ + public String getKey() { + return this.key; + } + + /** + * Return the value of the data. + * @return the data value + */ + public Object getValue() { + return this.value; + } + + /** + * Return a new {@link SanitizableData} instance with a different value. + * @param value the new value (often {@link #SANITIZED_VALUE} + * @return a new sanitizable data instance + */ + public SanitizableData withValue(Object value) { + return new SanitizableData(this.propertySource, this.key, value); + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/Sanitizer.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/Sanitizer.java index 61fd80e263..a14b0b2428 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/Sanitizer.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/Sanitizer.java @@ -16,8 +16,11 @@ package org.springframework.boot.actuate.endpoint; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.LinkedHashSet; +import java.util.List; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -38,6 +41,7 @@ import org.springframework.util.StringUtils; * @author HaiTao Zhang * @author Chris Bono * @author David Good + * @author Madhura Bhave * @since 2.0.0 */ public class Sanitizer { @@ -55,6 +59,8 @@ public class Sanitizer { private Pattern[] keysToSanitize; + private final List sanitizingFunctions = new ArrayList<>(); + static { DEFAULT_KEYS_TO_SANITIZE.addAll(URI_USERINFO_KEYS); } @@ -64,9 +70,26 @@ public class Sanitizer { } public Sanitizer(String... keysToSanitize) { + this(Collections.emptyList(), keysToSanitize); + } + + public Sanitizer(Iterable sanitizingFunctions) { + this(sanitizingFunctions, DEFAULT_KEYS_TO_SANITIZE.toArray(new String[0])); + } + + public Sanitizer(Iterable sanitizingFunctions, String... keysToSanitize) { + sanitizingFunctions.forEach(this.sanitizingFunctions::add); + this.sanitizingFunctions.add(getDefaultSanitizingFunction()); setKeysToSanitize(keysToSanitize); } + private SanitizingFunction getDefaultSanitizingFunction() { + return (data) -> { + Object sanitizedValue = sanitize(data.getKey(), data.getValue()); + return data.withValue(sanitizedValue); + }; + } + /** * Set the keys that should be sanitized, overwriting any existing configuration. Keys * can be simple strings that the property ends with or regular expressions. @@ -126,12 +149,29 @@ public class Sanitizer { if (keyIsUriWithUserInfo(pattern)) { return sanitizeUris(value.toString()); } - return "******"; + return SanitizableData.SANITIZED_VALUE; } } return value; } + /** + * Sanitize the value from the given {@link SanitizableData} using the available + * {@link SanitizingFunction}s. + * @param data the sanitizable data + * @return the potentially updated data + * @since 2.6.0 + */ + public Object sanitize(SanitizableData data) { + if (data.getValue() == null) { + return null; + } + for (SanitizingFunction sanitizingFunction : this.sanitizingFunctions) { + data = sanitizingFunction.apply(data); + } + return data.getValue(); + } + private boolean keyIsUriWithUserInfo(Pattern pattern) { for (String uriKey : URI_USERINFO_KEYS) { if (pattern.matcher(uriKey).matches()) { @@ -149,7 +189,7 @@ public class Sanitizer { Matcher matcher = URI_USERINFO_PATTERN.matcher(value); String password = matcher.matches() ? matcher.group(1) : null; if (password != null) { - return StringUtils.replace(value, ":" + password + "@", ":******@"); + return StringUtils.replace(value, ":" + password + "@", ":" + SanitizableData.SANITIZED_VALUE + "@"); } return value; } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/SanitizingFunction.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/SanitizingFunction.java new file mode 100644 index 0000000000..eedcefcb2c --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/SanitizingFunction.java @@ -0,0 +1,36 @@ +/* + * Copyright 2012-2021 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.boot.actuate.endpoint; + +/** + * Function that takes a {@link SanitizableData} and applies sanitization to the value, if + * necessary. Can be used by a {@link Sanitizer} to determine the sanitized value. + * + * @author Madhura Bhave + * @since 2.6.0 + */ +@FunctionalInterface +public interface SanitizingFunction { + + /** + * Apply the sanitiing function to the given data. + * @param data the data to sanitize + * @return the sanitized data or the original instance is no sanitization is applied + */ + SanitizableData apply(SanitizableData data); + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/env/EnvironmentEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/env/EnvironmentEndpoint.java index bd9118e751..d80f4decea 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/env/EnvironmentEndpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/env/EnvironmentEndpoint.java @@ -18,6 +18,7 @@ package org.springframework.boot.actuate.env; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -27,7 +28,9 @@ import java.util.stream.Stream; import com.fasterxml.jackson.annotation.JsonInclude; +import org.springframework.boot.actuate.endpoint.SanitizableData; import org.springframework.boot.actuate.endpoint.Sanitizer; +import org.springframework.boot.actuate.endpoint.SanitizingFunction; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; import org.springframework.boot.actuate.endpoint.annotation.Selector; @@ -64,12 +67,17 @@ import org.springframework.util.SystemPropertyUtils; @Endpoint(id = "env") public class EnvironmentEndpoint { - private final Sanitizer sanitizer = new Sanitizer(); + private final Sanitizer sanitizer; private final Environment environment; public EnvironmentEndpoint(Environment environment) { + this(environment, Collections.emptyList()); + } + + public EnvironmentEndpoint(Environment environment, Iterable sanitizingFunctions) { this.environment = environment; + this.sanitizer = new Sanitizer(sanitizingFunctions); } public void setKeysToSanitize(String... keysToSanitize) { @@ -149,7 +157,8 @@ public class EnvironmentEndpoint { PlaceholdersResolver resolver) { Object resolved = resolver.resolvePlaceholders(source.getProperty(name)); Origin origin = ((source instanceof OriginLookup) ? ((OriginLookup) source).getOrigin(name) : null); - return new PropertyValueDescriptor(stringifyIfNecessary(sanitize(name, resolved)), origin); + Object sanitizedValue = sanitize(source, name, resolved); + return new PropertyValueDescriptor(stringifyIfNecessary(sanitizedValue), origin); } private PlaceholdersResolver getResolver() { @@ -184,8 +193,21 @@ public class EnvironmentEndpoint { } } - public Object sanitize(String name, Object object) { - return this.sanitizer.sanitize(name, object); + /** + * Apply sanitiation to the given name and value. + * @param key the name to sanitize + * @param value the value to sanitize + * @return the sanitized value + * @deprecated since 2.6.0 for removal in 2.8.0 as sanitization should be internal to + * the class + */ + @Deprecated + public Object sanitize(String key, Object value) { + return this.sanitizer.sanitize(key, value); + } + + private Object sanitize(PropertySource source, String name, Object value) { + return this.sanitizer.sanitize(new SanitizableData(source, name, value)); } protected Object stringifyIfNecessary(Object value) { @@ -207,19 +229,28 @@ public class EnvironmentEndpoint { private final Sanitizer sanitizer; + private final Iterable> sources; + PropertySourcesPlaceholdersSanitizingResolver(Iterable> sources, Sanitizer sanitizer) { super(sources, new PropertyPlaceholderHelper(SystemPropertyUtils.PLACEHOLDER_PREFIX, SystemPropertyUtils.PLACEHOLDER_SUFFIX, SystemPropertyUtils.VALUE_SEPARATOR, true)); + this.sources = sources; this.sanitizer = sanitizer; } @Override protected String resolvePlaceholder(String placeholder) { - String value = super.resolvePlaceholder(placeholder); - if (value == null) { - return null; + if (this.sources != null) { + for (PropertySource source : this.sources) { + Object value = source.getProperty(placeholder); + if (value != null) { + SanitizableData data = new SanitizableData(source, placeholder, value); + Object sanitized = this.sanitizer.sanitize(data); + return (sanitized != null) ? String.valueOf(sanitized) : null; + } + } } - return (String) this.sanitizer.sanitize(placeholder, value); + return null; } } diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpointTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpointTests.java index 163d252c53..b98c72b311 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpointTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpointTests.java @@ -30,6 +30,7 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.actuate.context.properties.ConfigurationPropertiesReportEndpoint.ConfigurationPropertiesBeanDescriptor; import org.springframework.boot.actuate.context.properties.ConfigurationPropertiesReportEndpoint.ContextConfigurationProperties; +import org.springframework.boot.actuate.endpoint.SanitizingFunction; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConstructorBinding; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -57,6 +58,7 @@ import static org.assertj.core.api.Assertions.entry; * @author Stephane Nicoll * @author HaiTao Zhang * @author Chris Bono + * @author Madhura Bhave */ @SuppressWarnings("unchecked") class ConfigurationPropertiesReportEndpointTests { @@ -286,6 +288,50 @@ class ConfigurationPropertiesReportEndpointTests { })); } + @Test + void sanitizeWithCustomSanitizingFunction() { + new ApplicationContextRunner().withUserConfiguration(CustomSanitizingEndpointConfig.class, + SanitizingFunctionConfiguration.class, TestPropertiesConfiguration.class) + .run(assertProperties("test", (properties) -> { + assertThat(properties.get("dbPassword")).isEqualTo("******"); + assertThat(properties.get("myTestProperty")).isEqualTo("$$$"); + })); + } + + @Test + void sanitizeWithCustomPropertySourceBasedSanitizingFunction() { + new ApplicationContextRunner() + .withUserConfiguration(CustomSanitizingEndpointConfig.class, + PropertySourceBasedSanitizingFunctionConfiguration.class, TestPropertiesConfiguration.class) + .withPropertyValues("test.my-test-property=abcde").run(assertProperties("test", (properties) -> { + assertThat(properties.get("dbPassword")).isEqualTo("******"); + assertThat(properties.get("myTestProperty")).isEqualTo("$$$"); + })); + } + + @Test + void sanitizeListsWithCustomSanitizingFunction() { + new ApplicationContextRunner() + .withUserConfiguration(CustomSanitizingEndpointConfig.class, SanitizingFunctionConfiguration.class, + SensiblePropertiesConfiguration.class) + .withPropertyValues("sensible.listItems[0].custom=my-value") + .run(assertProperties("sensible", (properties) -> { + assertThat(properties.get("listItems")).isInstanceOf(List.class); + List list = (List) properties.get("listItems"); + assertThat(list).hasSize(1); + Map item = (Map) list.get(0); + assertThat(item.get("custom")).isEqualTo("$$$"); + }, (inputs) -> { + List list = (List) inputs.get("listItems"); + assertThat(list).hasSize(1); + Map item = (Map) list.get(0); + Map somePassword = (Map) item.get("custom"); + assertThat(somePassword.get("value")).isEqualTo("$$$"); + assertThat(somePassword.get("origin")) + .isEqualTo("\"sensible.listItems[0].custom\" from property source \"test\""); + })); + } + @Test void originParents() { this.contextRunner.withUserConfiguration(SensiblePropertiesConfiguration.class) @@ -778,6 +824,8 @@ class ConfigurationPropertiesReportEndpointTests { private String somePassword = "secret"; + private String custom; + public String getSomePassword() { return this.somePassword; } @@ -786,6 +834,60 @@ class ConfigurationPropertiesReportEndpointTests { this.somePassword = somePassword; } + public String getCustom() { + return this.custom; + } + + public void setCustom(String custom) { + this.custom = custom; + } + + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomSanitizingEndpointConfig { + + @Bean + ConfigurationPropertiesReportEndpoint endpoint(Environment environment, SanitizingFunction sanitizingFunction) { + ConfigurationPropertiesReportEndpoint endpoint = new ConfigurationPropertiesReportEndpoint( + Collections.singletonList(sanitizingFunction)); + String[] keys = environment.getProperty("test.keys-to-sanitize", String[].class); + if (keys != null) { + endpoint.setKeysToSanitize(keys); + } + return endpoint; + } + + } + + @Configuration(proxyBeanMethods = false) + static class SanitizingFunctionConfiguration { + + @Bean + SanitizingFunction testSanitizingFunction() { + return (data) -> { + if (data.getKey().contains("custom") || data.getKey().contains("test")) { + return data.withValue("$$$"); + } + return data; + }; + } + + } + + @Configuration(proxyBeanMethods = false) + static class PropertySourceBasedSanitizingFunctionConfiguration { + + @Bean + SanitizingFunction testSanitizingFunction() { + return (data) -> { + if (data.getPropertySource() != null && data.getPropertySource().getName().startsWith("test")) { + return data.withValue("$$$"); + } + return data; + }; } } diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/SanitizerTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/SanitizerTests.java index 48400d2352..f6305a4ac9 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/SanitizerTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/SanitizerTests.java @@ -16,6 +16,7 @@ package org.springframework.boot.actuate.endpoint; +import java.util.Collections; import java.util.stream.Stream; import org.junit.jupiter.api.Test; @@ -31,6 +32,7 @@ import static org.assertj.core.api.Assertions.assertThat; * @author Stephane Nicoll * @author Chris Bono * @author David Good + * @author Madhura Bhave */ class SanitizerTests { @@ -65,6 +67,22 @@ class SanitizerTests { assertThat(sanitizer.sanitize("private", "secret")).isEqualTo("secret"); } + @Test + void whenCustomSanitizingFunctionPresentValueShouldBeSanitized() { + Sanitizer sanitizer = new Sanitizer(Collections.singletonList((data) -> { + if (data.getKey().equals("custom")) { + return data.withValue("$$$$$$"); + } + return data; + })); + SanitizableData secret = new SanitizableData(null, "secret", "xyz"); + assertThat(sanitizer.sanitize(secret)).isEqualTo("******"); + SanitizableData custom = new SanitizableData(null, "custom", "abcde"); + assertThat(sanitizer.sanitize(custom)).isEqualTo("$$$$$$"); + SanitizableData hello = new SanitizableData(null, "hello", "abc"); + assertThat(sanitizer.sanitize(hello)).isEqualTo("abc"); + } + @ParameterizedTest(name = "key = {0}") @MethodSource("matchingUriUserInfoKeys") void uriWithSingleValueWithPasswordShouldBeSanitized(String key) { diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/env/EnvironmentEndpointTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/env/EnvironmentEndpointTests.java index 8d3cfd17ce..714f81a25d 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/env/EnvironmentEndpointTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/env/EnvironmentEndpointTests.java @@ -164,6 +164,28 @@ class EnvironmentEndpointTests { }); } + @Test + void keysMatchingCustomSanitizingFunctionHaveTheirValuesSanitized() { + ConfigurableEnvironment environment = new StandardEnvironment(); + TestPropertyValues.of("other.service=abcde").applyTo(environment); + TestPropertyValues.of("system.service=123456").applyToSystemProperties(() -> { + EnvironmentDescriptor descriptor = new EnvironmentEndpoint(environment, + Collections.singletonList((data) -> { + String name = data.getPropertySource().getName(); + if (name.equals(StandardEnvironment.SYSTEM_PROPERTIES_PROPERTY_SOURCE_NAME)) { + return data.withValue("******"); + } + return data; + })).environment(null); + assertThat(propertySources(descriptor).get("test").getProperties().get("other.service").getValue()) + .isEqualTo("abcde"); + Map systemProperties = propertySources(descriptor).get("systemProperties") + .getProperties(); + assertThat(systemProperties.get("system.service").getValue()).isEqualTo("******"); + return null; + }); + } + @Test void propertyWithPlaceholderResolved() { ConfigurableEnvironment environment = emptyEnvironment(); @@ -199,6 +221,17 @@ class EnvironmentEndpointTests { .isEqualTo("http://${bar.password}://hello"); } + @Test + void propertyWithSensitivePlaceholderWithCustomFunctionResolved() { + ConfigurableEnvironment environment = emptyEnvironment(); + TestPropertyValues.of("my.foo: http://${bar.password}://hello", "bar.password: hello").applyTo(environment); + EnvironmentDescriptor descriptor = new EnvironmentEndpoint(environment, + Collections.singletonList((data) -> data.withValue(data.getPropertySource().getName() + "******"))) + .environment(null); + assertThat(propertySources(descriptor).get("test").getProperties().get("my.foo").getValue()) + .isEqualTo("test******"); + } + @Test void propertyWithComplexTypeShouldNotFail() { ConfigurableEnvironment environment = emptyEnvironment(); diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/source/ConfigurationProperty.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/source/ConfigurationProperty.java index 7b07f5ebd1..c2f4f0e178 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/source/ConfigurationProperty.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/source/ConfigurationProperty.java @@ -38,20 +38,46 @@ public final class ConfigurationProperty implements OriginProvider, Comparable new ConfigurationProperty(null, "bar", null)) @@ -49,23 +52,30 @@ class ConfigurationPropertyTests { @Test void getNameShouldReturnName() { - ConfigurationProperty property = ConfigurationProperty.of(NAME, "foo", null); + ConfigurationProperty property = ConfigurationProperty.of(this.source, NAME, "foo", null); assertThat((Object) property.getName()).isEqualTo(NAME); } @Test void getValueShouldReturnValue() { - ConfigurationProperty property = ConfigurationProperty.of(NAME, "foo", null); + ConfigurationProperty property = ConfigurationProperty.of(this.source, NAME, "foo", null); assertThat(property.getValue()).isEqualTo("foo"); } @Test void getPropertyOriginShouldReturnValuePropertyOrigin() { Origin origin = mock(Origin.class); - OriginProvider property = ConfigurationProperty.of(NAME, "foo", origin); + OriginProvider property = ConfigurationProperty.of(this.source, NAME, "foo", origin); assertThat(property.getOrigin()).isEqualTo(origin); } + @Test + void getPropertySourceShouldReturnPropertySource() { + Origin origin = mock(Origin.class); + ConfigurationProperty property = ConfigurationProperty.of(this.source, NAME, "foo", origin); + assertThat(property.getSource()).isEqualTo(this.source); + } + @Test void equalsAndHashCode() { ConfigurationProperty property1 = new ConfigurationProperty(ConfigurationPropertyName.of("foo"), "bar", null); @@ -78,7 +88,7 @@ class ConfigurationPropertyTests { @Test void toStringShouldReturnValue() { - ConfigurationProperty property = ConfigurationProperty.of(NAME, "foo", null); + ConfigurationProperty property = ConfigurationProperty.of(this.source, NAME, "foo", null); assertThat(property.toString()).contains("name").contains("value"); } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/source/SpringConfigurationPropertySourceTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/source/SpringConfigurationPropertySourceTests.java index 605de0eab4..78bfa7994a 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/source/SpringConfigurationPropertySourceTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/source/SpringConfigurationPropertySourceTests.java @@ -61,7 +61,7 @@ class SpringConfigurationPropertySourceTests { } @Test - void getValueOrigin() { + void getValueOriginAndPropertySource() { Map source = new LinkedHashMap<>(); source.put("key", "value"); PropertySource propertySource = new MapPropertySource("test", source); @@ -69,8 +69,9 @@ class SpringConfigurationPropertySourceTests { ConfigurationPropertyName name = ConfigurationPropertyName.of("my.key"); mapper.addFromConfigurationProperty(name, "key"); SpringConfigurationPropertySource adapter = new SpringConfigurationPropertySource(propertySource, mapper); - assertThat(adapter.getConfigurationProperty(name).getOrigin().toString()) - .isEqualTo("\"key\" from property source \"test\""); + ConfigurationProperty configurationProperty = adapter.getConfigurationProperty(name); + assertThat(configurationProperty.getOrigin().toString()).isEqualTo("\"key\" from property source \"test\""); + assertThat(configurationProperty.getSource()).isEqualTo(adapter); } @Test