[PM-24380] fix: Correct and redact flight recorder hostname on logs (#6633)

This commit is contained in:
aj-rosado
2026-04-29 18:37:56 +01:00
committed by GitHub
parent 3845c1fb13
commit b3848ffdb4
4 changed files with 205 additions and 4 deletions

View File

@@ -2,6 +2,7 @@ package com.bitwarden.data.manager.flightrecorder
import android.os.Build
import android.util.Log
import androidx.core.net.toUri
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.core.data.manager.BuildInfoManager
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
@@ -10,6 +11,7 @@ import com.bitwarden.core.data.util.toFormattedPattern
import com.bitwarden.data.datasource.disk.model.FlightRecorderDataSet
import com.bitwarden.data.manager.file.FileManager
import com.bitwarden.data.repository.ServerConfigRepository
import com.bitwarden.network.util.redactHostnamesInMessage
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.BufferedWriter
@@ -34,6 +36,21 @@ internal class FlightRecorderWriterImpl(
private val buildInfoManager: BuildInfoManager,
private val serverConfigRepository: ServerConfigRepository,
) : FlightRecorderWriter {
private val configuredHosts: Set<String>
get() {
val environment = serverConfigRepository.serverConfigStateFlow.value
?.serverData?.environment ?: return emptySet()
return listOfNotNull(
environment.vaultUrl,
environment.apiUrl,
environment.identityUrl,
environment.notificationsUrl,
environment.ssoUrl,
)
.mapNotNull { it.toUri().host }
.toSet()
}
override suspend fun deleteLog(data: FlightRecorderDataSet.FlightRecorderData) {
fileManager.delete(File(File(fileManager.logsDirectory), data.fileName))
}
@@ -98,6 +115,7 @@ internal class FlightRecorderWriterImpl(
val formattedTime = clock
.instant()
.toFormattedPattern(pattern = LOG_TIME_PATTERN, clock = clock)
val hosts = configuredHosts
withContext(context = dispatcherManager.io) {
runCatching {
BufferedWriter(FileWriter(logFile, true)).use { bw ->
@@ -109,10 +127,10 @@ internal class FlightRecorderWriterImpl(
bw.append(it)
}
bw.append(" ")
bw.append(message)
bw.append(message.redactHostnamesInMessage(hosts))
throwable?.let {
bw.append(" ")
bw.append(it.getStackTraceString())
bw.append(it.getStackTraceString().redactHostnamesInMessage(hosts))
}
bw.newLine()
}

View File

@@ -16,6 +16,8 @@ import java.lang.reflect.Type
*/
private const val NO_CONTENT_RESPONSE_CODE: Int = 204
private val UNKNOWN_HOST_REGEX = Regex("""Unable to resolve host "([^"]+)"""")
/**
* A [Call] for wrapping a network request into a [NetworkResult].
*/
@@ -67,8 +69,16 @@ internal class NetworkResultCall<T>(
fun executeForResult(): NetworkResult<T> = requireNotNull(execute().body())
private fun Throwable.toFailure(): NetworkResult<T> {
// We rebuild the URL without query params, we do not want to log those
val url = backingCall.request().url.toUrl().run { "$protocol://$authority$path" }
val originalUrl = backingCall.request().url.toUrl()
val extractedHost = message?.let { UNKNOWN_HOST_REGEX.find(it)?.groupValues?.getOrNull(1) }
val url = if (extractedHost != null) {
"${originalUrl.protocol}://$extractedHost${originalUrl.path}"
} else {
"${originalUrl.protocol}://${originalUrl.authority}${originalUrl.path}"
}
Timber.w(this, "Network Error: $url")
return NetworkResult.Failure(this)
}

View File

@@ -0,0 +1,27 @@
package com.bitwarden.network.util
/**
* List of official Bitwarden cloud hostnames that are safe to log.
*/
private val BITWARDEN_HOSTS = listOf("bitwarden.com", "bitwarden.eu", "bitwarden.pw")
/**
* Redacts hostnames in a log message by replacing bare hostnames with [REDACTED_SELF_HOST].
*
* Only redacts hostnames that match [configuredHosts] AND are not official Bitwarden domains.
* Preserves all Bitwarden domains (including QA/dev environments).
*
* @param configuredHosts Set of hostnames to redact
* @return Message with hostnames redacted as [REDACTED_SELF_HOST]
*/
fun String.redactHostnamesInMessage(configuredHosts: Set<String>): String =
configuredHosts.fold(this) { result, hostname ->
val escapedHostname = Regex.escape(hostname)
val bareHostnamePattern = Regex("""\b$escapedHostname\b""")
bareHostnamePattern.replace(result) { hostname.redactIfSelfHosted() }
}
private fun String.redactIfSelfHosted(): String {
val isBitwardenHost = BITWARDEN_HOSTS.any { this.endsWith(it) }
return if (isBitwardenHost) this else "[REDACTED_SELF_HOST]"
}

View File

@@ -0,0 +1,146 @@
package com.bitwarden.network.util
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class HostnameRedactionUtilTest {
@Test
fun `redactHostnamesInMessage redacts configured self-hosted URLs`() {
val message = "--> GET https://vault.example.com/api/sync HTTP/1.1"
val configuredHosts = setOf("vault.example.com")
val result = message.redactHostnamesInMessage(configuredHosts)
assertEquals("--> GET https://[REDACTED_SELF_HOST]/api/sync HTTP/1.1", result)
}
@Test
fun `redactHostnamesInMessage preserves non-configured URLs`() {
val message = "--> GET https://vault.example.com/api/sync HTTP/1.1"
val configuredHosts = setOf("api.bitwarden.com") // Different host
val result = message.redactHostnamesInMessage(configuredHosts)
assertEquals(message, result) // Unchanged - not in configured hosts
}
@Test
fun `redactHostnamesInMessage preserves Bitwarden URLs even if configured`() {
val message = "--> GET https://vault.bitwarden.com/api/sync HTTP/1.1"
val configuredHosts = setOf("vault.bitwarden.com")
val result = message.redactHostnamesInMessage(configuredHosts)
assertEquals(message, result) // Unchanged - Bitwarden domain preserved
}
@Test
fun `redactHostnamesInMessage redacts quoted hostnames in error messages`() {
val message = """Unable to resolve host "vault.example.com": No address"""
val configuredHosts = setOf("vault.example.com")
val result = message.redactHostnamesInMessage(configuredHosts)
assertEquals("""Unable to resolve host "[REDACTED_SELF_HOST]": No address""", result)
}
@Test
fun `redactHostnamesInMessage handles multiple URLs in one message`() {
val message = "Redirect from https://old.corp.com to https://new.corp.com"
val configuredHosts = setOf("old.corp.com", "new.corp.com")
val result = message.redactHostnamesInMessage(configuredHosts)
assertEquals(
"Redirect from https://[REDACTED_SELF_HOST] to https://[REDACTED_SELF_HOST]",
result,
)
}
@Test
fun `redactHostnamesInMessage handles empty configured hosts`() {
val message = "--> GET https://vault.example.com/api HTTP/1.1"
val configuredHosts = emptySet<String>()
val result = message.redactHostnamesInMessage(configuredHosts)
assertEquals(message, result) // Unchanged - no hosts to redact
}
@Test
fun `redactHostnamesInMessage handles NetworkCookieManagerImpl getCookies pattern`() {
val message = "2026-03-09 12:43:29:857 DEBUG NetworkCookieManagerImpl " +
"getCookies(vault.example.com): resolved=vault.example.com, count=0"
val configuredHosts = setOf("vault.example.com")
val result = message.redactHostnamesInMessage(configuredHosts)
assertEquals(
"2026-03-09 12:43:29:857 DEBUG NetworkCookieManagerImpl " +
"getCookies([REDACTED_SELF_HOST]): resolved=[REDACTED_SELF_HOST], count=0",
result,
)
}
@Test
fun `redactHostnamesInMessage preserves Bitwarden domains in NetworkCookieManagerImpl logs`() {
val message = "2026-03-09 12:43:29:857 DEBUG NetworkCookieManagerImpl " +
"getCookies(vault.example.com): resolved=vault.qa.bitwarden.pw, count=0"
val configuredHosts = setOf("vault.example.com", "vault.qa.bitwarden.pw")
val result = message.redactHostnamesInMessage(configuredHosts)
assertEquals(
"2026-03-09 12:43:29:857 DEBUG NetworkCookieManagerImpl " +
"getCookies([REDACTED_SELF_HOST]): resolved=vault.qa.bitwarden.pw, count=0",
result,
)
}
@Test
fun `redactHostnamesInMessage handles UnknownHostException error message`() {
val message = "DEBUG BitwardenNetworkClient <-- HTTP FAILED: " +
"java.net.UnknownHostException: Unable to resolve host " +
"\"vault.example.com\": No address associated with hostname."
val configuredHosts = setOf("vault.example.com")
val result = message.redactHostnamesInMessage(configuredHosts)
assertEquals(
"DEBUG BitwardenNetworkClient <-- HTTP FAILED: " +
"java.net.UnknownHostException: Unable to resolve host " +
"\"[REDACTED_SELF_HOST]\": No address associated with hostname.",
result,
)
}
@Test
fun `redactHostnamesInMessage handles needsBootstrap pattern`() {
val message = "2026-03-09 12:43:29:851 DEBUG NetworkCookieManagerImpl " +
"needsBootstrap(vault.example.com): false (cookieDomain=null)"
val configuredHosts = setOf("vault.example.com")
val result = message.redactHostnamesInMessage(configuredHosts)
assertEquals(
"2026-03-09 12:43:29:851 DEBUG NetworkCookieManagerImpl " +
"needsBootstrap([REDACTED_SELF_HOST]): false (cookieDomain=null)",
result,
)
}
@Test
fun `redactHostnamesInMessage handles resolveHostname pattern`() {
val message = "2026-03-09 12:43:29:855 DEBUG NetworkCookieManagerImpl " +
"resolveHostname(vault.example.com): no stored config found, using original"
val configuredHosts = setOf("vault.example.com")
val result = message.redactHostnamesInMessage(configuredHosts)
assertEquals(
"2026-03-09 12:43:29:855 DEBUG NetworkCookieManagerImpl " +
"resolveHostname([REDACTED_SELF_HOST]): no stored config found, using original",
result,
)
}
}