Skip to content

Commit bfe0091

Browse files
EddyEddy
authored andcommitted
feat: sanitize secrets loaded from Secret Manager #4122
1 parent 5bf1b41 commit bfe0091

4 files changed

Lines changed: 199 additions & 0 deletions

File tree

spring-cloud-gcp-autoconfigure/src/main/java/com/google/cloud/spring/autoconfigure/secretmanager/GcpSecretManagerAutoConfiguration.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import com.google.cloud.spring.secretmanager.SecretManagerTemplate;
2727
import java.io.IOException;
2828
import org.springframework.beans.factory.ObjectProvider;
29+
import org.springframework.boot.actuate.endpoint.SanitizingFunction;
2930
import org.springframework.boot.autoconfigure.AutoConfiguration;
3031
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
3132
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
@@ -98,4 +99,11 @@ public SecretManagerTemplate secretManagerTemplate(
9899
}
99100
}
100101

102+
@Bean
103+
@ConditionalOnMissingBean
104+
@ConditionalOnClass(SanitizingFunction.class)
105+
public SecretManagerSanitizingFunction secretManagerSanitizingFunction() {
106+
return new SecretManagerSanitizingFunction();
107+
}
108+
101109
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
* Copyright 2017-2026 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.cloud.spring.autoconfigure.secretmanager;
18+
19+
import com.google.cloud.spring.secretmanager.SecretManagerSyntaxUtils;
20+
import org.springframework.boot.actuate.endpoint.SanitizableData;
21+
import org.springframework.boot.actuate.endpoint.SanitizingFunction;
22+
import org.springframework.core.env.PropertySource;
23+
24+
/**
25+
* A {@link SanitizingFunction} that prevents GCP Secret Manager secrets from being exposed
26+
* in plain text via Spring Boot Actuator endpoints (e.g. {@code /actuator/env}).
27+
*
28+
* <p>When a property's unresolved value contains a Secret Manager reference such as
29+
* {@code ${sm@my-secret}} or {@code ${sm://my-secret}}, this function replaces the resolved
30+
* secret value with the unresolved expression, so that the actual secret is never surfaced
31+
* regardless of the value of {@code management.endpoint.env.show-values}.
32+
*
33+
* @since 6.4.0
34+
*/
35+
public class SecretManagerSanitizingFunction implements SanitizingFunction {
36+
37+
@Override
38+
public SanitizableData apply(SanitizableData data) {
39+
PropertySource<?> propertySource = data.getPropertySource();
40+
41+
if (propertySource == null || data.getValue() == null) {
42+
return data;
43+
}
44+
45+
Object unresolvedValue = propertySource.getProperty(data.getKey());
46+
47+
if (unresolvedValue instanceof String stringValue) {
48+
for (String prefix : SecretManagerSyntaxUtils.PREFIXES) {
49+
if (stringValue.contains("${" + prefix)) {
50+
// Replace the resolved secret with the unresolved SM expression so the
51+
// real secret is never surfaced in actuator output.
52+
return data.withValue(stringValue);
53+
}
54+
}
55+
}
56+
57+
return data;
58+
}
59+
}

spring-cloud-gcp-autoconfigure/src/test/java/com/google/cloud/spring/autoconfigure/secretmanager/GcpSecretManagerAutoConfigurationUnitTests.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import com.google.cloud.spring.autoconfigure.TestUtils;
2424
import com.google.cloud.spring.autoconfigure.core.GcpContextAutoConfiguration;
2525
import com.google.cloud.spring.autoconfigure.parametermanager.GcpParameterManagerAutoConfiguration;
26+
import com.google.cloud.spring.autoconfigure.secretmanager.SecretManagerSanitizingFunction;
2627
import com.google.cloud.spring.parametermanager.ParameterManagerClientFactory;
2728
import com.google.cloud.spring.secretmanager.SecretManagerServiceClientFactory;
2829
import com.google.cloud.spring.secretmanager.SecretManagerTemplate;
@@ -99,6 +100,12 @@ void testSecretManagerTemplateExists() {
99100
.isNotNull());
100101
}
101102

103+
@Test
104+
void testSanitizingFunctionBeanRegistered() {
105+
contextRunner.run(
106+
ctx -> assertThat(ctx).hasSingleBean(SecretManagerSanitizingFunction.class));
107+
}
108+
102109
static class TestConfig {
103110

104111
@Bean
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*
2+
* Copyright 2017-2026 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.cloud.spring.autoconfigure.secretmanager;
18+
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
import static org.mockito.Mockito.mock;
21+
import static org.mockito.Mockito.when;
22+
23+
import org.junit.jupiter.api.Test;
24+
import org.springframework.boot.actuate.endpoint.SanitizableData;
25+
import org.springframework.core.env.PropertySource;
26+
27+
/**
28+
* Unit tests for {@link SecretManagerSanitizingFunction}.
29+
*/
30+
class SecretManagerSanitizingFunctionTest {
31+
32+
private final SecretManagerSanitizingFunction sanitizingFunction =
33+
new SecretManagerSanitizingFunction();
34+
35+
@Test
36+
void sanitizesSmAtPrefixProperty() {
37+
String key = "my.secret";
38+
String unresolvedValue = "${sm@my-password}";
39+
String resolvedValue = "super-secret-123";
40+
41+
PropertySource<?> mockSource = mock(PropertySource.class);
42+
when(mockSource.getProperty(key)).thenReturn(unresolvedValue);
43+
44+
SanitizableData data = new SanitizableData(mockSource, key, resolvedValue);
45+
SanitizableData result = sanitizingFunction.apply(data);
46+
47+
assertThat(result.getValue()).isEqualTo(unresolvedValue);
48+
}
49+
50+
@Test
51+
void sanitizesSmSlashPrefixProperty() {
52+
String key = "my.secret";
53+
String unresolvedValue = "${sm://my-password}";
54+
String resolvedValue = "super-secret-123";
55+
56+
PropertySource<?> mockSource = mock(PropertySource.class);
57+
when(mockSource.getProperty(key)).thenReturn(unresolvedValue);
58+
59+
SanitizableData data = new SanitizableData(mockSource, key, resolvedValue);
60+
SanitizableData result = sanitizingFunction.apply(data);
61+
62+
assertThat(result.getValue()).isEqualTo(unresolvedValue);
63+
}
64+
65+
@Test
66+
void sanitizesCompositeValueContainingSmReference() {
67+
String key = "my.database.url";
68+
String unresolvedValue = "https://user:${sm@my-pass}@host/db";
69+
String resolvedValue = "https://user:secret123@host/db";
70+
71+
PropertySource<?> mockSource = mock(PropertySource.class);
72+
when(mockSource.getProperty(key)).thenReturn(unresolvedValue);
73+
74+
SanitizableData data = new SanitizableData(mockSource, key, resolvedValue);
75+
SanitizableData result = sanitizingFunction.apply(data);
76+
77+
// The resolved URL (with embedded secret) should be replaced by the unresolved expression.
78+
assertThat(result.getValue()).isEqualTo(unresolvedValue);
79+
}
80+
81+
@Test
82+
void doesNotSanitizeRegularProperty() {
83+
String key = "normal.prop";
84+
String value = "normal-value";
85+
86+
PropertySource<?> mockSource = mock(PropertySource.class);
87+
when(mockSource.getProperty(key)).thenReturn(value);
88+
89+
SanitizableData data = new SanitizableData(mockSource, key, value);
90+
SanitizableData result = sanitizingFunction.apply(data);
91+
92+
assertThat(result.getValue()).isEqualTo(value);
93+
}
94+
95+
@Test
96+
void returnsDataUnchangedWhenPropertySourceIsNull() {
97+
SanitizableData data = new SanitizableData(null, "my.key", "some-value");
98+
SanitizableData result = sanitizingFunction.apply(data);
99+
100+
assertThat(result.getValue()).isEqualTo("some-value");
101+
}
102+
103+
@Test
104+
void returnsDataUnchangedWhenValueIsNull() {
105+
PropertySource<?> mockSource = mock(PropertySource.class);
106+
when(mockSource.getProperty("my.key")).thenReturn("${sm@some-secret}");
107+
108+
SanitizableData data = new SanitizableData(mockSource, "my.key", null);
109+
SanitizableData result = sanitizingFunction.apply(data);
110+
111+
assertThat(result.getValue()).isNull();
112+
}
113+
114+
@Test
115+
void returnsDataUnchangedWhenUnresolvedValueIsNull() {
116+
PropertySource<?> mockSource = mock(PropertySource.class);
117+
when(mockSource.getProperty("my.key")).thenReturn(null);
118+
119+
SanitizableData data = new SanitizableData(mockSource, "my.key", "resolved-value");
120+
SanitizableData result = sanitizingFunction.apply(data);
121+
122+
// No SM prefix detected — pass through unchanged.
123+
assertThat(result.getValue()).isEqualTo("resolved-value");
124+
}
125+
}

0 commit comments

Comments
 (0)