Skip to content

Commit e95bc3c

Browse files
committed
fix(logging): route breadcrumbs through plugin pipeline and add URL masking
Breadcrumbs sent to BreadcrumbSink (Bugsnag) were bypassing the TraceLogPlugin pipeline, allowing PII to reach Bugsnag unmasked. Apply plugins to breadcrumb messages and metadata before sink dispatch. Also add URL/URI masking to PiiMaskingPlugin to strip query params and fragments that may contain sensitive data (OAuth tokens, payment links). Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent edb0f28 commit e95bc3c

4 files changed

Lines changed: 156 additions & 8 deletions

File tree

libs/logging/src/main/kotlin/com/getcode/utils/Logging.kt

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -187,14 +187,15 @@ fun trace(
187187
TraceType.User -> tree.d(logMessage)
188188
}
189189

190-
val breadcrumb = if (tag != null) {
191-
"$tagBlock $message"
192-
} else {
193-
message
194-
}
195-
196-
TraceManager.sinks().forEach { sink ->
197-
sink.record(breadcrumb, metadataMap, type)
190+
val rawBreadcrumb = if (tag != null) "$tagBlock $message" else message
191+
val sinks = TraceManager.sinks()
192+
if (sinks.isNotEmpty()) {
193+
val (maskedBreadcrumb, maskedMetadata) = applyPluginsForBreadcrumb(
194+
rawBreadcrumb, metadataMap, TraceManager.plugins
195+
)
196+
sinks.forEach { sink ->
197+
sink.record(maskedBreadcrumb, maskedMetadata, type)
198+
}
198199
}
199200

200201
error?.let(ErrorUtils::handleError)
@@ -305,4 +306,20 @@ private fun metadata(block: MetadataBuilder.() -> Unit): Map<String, Any> {
305306
val builder = MetadataBuilder()
306307
builder.block()
307308
return builder.build()
309+
}
310+
311+
private fun applyPluginsForBreadcrumb(
312+
message: String,
313+
metadata: Map<String, Any>,
314+
plugins: List<TraceLogPlugin>,
315+
): Pair<String, Map<String, Any>> {
316+
val maskedMessage = plugins.fold(message) { acc, plugin ->
317+
plugin.process(acc) ?: acc
318+
}
319+
val maskedMetadata = metadata.mapValues { (_, value) ->
320+
plugins.fold(value.toString()) { acc, plugin ->
321+
plugin.process(acc) ?: acc
322+
}
323+
}
324+
return maskedMessage to maskedMetadata
308325
}

libs/logging/src/main/kotlin/com/getcode/utils/PiiMaskingPlugin.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.getcode.utils
22

3+
import java.net.URI
4+
35
class PiiMaskingPlugin : TraceLogPlugin {
46
companion object {
57
// E.164 (+1234567890) and parenthesized area code ((123) 456-7890)
@@ -13,11 +15,31 @@ class PiiMaskingPlugin : TraceLogPlugin {
1315

1416
// JWT tokens: three base64url segments separated by dots
1517
private val JWT_REGEX = Regex("""eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+""")
18+
19+
// URLs with a scheme (scheme://...) up to next whitespace
20+
private val URL_REGEX = Regex("""[a-zA-Z][a-zA-Z0-9+\-.]*://\S+""")
21+
}
22+
23+
private fun maskUrl(raw: String): String {
24+
val stripped = try {
25+
val uri = URI(raw)
26+
val base = buildString {
27+
append(uri.scheme)
28+
append("://")
29+
if (uri.authority != null) append(uri.authority)
30+
if (uri.path != null) append(uri.path)
31+
}
32+
base
33+
} catch (_: Exception) {
34+
raw.substringBefore('?').substringBefore('#')
35+
}
36+
return "[URL:$stripped]"
1637
}
1738

1839
override fun process(line: String): String? {
1940
var result = line
2041
result = JWT_REGEX.replace(result, "[JWT]")
42+
result = URL_REGEX.replace(result) { maskUrl(it.value) }
2143
result = EMAIL_REGEX.replace(result, "[EMAIL]")
2244
result = SOLANA_KEY_REGEX.replace(result) { match ->
2345
val key = match.value

libs/logging/src/test/kotlin/com/getcode/utils/PiiMaskingPluginTest.kt

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,4 +187,63 @@ class PiiMaskingPluginTest {
187187
assertTrue(result.contains("[KEY:7EcD...FLtV]"))
188188
assertTrue(result.contains("[KEY:9WzD...AWWM]"))
189189
}
190+
191+
// --- URL masking ---
192+
193+
@Test
194+
fun `masks https URL with query params`() {
195+
val line = "Opening https://pay.coinbase.com/buy?appId=abc123&token=secret"
196+
val result = plugin.process(line)
197+
assertNotNull(result)
198+
assertEquals("Opening [URL:https://pay.coinbase.com/buy]", result)
199+
}
200+
201+
@Test
202+
fun `masks URL with fragment`() {
203+
val line = "Visit https://example.com/page#section"
204+
val result = plugin.process(line)
205+
assertNotNull(result)
206+
assertEquals("Visit [URL:https://example.com/page]", result)
207+
}
208+
209+
@Test
210+
fun `preserves URL without query params`() {
211+
val line = "Loaded https://example.com/path"
212+
val result = plugin.process(line)
213+
assertNotNull(result)
214+
assertEquals("Loaded [URL:https://example.com/path]", result)
215+
}
216+
217+
@Test
218+
fun `masks deeplink-style URL with query params`() {
219+
val line = "Handling myapp://callback?code=authcode123&state=xyz"
220+
val result = plugin.process(line)
221+
assertNotNull(result)
222+
assertEquals("Handling [URL:myapp://callback]", result)
223+
}
224+
225+
@Test
226+
fun `does not mask non-URL strings`() {
227+
val line = "No URLs here, just plain text"
228+
val result = plugin.process(line)
229+
assertEquals(line, result)
230+
}
231+
232+
@Test
233+
fun `masks multiple URLs in one line`() {
234+
val line = "from https://a.com/x?k=v to https://b.com/y?t=1"
235+
val result = plugin.process(line)
236+
assertNotNull(result)
237+
assertEquals("from [URL:https://a.com/x] to [URL:https://b.com/y]", result)
238+
}
239+
240+
@Test
241+
fun `URL masking runs before Solana key masking`() {
242+
// A URL with a base58-like query param should be masked as a URL, not a key
243+
val line = "https://pay.coinbase.com/buy?dest=7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV"
244+
val result = plugin.process(line)
245+
assertNotNull(result)
246+
assertTrue(result.startsWith("[URL:"))
247+
assertTrue(!result.contains("[KEY:"))
248+
}
190249
}

libs/logging/src/test/kotlin/com/getcode/utils/TraceManagerTest.kt

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,4 +141,54 @@ class TraceManagerTest {
141141
assertNotNull(TraceManager.getLogFile())
142142
assertTrue(TraceManager.plugins.any { it is PiiMaskingPlugin })
143143
}
144+
145+
@Test
146+
fun `breadcrumbs are masked through plugin pipeline`() {
147+
val context = RuntimeEnvironment.getApplication()
148+
TraceManager.initialize(context)
149+
150+
val recorded = mutableListOf<Pair<String, Map<String, Any>>>()
151+
val sink = object : BreadcrumbSink {
152+
override fun record(message: String, metadata: Map<String, Any>, type: TraceType) {
153+
recorded.add(message to metadata)
154+
}
155+
}
156+
TraceManager.addSink(sink)
157+
158+
trace(
159+
message = "Login by alice@example.com",
160+
metadata = {
161+
"token" to "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig_here"
162+
}
163+
)
164+
165+
assertEquals(1, recorded.size)
166+
val (msg, meta) = recorded.first()
167+
assertTrue(msg.contains("[EMAIL]"), "Email in message should be masked")
168+
assertTrue(!msg.contains("alice@example.com"), "Raw email should not appear")
169+
assertTrue(meta["token"].toString().contains("[JWT]"), "JWT in metadata should be masked")
170+
}
171+
172+
@Test
173+
fun `breadcrumb plugin null return keeps message`() {
174+
val context = RuntimeEnvironment.getApplication()
175+
TraceManager.initialize(context)
176+
177+
// A plugin that returns null (drop signal) should not suppress breadcrumbs
178+
val dropPlugin = TraceLogPlugin { null }
179+
TraceManager.addPlugin(dropPlugin)
180+
181+
val recorded = mutableListOf<String>()
182+
val sink = object : BreadcrumbSink {
183+
override fun record(message: String, metadata: Map<String, Any>, type: TraceType) {
184+
recorded.add(message)
185+
}
186+
}
187+
TraceManager.addSink(sink)
188+
189+
trace(message = "should survive")
190+
191+
assertEquals(1, recorded.size)
192+
assertTrue(recorded.first().contains("should survive"))
193+
}
144194
}

0 commit comments

Comments
 (0)