From 8358edb439e8cf97f3c177efa59c28d3c3aa151a Mon Sep 17 00:00:00 2001 From: Jiho Rhee <63854819+eazyhozy@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:25:32 +0900 Subject: [PATCH 1/2] chore: mask metastore credentials in GraphConfig log output Graph logs the whole GraphConfig at INFO on startup. The generated data class toString() renders every field, so the metastore user and password end up in the log in plain text. Hand-write toString() to mask just those two fields. Everything else stays visible: that log line is the primary record of how an instance was configured. An empty credential still renders as empty, so "not configured" stays distinguishable from "configured". A hand-written toString() can fall behind the constructor, so the test walks the declared fields by reflection and fails if any property stops being rendered. --- .../kakao/actionbase/v2/engine/GraphConfig.kt | 23 ++++++++ .../actionbase/v2/engine/GraphConfigTest.kt | 58 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 engine/src/test/kotlin/com/kakao/actionbase/v2/engine/GraphConfigTest.kt diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/GraphConfig.kt b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/GraphConfig.kt index 2ab54cf6f..ec16d17a2 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/GraphConfig.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/GraphConfig.kt @@ -40,9 +40,32 @@ data class GraphConfig( val useJdbcMetastore: Boolean = true, val featureFlags: List = emptyList(), ) { + /** + * Hand-written because the generated rendering leaks the metastore credentials into the startup log. Every other + * field stays visible: the log line is the primary record of how an instance was configured. An empty credential + * renders as empty rather than masked, so "not configured" stays distinguishable from "configured". + */ + override fun toString(): String = + "GraphConfig(" + + "phase=$phase, tenant=$tenant, " + + "metastoreUrl=$metastoreUrl, metastoreUser=${mask(metastoreUser)}, metastorePassword=${mask(metastorePassword)}, " + + "metastoreDriver=$metastoreDriver, metastoreTable=$metastoreTable, " + + "metastoreReloadInitialDelay=$metastoreReloadInitialDelay, metastoreReloadInterval=$metastoreReloadInterval, " + + "metastoreConnectionPoolSize=$metastoreConnectionPoolSize, defaultStorageEntity=$defaultStorageEntity, " + + "walProperties=$walProperties, cdcProperties=$cdcProperties, topNProperties=$topNProperties, " + + "encoderPoolSize=$encoderPoolSize, lockTimeout=$lockTimeout, hostName=$hostName, warmUp=$warmUp, " + + "artifactInfo=$artifactInfo, mutationRequestTimeout=$mutationRequestTimeout, hbase=$hbase, " + + "metadataFetchLimit=$metadataFetchLimit, systemMutationMode=$systemMutationMode, readOnly=$readOnly, " + + "useJdbcMetastore=$useJdbcMetastore, featureFlags=$featureFlags" + + ")" + companion object { + private const val MASK = "****" + val builder: Builder get() = Builder() + + private fun mask(credential: String): String = if (credential.isEmpty()) "" else MASK } class Builder { diff --git a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/GraphConfigTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/GraphConfigTest.kt new file mode 100644 index 000000000..41ef93163 --- /dev/null +++ b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/GraphConfigTest.kt @@ -0,0 +1,58 @@ +package com.kakao.actionbase.v2.engine + +import java.lang.reflect.Modifier + +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +class GraphConfigTest { + @Nested + @DisplayName("toString") + inner class ToStringTest { + @Test + fun `should mask the metastore credentials`() { + val rendered = + GraphConfig.builder + .withMetastoreUser(USER) + .withMetastorePassword(PASSWORD) + .build() + .toString() + + assertFalse(rendered.contains(USER), "toString() rendered the metastore user") + assertFalse(rendered.contains(PASSWORD), "toString() rendered the metastore password") + assertTrue(rendered.contains("metastoreUser=****"), "toString() did not mask the metastore user") + assertTrue(rendered.contains("metastorePassword=****"), "toString() did not mask the metastore password") + } + + @Test + fun `should render an unset credential as empty rather than masked`() { + val rendered = GraphConfig.builder.build().toString() + + assertTrue(rendered.contains("metastoreUser=, "), "an unset metastore user should not look configured") + assertTrue(rendered.contains("metastorePassword=, "), "an unset metastore password should not look configured") + } + + @Test + fun `should render every property`() { + val rendered = GraphConfig.builder.build().toString() + + val missing = + GraphConfig::class.java.declaredFields + .filterNot { Modifier.isStatic(it.modifiers) } + .map { it.name } + .filterNot { rendered.contains("$it=") } + + assertEquals(emptyList(), missing, "toString() is hand-written and must be extended whenever a property is added") + } + } + + private companion object { + const val USER = "metastore-user-that-must-not-be-logged" + const val PASSWORD = "metastore-password-that-must-not-be-logged" + } +} From 9f76cd0b5473b508feea33fdf4c1fee625a43fa9 Mon Sep 17 00:00:00 2001 From: Jiho Rhee Date: Tue, 11 Aug 2026 17:33:40 +0900 Subject: [PATCH 2/2] refactor: redact credentials at the log call sites instead of in toString() The previous commit hand-wrote toString() to mask the credentials. That made the default safe, but it also pinned a 28-field rendering that has to be extended by hand every time a property is added, and a stale rendering fails silently. Move the masking to a redacted() copy and call it from the three sites that log the config. copy() keeps the generated toString(), so nothing drifts when the class grows. The trade-off is that toString() itself still renders the credentials, so a future caller that logs the config directly would leak them again. --- .../com/kakao/actionbase/v2/engine/Graph.kt | 6 +- .../kakao/actionbase/v2/engine/GraphConfig.kt | 25 +++----- .../actionbase/v2/engine/GraphConfigTest.kt | 58 ------------------- 3 files changed, 12 insertions(+), 77 deletions(-) delete mode 100644 engine/src/test/kotlin/com/kakao/actionbase/v2/engine/GraphConfigTest.kt diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/Graph.kt b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/Graph.kt index a0771fcd0..54c3e80e6 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/Graph.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/Graph.kt @@ -182,7 +182,7 @@ class Graph( Mono.fromCallable { printLog(config) - log.info("graph initialized with config: {}", config) + log.info("graph initialized with config: {}", config.redacted()) log.info("DEFAULT_BOUNDED_ELASTIC_SIZE: {}", Schedulers.DEFAULT_BOUNDED_ELASTIC_SIZE) log.info("DEFAULT_BOUNDED_ELASTIC_QUEUESIZE: {}", Schedulers.DEFAULT_BOUNDED_ELASTIC_QUEUESIZE) @@ -215,7 +215,7 @@ class Graph( config.tenant, config.phase, ) - log.info("graph initialized with config: {}", config) + log.info("graph initialized with config: {}", config.redacted()) log.info("DEFAULT_BOUNDED_ELASTIC_SIZE: {}", Schedulers.DEFAULT_BOUNDED_ELASTIC_SIZE) log.info("DEFAULT_BOUNDED_ELASTIC_QUEUESIZE: {}", Schedulers.DEFAULT_BOUNDED_ELASTIC_QUEUESIZE) } @@ -929,7 +929,7 @@ class Graph( DefaultStorageBackendFactory.initialize(config.hbase) log.info("phase: {}", config.phase) log.info("tenant: {}", config.tenant) - log.info("graph config: {}", config) + log.info("graph config: {}", config.redacted()) log.info("kafkaClientFactory: {}", kafkaClientFactory) log.info("webClientFactory: {}", webClientFactory) diff --git a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/GraphConfig.kt b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/GraphConfig.kt index ec16d17a2..ca9b51f1a 100644 --- a/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/GraphConfig.kt +++ b/engine/src/main/kotlin/com/kakao/actionbase/v2/engine/GraphConfig.kt @@ -41,23 +41,16 @@ data class GraphConfig( val featureFlags: List = emptyList(), ) { /** - * Hand-written because the generated rendering leaks the metastore credentials into the startup log. Every other - * field stays visible: the log line is the primary record of how an instance was configured. An empty credential - * renders as empty rather than masked, so "not configured" stays distinguishable from "configured". + * A copy safe to log. The generated [toString] renders every field, so logging this config directly puts the + * metastore credentials in the log in plain text. Only the credentials are replaced - every other field stays + * visible, because that line is the primary record of how an instance was configured. An empty credential stays + * empty rather than masked, so "not configured" remains distinguishable from "configured". */ - override fun toString(): String = - "GraphConfig(" + - "phase=$phase, tenant=$tenant, " + - "metastoreUrl=$metastoreUrl, metastoreUser=${mask(metastoreUser)}, metastorePassword=${mask(metastorePassword)}, " + - "metastoreDriver=$metastoreDriver, metastoreTable=$metastoreTable, " + - "metastoreReloadInitialDelay=$metastoreReloadInitialDelay, metastoreReloadInterval=$metastoreReloadInterval, " + - "metastoreConnectionPoolSize=$metastoreConnectionPoolSize, defaultStorageEntity=$defaultStorageEntity, " + - "walProperties=$walProperties, cdcProperties=$cdcProperties, topNProperties=$topNProperties, " + - "encoderPoolSize=$encoderPoolSize, lockTimeout=$lockTimeout, hostName=$hostName, warmUp=$warmUp, " + - "artifactInfo=$artifactInfo, mutationRequestTimeout=$mutationRequestTimeout, hbase=$hbase, " + - "metadataFetchLimit=$metadataFetchLimit, systemMutationMode=$systemMutationMode, readOnly=$readOnly, " + - "useJdbcMetastore=$useJdbcMetastore, featureFlags=$featureFlags" + - ")" + fun redacted(): GraphConfig = + copy( + metastoreUser = mask(metastoreUser), + metastorePassword = mask(metastorePassword), + ) companion object { private const val MASK = "****" diff --git a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/GraphConfigTest.kt b/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/GraphConfigTest.kt deleted file mode 100644 index 41ef93163..000000000 --- a/engine/src/test/kotlin/com/kakao/actionbase/v2/engine/GraphConfigTest.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.kakao.actionbase.v2.engine - -import java.lang.reflect.Modifier - -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -import org.junit.jupiter.api.DisplayName -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test - -class GraphConfigTest { - @Nested - @DisplayName("toString") - inner class ToStringTest { - @Test - fun `should mask the metastore credentials`() { - val rendered = - GraphConfig.builder - .withMetastoreUser(USER) - .withMetastorePassword(PASSWORD) - .build() - .toString() - - assertFalse(rendered.contains(USER), "toString() rendered the metastore user") - assertFalse(rendered.contains(PASSWORD), "toString() rendered the metastore password") - assertTrue(rendered.contains("metastoreUser=****"), "toString() did not mask the metastore user") - assertTrue(rendered.contains("metastorePassword=****"), "toString() did not mask the metastore password") - } - - @Test - fun `should render an unset credential as empty rather than masked`() { - val rendered = GraphConfig.builder.build().toString() - - assertTrue(rendered.contains("metastoreUser=, "), "an unset metastore user should not look configured") - assertTrue(rendered.contains("metastorePassword=, "), "an unset metastore password should not look configured") - } - - @Test - fun `should render every property`() { - val rendered = GraphConfig.builder.build().toString() - - val missing = - GraphConfig::class.java.declaredFields - .filterNot { Modifier.isStatic(it.modifiers) } - .map { it.name } - .filterNot { rendered.contains("$it=") } - - assertEquals(emptyList(), missing, "toString() is hand-written and must be extended whenever a property is added") - } - } - - private companion object { - const val USER = "metastore-user-that-must-not-be-logged" - const val PASSWORD = "metastore-password-that-must-not-be-logged" - } -}