Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions libs/fincore-observability/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -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("(?<!\\d)\\d{13,}(?!\\d)")

fun mask(input: String): String =
input
.replace(BEARER, REDACTION)
.replace(EMAIL, REDACTION)
.replace(LONG_DIGITS, REDACTION)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.observability

import ch.qos.logback.classic.spi.ILoggingEvent
import org.springframework.boot.json.JsonWriter
import org.springframework.boot.logging.structured.StructuredLoggingJsonMembersCustomizer
import java.util.function.UnaryOperator

class PiiMaskingMembersCustomizer : StructuredLoggingJsonMembersCustomizer<ILoggingEvent> {
override fun customize(members: JsonWriter.Members<ILoggingEvent>) {
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")
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
1 change: 1 addition & 0 deletions services/decision/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions services/decision/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ logging:
structured:
format:
console: logstash
json:
customizer: com.fincore.observability.PiiMaskingMembersCustomizer
fincore:
decision:
api:
Expand Down
1 change: 1 addition & 0 deletions services/ledger/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions services/ledger/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ logging:
structured:
format:
console: logstash
json:
customizer: com.fincore.observability.PiiMaskingMembersCustomizer
management:
endpoint:
health:
Expand Down
1 change: 1 addition & 0 deletions services/payments/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions services/payments/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,5 @@ logging:
structured:
format:
console: logstash
json:
customizer: com.fincore.observability.PiiMaskingMembersCustomizer
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading