This is an automated email from the ASF dual-hosted git repository. apkhmv pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/ignite-3.git
The following commit(s) were added to refs/heads/main by this push: new 733c9249d3 IGNITE-18950 Mask passwords in the configuration (#1848) 733c9249d3 is described below commit 733c9249d3d08c2670b6fd6e98055c94be439a39 Author: Ivan Gagarkin <gagarkin....@gmail.com> AuthorDate: Tue Apr 4 11:38:41 2023 +0400 IGNITE-18950 Mask passwords in the configuration (#1848) --- .../AbstractConfigurationController.java | 30 +++++- .../internal/rest/configuration/JsonMasker.java | 94 ++++++++++++++++++ .../ConfigurationControllerBaseTest.java | 20 +++- .../rest/configuration/JsonMaskerTest.java | 105 +++++++++++++++++++++ .../configuration/TestRootConfigurationSchema.java | 4 + ...va => TestSubSensitiveConfigurationSchema.java} | 17 ++-- 6 files changed, 256 insertions(+), 14 deletions(-) diff --git a/modules/rest/src/main/java/org/apache/ignite/internal/rest/configuration/AbstractConfigurationController.java b/modules/rest/src/main/java/org/apache/ignite/internal/rest/configuration/AbstractConfigurationController.java index a85eefd657..7f10788e51 100644 --- a/modules/rest/src/main/java/org/apache/ignite/internal/rest/configuration/AbstractConfigurationController.java +++ b/modules/rest/src/main/java/org/apache/ignite/internal/rest/configuration/AbstractConfigurationController.java @@ -17,8 +17,12 @@ package org.apache.ignite.internal.rest.configuration; +import java.util.Collections; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.regex.Pattern; +import java.util.stream.Collectors; import org.apache.ignite.configuration.validation.ConfigurationValidationException; import org.apache.ignite.lang.IgniteException; @@ -26,6 +30,11 @@ import org.apache.ignite.lang.IgniteException; * Base configuration controller. */ public abstract class AbstractConfigurationController { + + private final Set<String> keysToMask = Set.of("password"); + private final Pattern sensitiveInformationPattern = sensitiveInformationPattern(keysToMask); + private final JsonMasker jsonMasker = new JsonMasker(); + /** Presentation of the configuration. */ private final ConfigurationPresentation<String> cfgPresentation; @@ -39,7 +48,7 @@ public abstract class AbstractConfigurationController { * @return the presentation of configuration. */ public String getConfiguration() { - return cfgPresentation.represent(); + return maskSensitiveInformation(cfgPresentation.represent()); } /** @@ -50,7 +59,7 @@ public abstract class AbstractConfigurationController { */ public String getConfigurationByPath(String path) { try { - return cfgPresentation.representByPath(path); + return maskSensitiveInformation(path, cfgPresentation.representByPath(path)); } catch (IllegalArgumentException ex) { throw new IgniteException(ex); } @@ -74,4 +83,21 @@ public abstract class AbstractConfigurationController { throw new IgniteException(ex); }); } + + private String maskSensitiveInformation(String configuration) { + return maskSensitiveInformation("", configuration); + } + + private String maskSensitiveInformation(String path, String configuration) { + boolean containsOnlySensitiveInformation = sensitiveInformationPattern.matcher(path).find(); + Set<String> maskedKeys = containsOnlySensitiveInformation ? Collections.emptySet() : keysToMask; + return jsonMasker.mask(configuration, maskedKeys).toString(); + } + + private Pattern sensitiveInformationPattern(Set<String> keys) { + String regexp = keys.stream() + .map(it -> "(." + it + "$)") + .collect(Collectors.joining("|")); + return Pattern.compile(regexp); + } } diff --git a/modules/rest/src/main/java/org/apache/ignite/internal/rest/configuration/JsonMasker.java b/modules/rest/src/main/java/org/apache/ignite/internal/rest/configuration/JsonMasker.java new file mode 100644 index 0000000000..7e9174ac36 --- /dev/null +++ b/modules/rest/src/main/java/org/apache/ignite/internal/rest/configuration/JsonMasker.java @@ -0,0 +1,94 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + * + * http://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.apache.ignite.internal.rest.configuration; + +import static java.util.Collections.emptySet; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import java.io.IOException; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * JSON masker. + */ +public class JsonMasker { + + private static final String MASKING_STRING = "******"; + + private final ObjectMapper mapper = new ObjectMapper(); + + /** + * Masks the given JSON string. + * + * @param json JSON string. + * @param keysToMask Set of keys to mask. If empty, all keys will be masked. + * @return Masked {@link JsonNode}. + */ + public JsonNode mask(String json, Set<String> keysToMask) { + Set<String> keysToMaskInLowerCase = keysToMask.stream() + .map(it -> it.toLowerCase(Locale.ROOT)) + .collect(Collectors.toSet()); + return traverseAndMask(stringToJsonNode(json).deepCopy(), keysToMaskInLowerCase); + } + + private JsonNode traverseAndMask(JsonNode target, Set<String> keysToMask) { + if (target.isTextual() && !target.textValue().isBlank() && keysToMask.isEmpty()) { + return new TextNode(MASKING_STRING); + } + + if (target.isArray()) { + for (int i = 0; i < target.size(); i++) { + ((ArrayNode) target).set(i, traverseAndMask(target.get(i), keysToMask)); + } + } + + if (target.isObject()) { + ObjectNode targetObjectNode = (ObjectNode) target; + target.fields().forEachRemaining(field -> { + if (keysToMask.isEmpty()) { + targetObjectNode.replace(field.getKey(), traverseAndMask(field.getValue(), emptySet())); + } else if (keysToMask.contains(field.getKey().toLowerCase(Locale.ROOT))) { + targetObjectNode.replace(field.getKey(), traverseAndMask(field.getValue(), emptySet())); + } else { + traverseAndMask(field.getValue(), keysToMask); + } + } + ); + } + + return target; + } + + private JsonNode stringToJsonNode(String str) { + JsonFactory factory = mapper.getFactory(); + try { + JsonParser parser = factory.createParser(str); + return mapper.readTree(parser); + } catch (IOException e) { + throw new IllegalArgumentException("Invalid JSON string: " + str, e); + } + } +} diff --git a/modules/rest/src/test/java/org/apache/ignite/internal/rest/configuration/ConfigurationControllerBaseTest.java b/modules/rest/src/test/java/org/apache/ignite/internal/rest/configuration/ConfigurationControllerBaseTest.java index c7f9e90c85..c91f81f162 100644 --- a/modules/rest/src/test/java/org/apache/ignite/internal/rest/configuration/ConfigurationControllerBaseTest.java +++ b/modules/rest/src/test/java/org/apache/ignite/internal/rest/configuration/ConfigurationControllerBaseTest.java @@ -17,6 +17,7 @@ package org.apache.ignite.internal.rest.configuration; +import static java.util.Collections.emptySet; import static java.util.concurrent.TimeUnit.SECONDS; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; @@ -34,6 +35,7 @@ import io.micronaut.http.client.exceptions.HttpClientResponseException; import io.micronaut.runtime.server.EmbeddedServer; import io.micronaut.test.extensions.junit5.annotation.MicronautTest; import jakarta.inject.Inject; +import java.util.Set; import org.apache.ignite.internal.configuration.ConfigurationRegistry; import org.apache.ignite.internal.rest.api.InvalidParam; import org.apache.ignite.internal.rest.api.Problem; @@ -47,6 +49,11 @@ import org.junit.jupiter.api.Test; @MicronautTest @Property(name = "micronaut.security.enabled", value = "false") public abstract class ConfigurationControllerBaseTest { + + private final Set<String> secretKeys = Set.of("password"); + + private final JsonMasker jsonMasker = new JsonMasker(); + @Inject private EmbeddedServer server; @@ -65,14 +72,16 @@ public abstract class ConfigurationControllerBaseTest { void beforeEach() throws Exception { var cfg = configurationRegistry.getConfiguration(TestRootConfiguration.KEY); cfg.change(c -> c.changeFoo("foo").changeSubCfg(subCfg -> subCfg.changeBar("bar"))).get(1, SECONDS); + cfg.change(c -> c.changeSensitive().changePassword("password")).get(1, SECONDS); } @Test void testGetConfig() { var response = client().toBlocking().exchange("", String.class); + var expectedBody = jsonMasker.mask(cfgPresentation.represent(), secretKeys).toString(); assertEquals(HttpStatus.OK, response.status()); - assertEquals(cfgPresentation.represent(), response.body()); + assertEquals(expectedBody, response.body()); } @Test @@ -83,6 +92,15 @@ public abstract class ConfigurationControllerBaseTest { assertEquals(cfgPresentation.representByPath("root.subCfg"), response.body()); } + @Test + void testGetSensitiveInformationByPath() { + var response = client().toBlocking().exchange("/root.sensitive.password", String.class); + var expectedBody = jsonMasker.mask(cfgPresentation.representByPath("root.sensitive.password"), emptySet()).toString(); + + assertEquals(HttpStatus.OK, response.status()); + assertEquals(expectedBody, response.body()); + } + @Test void testUpdateConfig() { String givenChangedConfig = "{root:{foo:foo,subCfg:{bar:changed}}}"; diff --git a/modules/rest/src/test/java/org/apache/ignite/internal/rest/configuration/JsonMaskerTest.java b/modules/rest/src/test/java/org/apache/ignite/internal/rest/configuration/JsonMaskerTest.java new file mode 100644 index 0000000000..be4cc184d2 --- /dev/null +++ b/modules/rest/src/test/java/org/apache/ignite/internal/rest/configuration/JsonMaskerTest.java @@ -0,0 +1,105 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + * + * http://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.apache.ignite.internal.rest.configuration; + +import static java.util.Collections.emptySet; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Set; +import org.junit.jupiter.api.Test; + +class JsonMaskerTest { + + private final Set<String> keysToMask = Set.of("credentials"); + private final JsonMasker jsonMasker = new JsonMasker(); + + @Test + void notingToMask() { + String json = "{\"name\": \"John Doe\", \"age\": 30}"; + String masked = jsonMasker.mask(json, keysToMask).toString(); + assertEquals("{\"name\":\"John Doe\",\"age\":30}", masked); + } + + @Test + void maskAll() { + String json = "{\"name\": \"John Doe\", \"age\": 30}"; + String masked = jsonMasker.mask(json, emptySet()).toString(); + assertEquals("{\"name\":\"******\",\"age\":30}", masked); + } + + @Test + void maskField() { + String json = "{\"name\": \"John Doe\", \"age\": 30, \"credentials\": \"admin\"}"; + String masked = jsonMasker.mask(json, keysToMask).toString(); + assertEquals("{\"name\":\"John Doe\",\"age\":30,\"credentials\":\"******\"}", masked); + } + + @Test + void maskNestedArray() { + String json = "{\"name\": \"John Doe\", \"age\": 30, \"credentials\": [\"admin1\", \"admin2\"]}"; + String masked = jsonMasker.mask(json, keysToMask).toString(); + assertEquals("{\"name\":\"John Doe\",\"age\":30,\"credentials\":[\"******\",\"******\"]}", masked); + } + + @Test + void maskNestedObject() { + String json = "{\n" + + " \"name\": \"John Doe\",\n" + + " \"age\": 30,\n" + + " \"credentials\": {\n" + + " \"basic\": {\n" + + " \"user\": \"admin\",\n" + + " \"password\": \"admin\"\n" + + " }\n" + + " }\n" + + "}"; + String masked = jsonMasker.mask(json, keysToMask).toString(); + assertEquals( + "{\"name\":\"John Doe\",\"age\":30,\"credentials\":{\"basic\":{\"user\":\"******\",\"password\":\"******\"}}}", + masked + ); + } + + @Test + void booleanNotMasked() { + String json = "{\"name\": \"John Doe\", \"age\": 30, \"credentials\": true}"; + String masked = jsonMasker.mask(json, keysToMask).toString(); + assertEquals("{\"name\":\"John Doe\",\"age\":30,\"credentials\":true}", masked); + } + + @Test + void numberNotMasked() { + String json = "{\"name\": \"John Doe\", \"age\": 30, \"credentials\": 123}"; + String masked = jsonMasker.mask(json, keysToMask).toString(); + assertEquals("{\"name\":\"John Doe\",\"age\":30,\"credentials\":123}", masked); + } + + @Test + void emptyNotMasked() { + String json = "{\"name\": \"John Doe\", \"age\": 30, \"credentials\": \"\"}"; + String masked = jsonMasker.mask(json, keysToMask).toString(); + assertEquals("{\"name\":\"John Doe\",\"age\":30,\"credentials\":\"\"}", masked); + } + + @Test + void fieldInUpperCase() { + String json = "{\"name\": \"John Doe\", \"age\": 30, \"CREDENTIALS\": \"admin\"}"; + String masked = jsonMasker.mask(json, keysToMask).toString(); + assertEquals("{\"name\":\"John Doe\",\"age\":30,\"CREDENTIALS\":\"******\"}", masked); + } +} diff --git a/modules/rest/src/test/java/org/apache/ignite/internal/rest/configuration/TestRootConfigurationSchema.java b/modules/rest/src/test/java/org/apache/ignite/internal/rest/configuration/TestRootConfigurationSchema.java index fdd6a09f15..0d52c9fac9 100644 --- a/modules/rest/src/test/java/org/apache/ignite/internal/rest/configuration/TestRootConfigurationSchema.java +++ b/modules/rest/src/test/java/org/apache/ignite/internal/rest/configuration/TestRootConfigurationSchema.java @@ -30,6 +30,10 @@ public class TestRootConfigurationSchema { @Value(hasDefault = true) public String foo = "foo"; + /** Sub sensitive configuration schema. */ + @ConfigValue + public TestSubSensitiveConfigurationSchema sensitive; + /** Sub configuration schema. */ @ConfigValue public TestSubConfigurationSchema subCfg; diff --git a/modules/rest/src/test/java/org/apache/ignite/internal/rest/configuration/TestRootConfigurationSchema.java b/modules/rest/src/test/java/org/apache/ignite/internal/rest/configuration/TestSubSensitiveConfigurationSchema.java similarity index 70% copy from modules/rest/src/test/java/org/apache/ignite/internal/rest/configuration/TestRootConfigurationSchema.java copy to modules/rest/src/test/java/org/apache/ignite/internal/rest/configuration/TestSubSensitiveConfigurationSchema.java index fdd6a09f15..acfb752c1f 100644 --- a/modules/rest/src/test/java/org/apache/ignite/internal/rest/configuration/TestRootConfigurationSchema.java +++ b/modules/rest/src/test/java/org/apache/ignite/internal/rest/configuration/TestSubSensitiveConfigurationSchema.java @@ -17,20 +17,15 @@ package org.apache.ignite.internal.rest.configuration; -import org.apache.ignite.configuration.annotation.ConfigValue; -import org.apache.ignite.configuration.annotation.ConfigurationRoot; +import org.apache.ignite.configuration.annotation.Config; import org.apache.ignite.configuration.annotation.Value; /** - * Test root configuration schema. + * Test sub sensitive configuration schema. */ -@ConfigurationRoot(rootName = "root") -public class TestRootConfigurationSchema { - /** Foo field. */ - @Value(hasDefault = true) - public String foo = "foo"; +@Config +public class TestSubSensitiveConfigurationSchema { - /** Sub configuration schema. */ - @ConfigValue - public TestSubConfigurationSchema subCfg; + @Value(hasDefault = true) + public String password = ""; }