diff --git a/src/main/java/net/hostsharing/hsadminng/config/ActuatorSanitizer.java b/src/main/java/net/hostsharing/hsadminng/config/ActuatorSanitizer.java new file mode 100644 index 00000000..9967a78f --- /dev/null +++ b/src/main/java/net/hostsharing/hsadminng/config/ActuatorSanitizer.java @@ -0,0 +1,105 @@ +package net.hostsharing.hsadminng.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.actuate.endpoint.SanitizableData; +import org.springframework.boot.actuate.endpoint.SanitizingFunction; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +// HOWTO: exclude sensitive values, like passwords and other secrets, from being show by actuator endpoints: +// either use: add your custom keys to management.endpoint.additionalKeysToSanitize, +// or, if you need more heuristics, amend this code down here. +@Component +public class ActuatorSanitizer implements SanitizingFunction { + + private static final String[] REGEX_PARTS = {"*", "$", "^", "+"}; + + private static final Set DEFAULT_KEYS_TO_SANITIZE = Set.of( + "password", "secret", "token", ".*credentials.*", "vcap_services", "^vcap\\.services.*$", "sun.java.command", "^spring[._]application[._]json$" + ); + + private static final Set URI_USERINFO_KEYS = Set.of( + "uri", "uris", "url", "urls", "address", "addresses" + ); + + private static final Pattern URI_USERINFO_PATTERN = Pattern.compile("^\\[?[A-Za-z][A-Za-z0-9\\+\\.\\-]+://.+:(.*)@.+$"); + + private final List keysToSanitize = new ArrayList<>(); + + public ActuatorSanitizer(@Value("${management.endpoint.additionalKeysToSanitize:}") final List additionalKeysToSanitize) { + addKeysToSanitize(DEFAULT_KEYS_TO_SANITIZE); + addKeysToSanitize(URI_USERINFO_KEYS); + addKeysToSanitize(additionalKeysToSanitize); + } + + @Override + public SanitizableData apply(final SanitizableData data) { + if (data.getValue() == null) { + return data; + } + + for (final Pattern pattern : keysToSanitize) { + if (pattern.matcher(data.getKey()).matches()) { + if (keyIsUriWithUserInfo(pattern)) { + return data.withValue(sanitizeUris(data.getValue().toString())); + } + + return data.withValue(SanitizableData.SANITIZED_VALUE); + } + } + + return data; + } + + private void addKeysToSanitize(final Collection keysToSanitize) { + for (final String key : keysToSanitize) { + this.keysToSanitize.add(getPattern(key)); + } + } + + private Pattern getPattern(final String value) { + if (isRegex(value)) { + return Pattern.compile(value, Pattern.CASE_INSENSITIVE); + } + return Pattern.compile(".*" + value + "$", Pattern.CASE_INSENSITIVE); + } + + private boolean isRegex(final String value) { + for (final String part : REGEX_PARTS) { + if (value.contains(part)) { + return true; + } + } + return false; + } + + private boolean keyIsUriWithUserInfo(final Pattern pattern) { + for (String uriKey : URI_USERINFO_KEYS) { + if (pattern.matcher(uriKey).matches()) { + return true; + } + } + return false; + } + + private Object sanitizeUris(final String value) { + return Arrays.stream(value.split(",")).map(this::sanitizeUri).collect(Collectors.joining(",")); + } + + private String sanitizeUri(final String value) { + final var matcher = URI_USERINFO_PATTERN.matcher(value); + final var password = matcher.matches() ? matcher.group(1) : null; + if (password != null) { + return StringUtils.replace(value, ":" + password + "@", ":" + SanitizableData.SANITIZED_VALUE + "@"); + } + return value; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f6a6fe88..526a2a58 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -8,8 +8,23 @@ management: endpoints: web: exposure: - # HOWTO: view _clickable_ Spring Actuator (Micrometer) Metrics endpoints: http://localhost:8081/actuator/metric-links - include: info, health, metrics, metric-links, mappings, openapi, swaggerui + # HOWTO: view _clickable_ Spring Actuator (Micrometer) Metrics endpoints: + # http://localhost:8081/actuator/metric-links + + # HOWTO: view all configured endpoints of the running application: + # http://localhost:8081/actuator/mappings + + # HOWTO: view the effective application configuration properties: + # http://localhost:8081/actuator/configprops + + include: info, health, metrics, metric-links, mappings, openapi, swaggerui, configprops, env + endpoint: + env: + # TODO.spec: check this, maybe set to when_authorized? + show-values: always + configprops: + # TODO.spec: check this, maybe set to when_authorized? + show-values: always observations: annotations: enabled: true @@ -18,7 +33,7 @@ spring: datasource: driver-class-name: org.postgresql.Driver password: password - url: jdbc:postgresql://localhost:5432/postgres + url: ${HSADMINNG_POSTGRES_JDBC_URL} username: postgres sql: @@ -30,13 +45,13 @@ spring: hibernate: dialect: net.hostsharing.hsadminng.config.PostgresCustomDialect + liquibase: + contexts: dev,office + # keep this in sync with test/.../application.yml springdoc: use-management-port: true -liquibase: - contexts: dev - hsadminng: postgres: leakproof: @@ -51,3 +66,33 @@ metrics: server: requests: true +--- + +# the 'dev-all' profile automatically creates the PgSql users and runs only the office module +spring: + config: + activate: + on-profile: dev-all + liquibase: + contexts: dev,office,booking,hosting + +--- + +# the 'dev-office' profile automatically creates the PgSql users and runs only the office module +spring: + config: + activate: + on-profile: dev-office + liquibase: + contexts: dev,office + +--- + +# the 'prod-office' profile expects that the PgSql users are already created and runs only the office module +spring: + config: + activate: + on-profile: prod-office + liquibase: + contexts: office + diff --git a/src/test/java/net/hostsharing/hsadminng/config/ActuatorSanitizerUnitTest.java b/src/test/java/net/hostsharing/hsadminng/config/ActuatorSanitizerUnitTest.java new file mode 100644 index 00000000..a804251e --- /dev/null +++ b/src/test/java/net/hostsharing/hsadminng/config/ActuatorSanitizerUnitTest.java @@ -0,0 +1,95 @@ +package net.hostsharing.hsadminng.config; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.actuate.endpoint.SanitizableData; +import org.springframework.core.env.PropertySource; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ActuatorSanitizerTest { + + private ActuatorSanitizer actuatorSanitizer; + + @BeforeEach + void setUp() { + // Initialize with additional keys for testing + final var additionalKeys = List.of("customSecret", "^custom[._]regex.*$"); + actuatorSanitizer = new ActuatorSanitizer(additionalKeys); + } + + @Test + void testSanitizesDefaultKeys() { + final var data = createSanitizableData("password", "my-secret-password"); + final var sanitizedData = actuatorSanitizer.apply(data); + + assertThat(sanitizedData.getValue()).isEqualTo(SanitizableData.SANITIZED_VALUE); + } + + @Test + void testSanitizesCustomKey() { + final var data = createSanitizableData("customSecret", "my-custom-secret"); + final var sanitizedData = actuatorSanitizer.apply(data); + + assertThat(sanitizedData.getValue()).isEqualTo(SanitizableData.SANITIZED_VALUE); + } + + @Test + void testSanitizesCustomRegexKey() { + final var data = createSanitizableData("custom.regex.key", "my-custom-regex-value"); + final var sanitizedData = actuatorSanitizer.apply(data); + + assertThat(sanitizedData.getValue()).isEqualTo(SanitizableData.SANITIZED_VALUE); + } + + @Test + void testSanitizesUriWithUserInfo() { + final var data = createSanitizableData("uri", "http://user:password@host.com"); + final var sanitizedData = actuatorSanitizer.apply(data); + + assertThat(sanitizedData.getValue()).isEqualTo("http://user:******@host.com"); + } + + @Test + void testDoesNotSanitizeIrrelevantKey() { + final var data = createSanitizableData("irrelevantKey", "non-sensitive-value"); + final var sanitizedData = actuatorSanitizer.apply(data); + + assertThat(sanitizedData.getValue()).isEqualTo("non-sensitive-value"); + } + + @Test + void testHandlesNullValue() { + final var data = createSanitizableData("password", null); + final var sanitizedData = actuatorSanitizer.apply(data); + + assertThat(sanitizedData.getValue()).isNull(); + } + + @Test + void testHandlesMultipleUris() { + final var data = createSanitizableData( + "uris", + "http://user1:password1@host1.com,http://user2:geheim@host2.com,http://user2@host2.com"); + final var sanitizedData = actuatorSanitizer.apply(data); + + assertThat(sanitizedData.getValue()).isEqualTo( + "http://user1:******@host1.com,http://user2:******@host2.com,http://user2@host2.com"); + } + + /** + * Utility method to create a SanitizableData instance for testing. + */ + private SanitizableData createSanitizableData(final String key, final String value) { + final var dummyPropertySource = new PropertySource<>("testSource") { + + @Override + public Object getProperty(String name) { + return null; // No real property resolution needed for this test + } + }; + return new SanitizableData(dummyPropertySource, key, value); + } +}