diff --git a/libs/fincore-observability/build.gradle.kts b/libs/fincore-observability/build.gradle.kts new file mode 100644 index 0000000..f6f5955 --- /dev/null +++ b/libs/fincore-observability/build.gradle.kts @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.spring) +} + +description = "FinCore observability: structured-log PII masking shared across services" + +kotlin { + jvmToolchain(21) +} + +// Boot BOM via Gradle's native platform() so version-less Spring deps resolve, scoped to +// the dependency configurations only (does not apply the BOM to detekt's configuration). +val springBootBom = "org.springframework.boot:spring-boot-dependencies:${libs.versions.spring.boot.get()}" + +dependencies { + implementation(platform(springBootBom)) + implementation(libs.kotlin.stdlib) + + // spring-boot-starter brings spring-boot core (StructuredLoggingJsonMembersCustomizer / JsonWriter) + // and logback-classic (ILoggingEvent) transitively. + implementation(libs.spring.boot.starter) + + testImplementation(platform(springBootBom)) + testImplementation(libs.spring.boot.starter.test) + testImplementation(libs.kotest.assertions.core) + testImplementation(libs.kotest.runner.junit5) + testImplementation(libs.jackson.module.kotlin) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/libs/fincore-observability/src/main/kotlin/com/fincore/observability/PiiMasker.kt b/libs/fincore-observability/src/main/kotlin/com/fincore/observability/PiiMasker.kt new file mode 100644 index 0000000..4de2637 --- /dev/null +++ b/libs/fincore-observability/src/main/kotlin/com/fincore/observability/PiiMasker.kt @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.observability + +object PiiMasker { + const val REDACTION = "[REDACTED]" + + private val BEARER = Regex("(?i)Bearer\\s{1,4}[A-Za-z0-9._~+/=-]{8,512}") + private val EMAIL = Regex("[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\\.[A-Za-z]{2,24}") + + // 13+ contiguous digits (PAN/account/long-id); the lookarounds take the maximal run so a 20+ digit + // sequence is masked whole, while a UUID's fixed 12-digit node group stays below the threshold. + private val LONG_DIGITS = Regex("(? { + override fun customize(members: JsonWriter.Members) { + members.applyingValueProcessor( + JsonWriter.ValueProcessor + .of(String::class.java, UnaryOperator { PiiMasker.mask(it) }) + .whenHasPath { it.name() !in SKIP_KEYS }, + ) + } + + private companion object { + val SKIP_KEYS = setOf("correlation_id", "trace_id", "span_id") + } +} diff --git a/libs/fincore-observability/src/test/kotlin/com/fincore/observability/PiiMaskerTest.kt b/libs/fincore-observability/src/test/kotlin/com/fincore/observability/PiiMaskerTest.kt new file mode 100644 index 0000000..b274a35 --- /dev/null +++ b/libs/fincore-observability/src/test/kotlin/com/fincore/observability/PiiMaskerTest.kt @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.observability + +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain +import org.junit.jupiter.api.Test + +class PiiMaskerTest { + @Test + fun `should mask an email address`() { + val masked = PiiMasker.mask("contact user@example.test now") + + masked shouldNotContain "user@example.test" + masked shouldContain PiiMasker.REDACTION + } + + @Test + fun `should mask a long digit run in the pan range`() { + val masked = PiiMasker.mask("card 4111111111111111 used") + + masked shouldNotContain "4111111111111111" + masked shouldContain PiiMasker.REDACTION + } + + @Test + fun `should leave a short digit sequence intact`() { + PiiMasker.mask("order 12345 placed") shouldBe "order 12345 placed" + } + + @Test + fun `should leave a twelve digit run intact so uuid node groups survive`() { + PiiMasker.mask("node 426614174000 here") shouldBe "node 426614174000 here" + } + + @Test + fun `should mask a long digit run beyond the pan upper bound`() { + val masked = PiiMasker.mask("ref 123456789012345678901234 end") + + masked shouldNotContain "123456789012345678901234" + masked shouldContain PiiMasker.REDACTION + } + + @Test + fun `should mask a bearer token`() { + val masked = PiiMasker.mask("Authorization: Bearer faketoken_aBcD1234efGh") + + masked shouldNotContain "faketoken_aBcD1234efGh" + masked shouldContain PiiMasker.REDACTION + } + + @Test + fun `should leave a bearer token with a too-short body intact`() { + PiiMasker.mask("Authorization: Bearer tok") shouldBe "Authorization: Bearer tok" + } + + @Test + fun `should leave a uuid correlation id intact`() { + val uuid = "123e4567-e89b-12d3-a456-426614174000" + + PiiMasker.mask("correlation $uuid") shouldBe "correlation $uuid" + } + + @Test + fun `should be idempotent when re-masking already-masked text`() { + val once = PiiMasker.mask("email user@example.test") + + PiiMasker.mask(once) shouldBe once + } + + @Test + fun `should return the input unchanged when nothing matches`() { + PiiMasker.mask("a plain log line") shouldBe "a plain log line" + } +} diff --git a/libs/fincore-observability/src/test/kotlin/com/fincore/observability/PiiMaskingMembersCustomizerTest.kt b/libs/fincore-observability/src/test/kotlin/com/fincore/observability/PiiMaskingMembersCustomizerTest.kt new file mode 100644 index 0000000..ebb0aec --- /dev/null +++ b/libs/fincore-observability/src/test/kotlin/com/fincore/observability/PiiMaskingMembersCustomizerTest.kt @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: BUSL-1.1 +// SPDX-FileCopyrightText: 2026 FinCore Engine Authors + +package com.fincore.observability + +import ch.qos.logback.classic.Level +import ch.qos.logback.classic.LoggerContext +import ch.qos.logback.classic.spi.LoggingEvent +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain +import org.junit.jupiter.api.Test +import org.springframework.boot.logging.logback.StructuredLogEncoder +import org.springframework.core.env.Environment +import org.springframework.core.env.MapPropertySource +import org.springframework.core.env.StandardEnvironment + +class PiiMaskingMembersCustomizerTest { + private val uuid = "123e4567-e89b-12d3-a456-426614174000" + + private val encoder: StructuredLogEncoder = + StructuredLogEncoder().apply { + setFormat("logstash") + val environment = + StandardEnvironment().apply { + propertySources.addFirst( + MapPropertySource( + "test", + mapOf("logging.structured.json.customizer" to PiiMaskingMembersCustomizer::class.java.name), + ), + ) + } + context = LoggerContext().apply { putObject(Environment::class.java.name, environment) } + start() + } + + @Test + fun `should mask pii in the message while keeping the correlation id and json shape`() { + val event = + LoggingEvent().apply { + loggerName = "com.fincore.observability.test" + level = Level.INFO + setMessage("login user@example.test card 4111111111111111") + timeStamp = System.currentTimeMillis() + mdcPropertyMap = mapOf("correlation_id" to uuid, "trace_id" to uuid, "span_id" to uuid) + } + + val json = jacksonObjectMapper().readTree(encoder.encode(event)) + + json.get("message").asText() shouldNotContain "user@example.test" + json.get("message").asText() shouldNotContain "4111111111111111" + json.get("message").asText() shouldContain PiiMasker.REDACTION + json.get("correlation_id").asText() shouldBe uuid + json.get("trace_id").asText() shouldBe uuid + json.get("span_id").asText() shouldBe uuid + json.hasNonNull("@timestamp") shouldBe true + json.get("level").asText() shouldBe "INFO" + json.get("logger_name").asText() shouldBe "com.fincore.observability.test" + } +} diff --git a/services/decision/build.gradle.kts b/services/decision/build.gradle.kts index dc0540a..1163fdb 100644 --- a/services/decision/build.gradle.kts +++ b/services/decision/build.gradle.kts @@ -17,6 +17,7 @@ kotlin { dependencies { implementation(project(":libs:decision-engine")) + implementation(project(":libs:fincore-observability")) implementation(libs.kotlin.stdlib) implementation(libs.kotlin.reflect) implementation(libs.jackson.module.kotlin) diff --git a/services/decision/src/main/resources/application.yml b/services/decision/src/main/resources/application.yml index f5eed2b..f770cdf 100644 --- a/services/decision/src/main/resources/application.yml +++ b/services/decision/src/main/resources/application.yml @@ -52,6 +52,8 @@ logging: structured: format: console: logstash + json: + customizer: com.fincore.observability.PiiMaskingMembersCustomizer fincore: decision: api: diff --git a/services/ledger/build.gradle.kts b/services/ledger/build.gradle.kts index daceb44..d664490 100644 --- a/services/ledger/build.gradle.kts +++ b/services/ledger/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { implementation(project(":libs:fincore-core")) implementation(project(":libs:fincore-events")) implementation(project(":libs:fincore-eventbus")) + implementation(project(":libs:fincore-observability")) implementation(libs.kotlin.stdlib) implementation(libs.kotlin.reflect) diff --git a/services/ledger/src/main/resources/application.yml b/services/ledger/src/main/resources/application.yml index 983f17e..180e2fa 100644 --- a/services/ledger/src/main/resources/application.yml +++ b/services/ledger/src/main/resources/application.yml @@ -34,6 +34,8 @@ logging: structured: format: console: logstash + json: + customizer: com.fincore.observability.PiiMaskingMembersCustomizer management: endpoint: health: diff --git a/services/payments/build.gradle.kts b/services/payments/build.gradle.kts index c5dffbc..19e3195 100644 --- a/services/payments/build.gradle.kts +++ b/services/payments/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { implementation(project(":libs:fincore-core")) implementation(project(":libs:fincore-events")) implementation(project(":libs:fincore-eventbus")) + implementation(project(":libs:fincore-observability")) implementation(project(":libs:decision-engine")) implementation(libs.kotlin.stdlib) diff --git a/services/payments/src/main/resources/application.yml b/services/payments/src/main/resources/application.yml index 5646400..750d0f6 100644 --- a/services/payments/src/main/resources/application.yml +++ b/services/payments/src/main/resources/application.yml @@ -46,3 +46,5 @@ logging: structured: format: console: logstash + json: + customizer: com.fincore.observability.PiiMaskingMembersCustomizer diff --git a/settings.gradle.kts b/settings.gradle.kts index fd93ea7..c57711b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,6 +24,7 @@ include( ":libs:fincore-core", ":libs:fincore-events", ":libs:fincore-eventbus", + ":libs:fincore-observability", ":libs:fincore-test-support", ":libs:decision-engine", ":services:ledger",