Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-01 15:56:59 +04:00
parent ad0b09deae
commit 6480caeeed
13 changed files with 492 additions and 35 deletions

View file

@ -95,6 +95,7 @@ dependencies {
implementation(deps.kotlin.coroutines)
implementation(deps.kotlin.coroutines.rx2)
implementation(deps.kotlin.datetime)
implementation(deps.kotlin.serialization)
/** Logging */

View file

@ -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),
)
}

View file

@ -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,
)

View file

@ -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(

View file

@ -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 = "******"
}
}

View file

@ -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
*/

View file

@ -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,
)
}

View file

@ -1,6 +1,7 @@
plugins {
alias(deps.plugins.kotlin.jvm)
alias(deps.plugins.kotlin.kapt)
alias(deps.plugins.kotlin.serialization)
id("configuration")
}
@ -15,12 +16,13 @@ dependencies {
kapt(deps.hilt.kapt)
// endregion
// region Coroutines
implementation(deps.kotlin.coroutines)
// region Kotlin
api(deps.kotlin.coroutines)
api(deps.kotlin.serialization)
// endregion
// region Time dependencies
implementation(deps.jodatime)
api(deps.jodatime)
// endregion
testImplementation(deps.test.coroutine)

View file

@ -0,0 +1,22 @@
package com.tangem.utils
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
/**
* Extracts all string primitive values from a [JsonElement] tree (recursively into
* objects and arrays). Non-string primitives are ignored.
*/
object JsonStringValuesExtractor {
fun extract(json: JsonElement): List<String> = json.extractStringValues()
private fun JsonElement.extractStringValues(): List<String> = when (this) {
is JsonPrimitive -> if (isString) listOfNotNull(contentOrNull) else emptyList()
is JsonObject -> values.flatMap { it.extractStringValues() }
is JsonArray -> flatMap { it.extractStringValues() }
}
}

View file

@ -0,0 +1,171 @@
package com.tangem.utils
import com.google.common.truth.Truth
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class JsonStringValuesExtractorTest {
@Test
fun `extract returns single value for string primitive`() {
// Arrange
val json = JsonPrimitive("hello")
// Act
val actual = JsonStringValuesExtractor.extract(json)
// Assert
Truth.assertThat(actual).containsExactly("hello")
}
@Test
fun `extract returns empty for numeric primitive`() {
// Arrange
val json = JsonPrimitive(42)
// Act
val actual = JsonStringValuesExtractor.extract(json)
// Assert
Truth.assertThat(actual).isEmpty()
}
@Test
fun `extract returns empty for boolean primitive`() {
// Arrange
val json = JsonPrimitive(true)
// Act
val actual = JsonStringValuesExtractor.extract(json)
// Assert
Truth.assertThat(actual).isEmpty()
}
@Test
fun `extract returns empty for json null`() {
// Act
val actual = JsonStringValuesExtractor.extract(JsonNull)
// Assert
Truth.assertThat(actual).isEmpty()
}
@Test
fun `extract returns all string values from flat object`() {
// Arrange
val json = Json.parseToJsonElement(
"""{"apiKey":"abc","secret":"xyz","count":42,"enabled":true}""",
)
// Act
val actual = JsonStringValuesExtractor.extract(json)
// Assert
Truth.assertThat(actual).containsExactly("abc", "xyz")
}
@Test
fun `extract returns all string values from flat array`() {
// Arrange
val json = Json.parseToJsonElement("""["one","two",3,true,null]""")
// Act
val actual = JsonStringValuesExtractor.extract(json)
// Assert
Truth.assertThat(actual).containsExactly("one", "two").inOrder()
}
@Test
fun `extract recurses into nested objects`() {
// Arrange
val json = Json.parseToJsonElement(
"""{"outer":{"inner":{"key":"deep"}},"top":"shallow"}""",
)
// Act
val actual = JsonStringValuesExtractor.extract(json)
// Assert
Truth.assertThat(actual).containsExactly("deep", "shallow")
}
@Test
fun `extract recurses into nested arrays`() {
// Arrange
val json = Json.parseToJsonElement("""[["a","b"],["c",["d"]]]""")
// Act
val actual = JsonStringValuesExtractor.extract(json)
// Assert
Truth.assertThat(actual).containsExactly("a", "b", "c", "d").inOrder()
}
@Test
fun `extract handles mixed nested objects and arrays`() {
// Arrange
val json = Json.parseToJsonElement(
"""{"keys":["k1","k2"],"nested":{"items":[{"name":"x"},{"name":"y"}]}}""",
)
// Act
val actual = JsonStringValuesExtractor.extract(json)
// Assert
Truth.assertThat(actual).containsExactly("k1", "k2", "x", "y")
}
@Test
fun `extract returns empty for empty object`() {
// Arrange
val json = Json.parseToJsonElement("""{}""")
// Act
val actual = JsonStringValuesExtractor.extract(json)
// Assert
Truth.assertThat(actual).isEmpty()
}
@Test
fun `extract returns empty for empty array`() {
// Arrange
val json = Json.parseToJsonElement("""[]""")
// Act
val actual = JsonStringValuesExtractor.extract(json)
// Assert
Truth.assertThat(actual).isEmpty()
}
@Test
fun `extract preserves duplicate values`() {
// Arrange — extractor does NOT dedupe; that's the caller's concern
val json = Json.parseToJsonElement("""{"a":"same","b":"same","c":"other"}""")
// Act
val actual = JsonStringValuesExtractor.extract(json)
// Assert
Truth.assertThat(actual).containsExactly("same", "same", "other")
}
@Test
fun `extract returns empty string when string primitive is empty`() {
// Arrange
val json = Json.parseToJsonElement("""{"a":"","b":"x"}""")
// Act
val actual = JsonStringValuesExtractor.extract(json)
// Assert — extractor returns "" too; filtering is caller's job
Truth.assertThat(actual).containsExactly("", "x")
}
}