diff --git a/.claude/skills/analyze-logs/SKILL.md b/.claude/skills/analyze-logs/SKILL.md index 4ecc6e0104..f26b769abd 100644 --- a/.claude/skills/analyze-logs/SKILL.md +++ b/.claude/skills/analyze-logs/SKILL.md @@ -2,7 +2,7 @@ name: analyze-logs description: Analyze Tangem app user logs — extract device info, navigation path, errors, and key events timeline. Use when user provides a log file for bug investigation. allowed-tools: Read, Grep -argument-hint: /path/to/logfile.txt [/path/to/logs.rtf] +argument-hint: /path/to/logfile.txt [/path/to/logs.rtf] [--no-secrets-audit] --- Analyze the Tangem app user log file at path: `$ARGUMENTS` @@ -108,12 +108,37 @@ Launch ALL Grep calls below in parallel. Steps 2+3 search the **full file** (dev - `MainActivity.*onNewIntent` — deep link or push notification - `CardSDK_Session.*start card session` — NFC session starts +**Secrets & PII Audit (full file, head_limit: 20 each, -n: true):** + +Skip this entire group if `--no-secrets-audit` is in arguments. + +- API key leak in URL: `[?&](api[_-]?key|apiKey|access_token|token|secret)=(?!\*+)[^&\s]{8,}` +- Bearer token: `Bearer\s+[A-Za-z0-9._\-]{20,}` +- JWT: `eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+` +- Authorization header: `(?i)authorization:\s*\S+` +- Critical PII in JSON: `"(privateKey|mnemonic|seedPhrase|private_key|seed_phrase)"\s*:\s*"[^"]+"` +- card_public_key in JSON: `"card_public_key"\s*:\s*"[^"]{40,}"` +- FCM push token: `:APA91[A-Za-z0-9_\-]{100,}` +- xprv/tprv extended private key: `\b[xytzuv]prv[A-Za-z0-9]{100,}` +- Suspicious long hex in URL path: `https?://[^?\s]+/[A-Fa-f0-9]{32,}\b` +- Masking health check: count of `\*{6,}` — if 0 in a build that should mask, flag pipeline broken + **Error filtering:** When processing error results, skip these noisy matches: - `java.io.IOException: Canceled` — normal request cancellation - `HttpException(code=304` — HTTP "Not Modified" - Bare stacktrace lines starting with `\tat` - `<-- HTTP FAILED: java.io.IOException: Canceled` +### Step 6.5: Masking Consistency Check + +Skip if `--no-secrets-audit` in arguments. Run sequentially after the parallel batch (needs results from the masked-endpoint grep). + +1. Grep `https?://[^/\s]+/[^\s*]*\*{6,}` (full file, head_limit: 50) — collect all URLs where a path segment is masked +2. For each unique `host + path-prefix-before-mask`, derive the prefix string +3. For each prefix, Grep the prefix followed by a non-`*` character (`[^*\s]`, head_limit: 20) + - If hits found → masking inconsistency: same endpoint has both masked and unmasked variants + - Record the prefix, count of masked hits, count of unmasked hits, first unmasked line number + ### Step 7: Deep Dive For each significant error found above: @@ -207,6 +232,37 @@ Structure your report EXACTLY as follows: |------|-------|---------| (chronological: app starts, card sessions, navigation, errors, notable API calls) +## Secrets & PII Audit + +Omit this section entirely if `--no-secrets-audit` was passed. + +### Health Check +- Total masked tokens (`******`) in log: **N** +- If N = 0 in a build expected to mask, flag: "masking pipeline may be broken" + +### Confirmed Leaks (CRITICAL / HIGH) +| Line | Severity | Type | Matched (first 16 chars + `…`) | Context | +|------|----------|------|--------------------------------|---------| + +### Masking Inconsistencies +| Endpoint Prefix | Masked Hits | Unmasked Hits | First Unmasked Line | +|-----------------|-------------|---------------|---------------------| + +### Suspected Leaks (MEDIUM / LOW) +| Line | Severity | Type | Pattern Matched | Why Suspect | +|------|----------|------|-----------------|-------------| + +**Severity legend:** +- **CRITICAL** — private key / mnemonic / xprv in clear text +- **HIGH** — API key / bearer / JWT / card_public_key visible +- **MEDIUM** — push token, card_id, persistent identifiers +- **LOW** — heuristic patterns that may be false positives (tx hash, content hash) + +**Output rules:** +- Never include the full matched value — always truncate to 16 chars + `…` +- For LOW severity, add a "Why Suspect" column explaining typical false positives +- Skip matches from these known-public Tangem endpoints: `/v1/coins/settings`, `/v1/geo`, `/v1/currencies`, `/v1/hot_crypto` + ## Analysis Summary (2-3 paragraphs: what the user was doing, what broke, probable cause, recommendations. If FOCUS_AREA was specified, emphasize errors, navigation, and API calls related to that area.) diff --git a/app/src/main/java/com/tangem/tap/common/log/TangemLoggingInitializer.kt b/app/src/main/java/com/tangem/tap/common/log/TangemLoggingInitializer.kt index 6e9e000506..a2496c3b32 100644 --- a/app/src/main/java/com/tangem/tap/common/log/TangemLoggingInitializer.kt +++ b/app/src/main/java/com/tangem/tap/common/log/TangemLoggingInitializer.kt @@ -4,15 +4,20 @@ import android.app.Application import com.chuckerteam.chucker.api.ChuckerInterceptor import com.tangem.Log import com.tangem.TangemSdkLogger +import com.tangem.blockchain.common.BlockchainSdkConfig import com.tangem.blockchain.network.BlockchainSdkRetrofitBuilder import com.tangem.datasource.api.common.createNetworkLoggingInterceptor +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.domain.common.LogConfig import com.tangem.operations.attestation.api.TangemApiServiceSettings +import com.tangem.utils.JsonStringValuesExtractor import com.tangem.utils.logging.TangemLogger import com.tangem.wallet.BuildConfig +import kotlinx.serialization.json.Json /** * Owns all app-startup wiring of the logging subsystem in a single place: @@ -23,12 +28,15 @@ import com.tangem.wallet.BuildConfig * @property appLogsStore app logs store used by file-based writer and the network logs save * interceptor * @property tangemSdkLogger Card SDK logger registered with [Log.addLogger] + * @property environmentConfig source of [BlockchainSdkConfig] used to build the blockchain + * URL masker * [REDACTED_AUTHOR] */ class TangemLoggingInitializer( private val appLogsStore: AppLogsStore, private val tangemSdkLogger: TangemSdkLogger, + private val environmentConfig: EnvironmentConfig, ) { fun initAppLogging() { @@ -64,6 +72,13 @@ class TangemLoggingInitializer( } add(createNetworkLoggingInterceptor()) add(ChuckerInterceptor(application)) + add( + NetworkLogsSaveInterceptor( + appLogsStore = appLogsStore, + sensitiveUrlMasker = createBlockchainSensitiveUrlMasker(), + shouldCheckResponseBodySize = true, + ), + ) } TangemApiServiceSettings.addInterceptors( @@ -77,4 +92,16 @@ class TangemLoggingInitializer( }.toTypedArray(), ) } + + private fun createBlockchainSensitiveUrlMasker(): SensitiveUrlMasker { + val json = Json.encodeToJsonElement( + BlockchainSdkConfig.serializer(), + environmentConfig.blockchainSdkConfig, + ) + // Drop URL-shaped values (e.g. public endpoint URLs from BlockchainSdkConfig like + // kaspaSecondaryApiUrl); they are not secrets and would obscure unrelated requests in logs. + val values = JsonStringValuesExtractor.extract(json) + .filter { it.isNotBlank() && !it.startsWith("http", ignoreCase = true) } + return SensitiveUrlMasker(values) + } } \ No newline at end of file diff --git a/app/src/main/java/com/tangem/tap/di/data/TangemLoggingModule.kt b/app/src/main/java/com/tangem/tap/di/data/TangemLoggingModule.kt index f6dc626675..0086789a1c 100644 --- a/app/src/main/java/com/tangem/tap/di/data/TangemLoggingModule.kt +++ b/app/src/main/java/com/tangem/tap/di/data/TangemLoggingModule.kt @@ -1,6 +1,7 @@ package com.tangem.tap.di.data import com.tangem.blockchain.common.logging.BlockchainSDKLogger +import com.tangem.datasource.local.config.environment.EnvironmentConfig import com.tangem.datasource.local.logs.AppLogsStore import com.tangem.tap.common.log.TangemBlockchainSDKLogger import com.tangem.tap.common.log.TangemCardSDKLogger @@ -17,10 +18,14 @@ internal object TangemLoggingModule { @Provides @Singleton - fun provideLoggingInitializer(appLogsStore: AppLogsStore): TangemLoggingInitializer { + fun provideLoggingInitializer( + appLogsStore: AppLogsStore, + environmentConfig: EnvironmentConfig, + ): TangemLoggingInitializer { return TangemLoggingInitializer( appLogsStore = appLogsStore, tangemSdkLogger = TangemCardSDKLogger(appLogsStore), + environmentConfig = environmentConfig, ) } diff --git a/core/datasource/build.gradle.kts b/core/datasource/build.gradle.kts index 16c61b4b29..c1b20c401a 100644 --- a/core/datasource/build.gradle.kts +++ b/core/datasource/build.gradle.kts @@ -95,6 +95,7 @@ dependencies { implementation(deps.kotlin.coroutines) implementation(deps.kotlin.coroutines.rx2) implementation(deps.kotlin.datetime) + implementation(deps.kotlin.serialization) /** Logging */ diff --git a/core/datasource/src/main/java/com/tangem/datasource/di/utils/RetrofitApiBuilder.kt b/core/datasource/src/main/java/com/tangem/datasource/di/utils/RetrofitApiBuilder.kt index f1f6854fce..e662899b51 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/di/utils/RetrofitApiBuilder.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/di/utils/RetrofitApiBuilder.kt @@ -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> = 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), ) } diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt index 2647b32dd3..3d3bb643c3 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/EnvironmentConfig.kt @@ -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, ) \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/models/EnvironmentConfigModels.kt b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/models/EnvironmentConfigModels.kt index 6b823a997f..58616fc615 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/models/EnvironmentConfigModels.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/local/config/environment/models/EnvironmentConfigModels.kt @@ -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( diff --git a/core/datasource/src/main/java/com/tangem/datasource/local/logs/SensitiveUrlMasker.kt b/core/datasource/src/main/java/com/tangem/datasource/local/logs/SensitiveUrlMasker.kt new file mode 100644 index 0000000000..18ed28c57f --- /dev/null +++ b/core/datasource/src/main/java/com/tangem/datasource/local/logs/SensitiveUrlMasker.kt @@ -0,0 +1,24 @@ +package com.tangem.datasource.local.logs + +class SensitiveUrlMasker(sensitiveValues: Collection) { + + // 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 = 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 = "******" + } +} \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/utils/NetworkLogsSaveInterceptor.kt b/core/datasource/src/main/java/com/tangem/datasource/utils/NetworkLogsSaveInterceptor.kt index 55d88cd09e..ab50085083 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/utils/NetworkLogsSaveInterceptor.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/utils/NetworkLogsSaveInterceptor.kt @@ -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 */ diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/local/logs/SensitiveUrlMaskerTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/local/logs/SensitiveUrlMaskerTest.kt new file mode 100644 index 0000000000..939b6581f8 --- /dev/null +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/local/logs/SensitiveUrlMaskerTest.kt @@ -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, + val expected: String, + ) +} \ No newline at end of file diff --git a/core/utils/build.gradle.kts b/core/utils/build.gradle.kts index 48e632eff4..e5abca8e7b 100644 --- a/core/utils/build.gradle.kts +++ b/core/utils/build.gradle.kts @@ -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) diff --git a/core/utils/src/main/java/com/tangem/utils/JsonStringValuesExtractor.kt b/core/utils/src/main/java/com/tangem/utils/JsonStringValuesExtractor.kt new file mode 100644 index 0000000000..3a54c89eb8 --- /dev/null +++ b/core/utils/src/main/java/com/tangem/utils/JsonStringValuesExtractor.kt @@ -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 = json.extractStringValues() + + private fun JsonElement.extractStringValues(): List = when (this) { + is JsonPrimitive -> if (isString) listOfNotNull(contentOrNull) else emptyList() + is JsonObject -> values.flatMap { it.extractStringValues() } + is JsonArray -> flatMap { it.extractStringValues() } + } +} \ No newline at end of file diff --git a/core/utils/src/test/kotlin/com/tangem/utils/JsonStringValuesExtractorTest.kt b/core/utils/src/test/kotlin/com/tangem/utils/JsonStringValuesExtractorTest.kt new file mode 100644 index 0000000000..1239ac3ad3 --- /dev/null +++ b/core/utils/src/test/kotlin/com/tangem/utils/JsonStringValuesExtractorTest.kt @@ -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") + } +} \ No newline at end of file