Updated on 2026-08-14
This commit is contained in:
parent
ad0b09deae
commit
6480caeeed
13 changed files with 492 additions and 35 deletions
|
|
@ -16,11 +16,15 @@ import com.tangem.datasource.api.utils.ConnectTimeout
|
|||
import com.tangem.datasource.api.utils.ReadTimeout
|
||||
import com.tangem.datasource.api.utils.WriteTimeout
|
||||
import com.tangem.datasource.di.NetworkMoshi
|
||||
import com.tangem.datasource.local.config.environment.EnvironmentConfig
|
||||
import com.tangem.datasource.local.logs.AppLogsStore
|
||||
import com.tangem.datasource.local.logs.SensitiveUrlMasker
|
||||
import com.tangem.datasource.utils.NetworkLogsSaveInterceptor
|
||||
import com.tangem.datasource.utils.WireMockRedirectInterceptor
|
||||
import com.tangem.datasource.utils.addHeaders
|
||||
import com.tangem.utils.JsonStringValuesExtractor
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Invocation
|
||||
|
|
@ -41,6 +45,7 @@ import javax.inject.Singleton
|
|||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
@Singleton
|
||||
internal class RetrofitApiBuilder @Inject constructor(
|
||||
private val apiConfigs: ApiConfigs,
|
||||
|
|
@ -49,10 +54,20 @@ internal class RetrofitApiBuilder @Inject constructor(
|
|||
private val analyticsErrorHandler: AnalyticsErrorHandler,
|
||||
@ApplicationContext private val context: Context,
|
||||
private val appLogsStore: AppLogsStore,
|
||||
private val environmentConfig: EnvironmentConfig,
|
||||
) {
|
||||
|
||||
private val configsBaseUrls: Map<ApiConfig.ID, Set<String>> = getConfigsBaseUrls()
|
||||
|
||||
private val sensitiveUrlMasker: SensitiveUrlMasker by lazy {
|
||||
val json = Json.encodeToJsonElement(EnvironmentConfig.serializer(), environmentConfig)
|
||||
// Drop URL-shaped values (e.g. public endpoint URLs from config); they are not secrets
|
||||
// and would obscure unrelated requests in logs.
|
||||
val values = JsonStringValuesExtractor.extract(json)
|
||||
.filter { it.isNotBlank() && !it.startsWith("http", ignoreCase = true) }
|
||||
SensitiveUrlMasker(values)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Retrofit API instance for the specified API configuration ID
|
||||
*
|
||||
|
|
@ -179,7 +194,7 @@ internal class RetrofitApiBuilder @Inject constructor(
|
|||
|
||||
private fun OkHttpClient.Builder.applyLogsSaving(): OkHttpClient.Builder {
|
||||
return addInterceptor(
|
||||
interceptor = NetworkLogsSaveInterceptor(appLogsStore),
|
||||
interceptor = NetworkLogsSaveInterceptor(appLogsStore, sensitiveUrlMasker),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import com.tangem.blockchain.common.BlockchainSdkConfig
|
|||
import com.tangem.datasource.local.config.environment.models.ExpressModel
|
||||
import com.tangem.datasource.local.config.environment.models.P2PKeys
|
||||
import com.tangem.datasource.local.config.environment.models.SurveySparrowSwapRatingConfig
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
|
||||
@Serializable
|
||||
data class EnvironmentConfig(
|
||||
val moonPayApiKey: String = "",
|
||||
val moonPayApiSecretKey: String = "",
|
||||
|
|
@ -32,6 +35,7 @@ data class EnvironmentConfig(
|
|||
val gaslessTxApiKey: String? = null,
|
||||
val customerIoCdpApiKey: String? = null,
|
||||
val surveySparrowToken: String? = null,
|
||||
@Transient
|
||||
val surveySparrowSwapRating: SurveySparrowSwapRatingConfig? = null,
|
||||
val authServiceKey: String? = null,
|
||||
)
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
package com.tangem.datasource.local.config.environment.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ExpressModel(val apiKey: String, val signVerifierPublicKey: String)
|
||||
|
||||
@Serializable
|
||||
data class P2PKeys(val mainnet: String, val hoodi: String)
|
||||
|
||||
data class SurveySparrowSwapRatingConfig(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
package com.tangem.datasource.local.logs
|
||||
|
||||
class SensitiveUrlMasker(sensitiveValues: Collection<String>) {
|
||||
|
||||
// Sorted by descending length so a value that is a prefix of another (e.g. "my-node" vs
|
||||
// "my-node-prod") cannot mask the shorter one first and leave the suffix in the log.
|
||||
private val sensitiveValues: List<String> = sensitiveValues
|
||||
.distinct()
|
||||
.sortedByDescending(String::length)
|
||||
|
||||
fun mask(url: String): String {
|
||||
var result = url
|
||||
for (value in sensitiveValues) {
|
||||
if (result.contains(value, ignoreCase = true)) {
|
||||
result = result.replace(value, MASKED_VALUE, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MASKED_VALUE = "******"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
package com.tangem.datasource.utils
|
||||
|
||||
import com.tangem.datasource.local.logs.AppLogsStore
|
||||
import com.tangem.datasource.local.logs.SensitiveUrlMasker
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
|
@ -22,11 +24,15 @@ private const val JSON_INDENT_SPACES = 4
|
|||
* Interceptor for save network requests and responses logs
|
||||
*
|
||||
* @property appLogsStore app logs store
|
||||
* @property sensitiveUrlMasker masker for sensitive data in URLs
|
||||
* @property shouldCheckResponseBodySize whether to skip logging large response bodies
|
||||
*
|
||||
[REDACTED_AUTHOR]
|
||||
*/
|
||||
class NetworkLogsSaveInterceptor(
|
||||
private val appLogsStore: AppLogsStore,
|
||||
private val sensitiveUrlMasker: SensitiveUrlMasker? = null,
|
||||
private val shouldCheckResponseBodySize: Boolean = false,
|
||||
) : Interceptor {
|
||||
|
||||
@Throws(IOException::class)
|
||||
|
|
@ -65,7 +71,7 @@ class NetworkLogsSaveInterceptor(
|
|||
val connection = chain.connection()
|
||||
val connectionProtocol = if (connection != null) " ${connection.protocol()}" else ""
|
||||
|
||||
saveLogMessage("--> ${request.method} ${request.url}$connectionProtocol\n")
|
||||
saveLogMessage("--> ${request.method} ${request.url.maskSensitiveInfo()}$connectionProtocol\n")
|
||||
}
|
||||
|
||||
private fun logRequestMessage(chain: Interceptor.Chain, request: Request) {
|
||||
|
|
@ -73,7 +79,7 @@ class NetworkLogsSaveInterceptor(
|
|||
val connectionProtocol = if (connection != null) " ${connection.protocol()}" else ""
|
||||
|
||||
saveLogMessage(
|
||||
"--> ${request.method} ${request.url}$connectionProtocol\n",
|
||||
"--> ${request.method} ${request.url.maskSensitiveInfo()}$connectionProtocol\n",
|
||||
createRequestEndMessage(request),
|
||||
)
|
||||
}
|
||||
|
|
@ -110,7 +116,7 @@ class NetworkLogsSaveInterceptor(
|
|||
val tookMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs)
|
||||
saveLogMessage(
|
||||
"<-- ${response.code}",
|
||||
" ${response.request.url} (${tookMs}ms)\n",
|
||||
" ${response.request.url.maskSensitiveInfo()} (${tookMs}ms)\n",
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -123,39 +129,45 @@ class NetworkLogsSaveInterceptor(
|
|||
"<-- END HTTP"
|
||||
} else if (bodyHasUnknownEncoding(response.headers)) {
|
||||
"<-- END HTTP (encoded body omitted)"
|
||||
} else if (shouldCheckResponseBodySize && contentLength > WRITE_LOG_THRESHOLD_BYTES_SIZE) {
|
||||
"Response size too large: $contentLength bytes \n<-- END HTTP"
|
||||
} else {
|
||||
val source = responseBody.source()
|
||||
source.request(Long.MAX_VALUE)
|
||||
var buffer = source.buffer
|
||||
|
||||
var gzippedLength: Long? = null
|
||||
if ("gzip".equals(responseHeaders["Content-Encoding"], ignoreCase = true)) {
|
||||
gzippedLength = buffer.size
|
||||
GzipSource(buffer.clone()).use { gzippedResponseBody ->
|
||||
buffer = Buffer()
|
||||
buffer.writeAll(gzippedResponseBody)
|
||||
}
|
||||
}
|
||||
|
||||
val contentType = responseBody.contentType()
|
||||
val charset: Charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
|
||||
|
||||
if (!buffer.isProbablyUtf8()) {
|
||||
"<-- END HTTP (binary ${buffer.size}-byte body omitted)"
|
||||
if (shouldCheckResponseBodySize && buffer.size > WRITE_LOG_THRESHOLD_BYTES_SIZE) {
|
||||
"Response size too large: ${buffer.size} bytes \n<-- END HTTP"
|
||||
} else {
|
||||
val json = if (contentLength != 0L) {
|
||||
buffer.clone().readString(charset).beautifyJson()
|
||||
} else {
|
||||
""
|
||||
var gzippedLength: Long? = null
|
||||
if ("gzip".equals(responseHeaders["Content-Encoding"], ignoreCase = true)) {
|
||||
gzippedLength = buffer.size
|
||||
GzipSource(buffer.clone()).use { gzippedResponseBody ->
|
||||
buffer = Buffer()
|
||||
buffer.writeAll(gzippedResponseBody)
|
||||
}
|
||||
}
|
||||
|
||||
val end = if (gzippedLength != null) {
|
||||
"<-- END HTTP (${buffer.size}-byte, $gzippedLength-gzipped-byte body)"
|
||||
} else {
|
||||
"<-- END HTTP (${buffer.size}-byte body)"
|
||||
}
|
||||
val contentType = responseBody.contentType()
|
||||
val charset: Charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
|
||||
|
||||
"$json\n$end"
|
||||
if (!buffer.isProbablyUtf8()) {
|
||||
"<-- END HTTP (binary ${buffer.size}-byte body omitted)"
|
||||
} else {
|
||||
val json = if (contentLength != 0L) {
|
||||
buffer.clone().readString(charset).beautifyJson()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
val end = if (gzippedLength != null) {
|
||||
"<-- END HTTP (${buffer.size}-byte, $gzippedLength-gzipped-byte body)"
|
||||
} else {
|
||||
"<-- END HTTP (${buffer.size}-byte body)"
|
||||
}
|
||||
|
||||
"$json\n$end"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -166,12 +178,16 @@ class NetworkLogsSaveInterceptor(
|
|||
saveLogMessage(
|
||||
"<-- ${response.code}",
|
||||
spaceBeforeResponseMessage,
|
||||
response.message,
|
||||
" ${response.request.url} (${tookMs}ms)\n",
|
||||
" ${response.request.url.maskSensitiveInfo()} (${tookMs}ms)\n",
|
||||
message,
|
||||
)
|
||||
}
|
||||
|
||||
private fun HttpUrl.maskSensitiveInfo(): String {
|
||||
val url = toString()
|
||||
return sensitiveUrlMasker?.mask(url) ?: url
|
||||
}
|
||||
|
||||
private fun bodyHasUnknownEncoding(headers: Headers): Boolean {
|
||||
val contentEncoding = headers["Content-Encoding"] ?: return false
|
||||
return !contentEncoding.equals("identity", ignoreCase = true) &&
|
||||
|
|
@ -231,6 +247,9 @@ class NetworkLogsSaveInterceptor(
|
|||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
const val WRITE_LOG_THRESHOLD_BYTES_SIZE = 2_048_000L
|
||||
|
||||
/**
|
||||
* List of URLs (host + path) for which logging is restricted
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
package com.tangem.datasource.local.logs
|
||||
|
||||
import com.google.common.truth.Truth
|
||||
import com.tangem.datasource.local.logs.SensitiveUrlMasker.Companion.MASKED_VALUE
|
||||
import com.tangem.test.core.ProvideTestModels
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
internal class SensitiveUrlMaskerTest {
|
||||
|
||||
@ParameterizedTest
|
||||
@ProvideTestModels
|
||||
fun mask(model: TestModel) {
|
||||
// Arrange
|
||||
val masker = SensitiveUrlMasker(model.sensitiveValues)
|
||||
|
||||
// Act
|
||||
val actual = masker.mask(model.input)
|
||||
|
||||
// Assert
|
||||
Truth.assertThat(actual).isEqualTo(model.expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mask returns url unchanged when no sensitive values provided`() {
|
||||
// Arrange
|
||||
val masker = SensitiveUrlMasker(emptyList())
|
||||
val url = "https://api.tangem.com/v1/cards/abc123"
|
||||
|
||||
// Act
|
||||
val actual = masker.mask(url)
|
||||
|
||||
// Assert
|
||||
Truth.assertThat(actual).isEqualTo(url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `constructor deduplicates input values`() {
|
||||
// Arrange — same secret repeated; if no dedup, replace would be invoked twice
|
||||
// (idempotent on already-masked string, but we assert behavior is identical
|
||||
// to a single-value masker as a smoke-check)
|
||||
val withDuplicates = SensitiveUrlMasker(listOf("secret123", "secret123", "secret123"))
|
||||
val withSingle = SensitiveUrlMasker(listOf("secret123"))
|
||||
val url = "https://api.tangem.com/?key=secret123"
|
||||
|
||||
// Act
|
||||
val withDup = withDuplicates.mask(url)
|
||||
val withSingleResult = withSingle.mask(url)
|
||||
|
||||
// Assert
|
||||
Truth.assertThat(withDup).isEqualTo(withSingleResult)
|
||||
Truth.assertThat(withDup).isEqualTo("https://api.tangem.com/?key=$MASKED_VALUE")
|
||||
}
|
||||
|
||||
private fun provideTestModels() = listOf(
|
||||
TestModel(
|
||||
input = "https://api.tangem.com/?key=secret123",
|
||||
sensitiveValues = listOf("secret123"),
|
||||
expected = "https://api.tangem.com/?key=$MASKED_VALUE",
|
||||
),
|
||||
TestModel(
|
||||
input = "https://api.tangem.com/?a=alpha&b=beta",
|
||||
sensitiveValues = listOf("alpha", "beta"),
|
||||
expected = "https://api.tangem.com/?a=$MASKED_VALUE&b=$MASKED_VALUE",
|
||||
),
|
||||
TestModel(
|
||||
input = "https://api.tangem.com/?key=SECRET123",
|
||||
sensitiveValues = listOf("secret123"),
|
||||
expected = "https://api.tangem.com/?key=$MASKED_VALUE",
|
||||
),
|
||||
TestModel(
|
||||
input = "https://api.tangem.com/v1/balance",
|
||||
sensitiveValues = listOf("notInUrl"),
|
||||
expected = "https://api.tangem.com/v1/balance",
|
||||
),
|
||||
TestModel(
|
||||
input = "https://api.tangem.com/?key=secret123&other=secret123",
|
||||
sensitiveValues = listOf("secret123"),
|
||||
expected = "https://api.tangem.com/?key=$MASKED_VALUE&other=$MASKED_VALUE",
|
||||
),
|
||||
TestModel(
|
||||
input = "https://api.tangem.com/v1/cards",
|
||||
sensitiveValues = emptyList(),
|
||||
expected = "https://api.tangem.com/v1/cards",
|
||||
),
|
||||
// Regression: when one value is a prefix of another, the longer one must be masked first
|
||||
// regardless of input order, otherwise the suffix leaks (e.g. "my-node-prod" -> "******-prod").
|
||||
TestModel(
|
||||
input = "https://my-node-prod.example.com/v1",
|
||||
sensitiveValues = listOf("my-node", "my-node-prod"),
|
||||
expected = "https://$MASKED_VALUE.example.com/v1",
|
||||
),
|
||||
TestModel(
|
||||
input = "https://my-node-prod.example.com/v1",
|
||||
sensitiveValues = listOf("my-node-prod", "my-node"),
|
||||
expected = "https://$MASKED_VALUE.example.com/v1",
|
||||
),
|
||||
)
|
||||
|
||||
data class TestModel(
|
||||
val input: String,
|
||||
val sensitiveValues: List<String>,
|
||||
val expected: String,
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue