diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index 0388b7de48..0b9fa89b54 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -110,6 +110,7 @@ dependencies { testImplementation(libs.androidx.test.ext.junit) testImplementation(libs.androidx.test.runner) testImplementation(libs.awaitility.kotlin) + testImplementation(libs.google.truth) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.inline) testImplementation(projects.sentryTestSupport) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 7a9cd8a4d1..b3c3b86167 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -3,8 +3,11 @@ import android.content.Context; import android.content.pm.ApplicationInfo; import android.os.Bundle; +import io.sentry.DataCollection; +import io.sentry.HttpBodyType; import io.sentry.ILogger; import io.sentry.InitPriority; +import io.sentry.KeyValueCollectionBehavior; import io.sentry.ProfileLifecycle; import io.sentry.ScreenshotStrategyType; import io.sentry.SentryFeedbackOptions; @@ -16,8 +19,10 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.EnumSet; import java.util.List; import java.util.Locale; +import java.util.Set; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -102,6 +107,22 @@ final class ManifestMetadataReader { static final String SEND_DEFAULT_PII = "io.sentry.send-default-pii"; + static final String DATA_COLLECTION_USER_INFO = "io.sentry.data-collection.user-info"; + static final String DATA_COLLECTION_HTTP_BODIES = "io.sentry.data-collection.http-bodies"; + static final String DATA_COLLECTION_COOKIES = "io.sentry.data-collection.cookies"; + static final String DATA_COLLECTION_HTTP_REQUEST_HEADERS = + "io.sentry.data-collection.http-headers.request"; + static final String DATA_COLLECTION_HTTP_RESPONSE_HEADERS = + "io.sentry.data-collection.http-headers.response"; + static final String DATA_COLLECTION_URL_QUERY_PARAMS = + "io.sentry.data-collection.url-query-params"; + static final String DATA_COLLECTION_GRAPHQL_DOCUMENT = + "io.sentry.data-collection.graphql.document"; + static final String DATA_COLLECTION_GRAPHQL_VARIABLES = + "io.sentry.data-collection.graphql.variables"; + static final String DATA_COLLECTION_DATABASE_QUERY_DATA = + "io.sentry.data-collection.database-query-data"; + static final String PERFORM_FRAMES_TRACKING = "io.sentry.traces.frames-tracking"; static final String SENTRY_GRADLE_PLUGIN_INTEGRATIONS = "io.sentry.gradle-plugin-integrations"; @@ -761,6 +782,12 @@ static void applyMetadata( options.setEnableAnrFingerprinting( readBool( metadata, logger, ENABLE_ANR_FINGERPRINTING, options.isEnableAnrFingerprinting())); + + final @Nullable DataCollection dataCollection = + readDataCollection(metadata, logger, options.getDataCollection()); + if (dataCollection != null) { + mergeDataCollection(options.getDataCollection(), dataCollection); + } } options .getLogger() @@ -773,6 +800,152 @@ static void applyMetadata( } } + private static @Nullable DataCollection readDataCollection( + final @NotNull Bundle metadata, + final @NotNull ILogger logger, + final @NotNull DataCollection currentDataCollection) { + final @NotNull DataCollection dataCollection = new DataCollection(false); + + if (metadata.containsKey(DATA_COLLECTION_USER_INFO)) { + dataCollection.setUserInfo(readBool(metadata, logger, DATA_COLLECTION_USER_INFO, false)); + } + + if (metadata.containsKey(DATA_COLLECTION_HTTP_BODIES)) { + dataCollection.setHttpBodies(readHttpBodyTypes(metadata, logger)); + } + + final @Nullable KeyValueCollectionBehavior cookies = + readKeyValueCollectionBehavior( + metadata, logger, DATA_COLLECTION_COOKIES, currentDataCollection.getCookies()); + if (cookies != null) { + dataCollection.setCookies(cookies); + } + + final @Nullable KeyValueCollectionBehavior requestHeaders = + readKeyValueCollectionBehavior( + metadata, + logger, + DATA_COLLECTION_HTTP_REQUEST_HEADERS, + currentDataCollection.getHttpHeaders().getRequest()); + if (requestHeaders != null) { + dataCollection.getHttpHeaders().setRequest(requestHeaders); + } + + final @Nullable KeyValueCollectionBehavior responseHeaders = + readKeyValueCollectionBehavior( + metadata, + logger, + DATA_COLLECTION_HTTP_RESPONSE_HEADERS, + currentDataCollection.getHttpHeaders().getResponse()); + if (responseHeaders != null) { + dataCollection.getHttpHeaders().setResponse(responseHeaders); + } + + final @Nullable KeyValueCollectionBehavior urlQueryParams = + readKeyValueCollectionBehavior( + metadata, + logger, + DATA_COLLECTION_URL_QUERY_PARAMS, + currentDataCollection.getUrlQueryParams()); + if (urlQueryParams != null) { + dataCollection.setUrlQueryParams(urlQueryParams); + } + + if (metadata.containsKey(DATA_COLLECTION_GRAPHQL_DOCUMENT)) { + dataCollection + .getGraphql() + .setDocument(readBool(metadata, logger, DATA_COLLECTION_GRAPHQL_DOCUMENT, false)); + } + + if (metadata.containsKey(DATA_COLLECTION_GRAPHQL_VARIABLES)) { + dataCollection + .getGraphql() + .setVariables(readBool(metadata, logger, DATA_COLLECTION_GRAPHQL_VARIABLES, false)); + } + + if (metadata.containsKey(DATA_COLLECTION_DATABASE_QUERY_DATA)) { + dataCollection.setDatabaseQueryData( + readBool(metadata, logger, DATA_COLLECTION_DATABASE_QUERY_DATA, false)); + } + + return dataCollection.isExplicitlyConfigured() ? dataCollection : null; + } + + private static void mergeDataCollection( + final @NotNull DataCollection target, final @NotNull DataCollection source) { + if (source.getUserInfo() != null) { + target.setUserInfo(source.getUserInfo()); + } + if (source.getHttpBodies() != null) { + target.setHttpBodies(source.getHttpBodies()); + } + if (source.getCookies() != null) { + target.setCookies(source.getCookies()); + } + if (source.getHttpHeaders().getRequest() != null) { + target.getHttpHeaders().setRequest(source.getHttpHeaders().getRequest()); + } + if (source.getHttpHeaders().getResponse() != null) { + target.getHttpHeaders().setResponse(source.getHttpHeaders().getResponse()); + } + if (source.getUrlQueryParams() != null) { + target.setUrlQueryParams(source.getUrlQueryParams()); + } + if (source.getGraphql().getDocument() != null) { + target.getGraphql().setDocument(source.getGraphql().getDocument()); + } + if (source.getGraphql().getVariables() != null) { + target.getGraphql().setVariables(source.getGraphql().getVariables()); + } + if (source.getDatabaseQueryData() != null) { + target.setDatabaseQueryData(source.getDatabaseQueryData()); + } + } + + private static @NotNull Set readHttpBodyTypes( + final @NotNull Bundle metadata, final @NotNull ILogger logger) { + final @Nullable List bodyTypes = + readList(metadata, logger, DATA_COLLECTION_HTTP_BODIES); + if (bodyTypes == null || (bodyTypes.size() == 1 && bodyTypes.get(0).isEmpty())) { + return Collections.emptySet(); + } + + final @NotNull Set result = EnumSet.noneOf(HttpBodyType.class); + for (final String bodyType : bodyTypes) { + result.add(HttpBodyType.valueOf(bodyType.toUpperCase(Locale.ROOT))); + } + return result; + } + + private static @Nullable KeyValueCollectionBehavior readKeyValueCollectionBehavior( + final @NotNull Bundle metadata, + final @NotNull ILogger logger, + final @NotNull String key, + final @Nullable KeyValueCollectionBehavior currentBehavior) { + final @NotNull String modeKey = key + ".mode"; + final @NotNull String termsKey = key + ".terms"; + if (!metadata.containsKey(modeKey) && !metadata.containsKey(termsKey)) { + return null; + } + + final @NotNull KeyValueCollectionBehavior behavior = new KeyValueCollectionBehavior(); + if (currentBehavior != null) { + behavior.setMode(currentBehavior.getMode()); + behavior.setTerms(currentBehavior.getTerms()); + } + if (metadata.containsKey(modeKey)) { + final @Nullable String mode = readString(metadata, logger, modeKey, null); + if (mode != null) { + behavior.setMode(KeyValueCollectionBehavior.Mode.valueOf(mode.toUpperCase(Locale.ROOT))); + } + } + if (metadata.containsKey(termsKey)) { + final @Nullable List terms = readList(metadata, logger, termsKey); + behavior.setTerms(terms == null ? Collections.emptyList() : terms); + } + return behavior; + } + private static boolean readBool( final @NotNull Bundle metadata, final @NotNull ILogger logger, diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index d0dbd1deb5..2ad8a80c0e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -4,8 +4,11 @@ import android.content.Context import android.os.Bundle import androidx.core.os.bundleOf import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat import io.sentry.FilterString +import io.sentry.HttpBodyType import io.sentry.ILogger +import io.sentry.KeyValueCollectionBehavior import io.sentry.ProfileLifecycle import io.sentry.SentryLevel import io.sentry.SentryReplayOptions @@ -1409,6 +1412,139 @@ class ManifestMetadataReaderTest { assertTrue(fixture.options.isSendDefaultPii) } + @Test + fun `applyMetadata preserves legacy data collection when metadata is absent`() { + val context = fixture.getContext() + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertThat(fixture.options.dataCollectionResolver.isDataCollectionConfigured()).isFalse() + } + + @Test + fun `applyMetadata reads data collection options`() { + val bundle = + bundleOf( + ManifestMetadataReader.DATA_COLLECTION_USER_INFO to false, + ManifestMetadataReader.DATA_COLLECTION_HTTP_BODIES to "incoming_request,outgoing_response", + ManifestMetadataReader.DATA_COLLECTION_COOKIES + ".mode" to "deny_list", + ManifestMetadataReader.DATA_COLLECTION_COOKIES + ".terms" to "authorization,session", + ManifestMetadataReader.DATA_COLLECTION_HTTP_REQUEST_HEADERS + ".mode" to "allow_list", + ManifestMetadataReader.DATA_COLLECTION_HTTP_REQUEST_HEADERS + ".terms" to + "x-request-id,content-type", + ManifestMetadataReader.DATA_COLLECTION_HTTP_RESPONSE_HEADERS + ".mode" to "off", + ManifestMetadataReader.DATA_COLLECTION_URL_QUERY_PARAMS + ".terms" to "search", + ManifestMetadataReader.DATA_COLLECTION_GRAPHQL_DOCUMENT to false, + ManifestMetadataReader.DATA_COLLECTION_GRAPHQL_VARIABLES to true, + ManifestMetadataReader.DATA_COLLECTION_DATABASE_QUERY_DATA to false, + ) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + val dataCollection = fixture.options.dataCollection + assertThat(dataCollection.userInfo).isFalse() + assertThat(dataCollection.httpBodies) + .containsExactly(HttpBodyType.INCOMING_REQUEST, HttpBodyType.OUTGOING_RESPONSE) + assertThat(dataCollection.cookies) + .isEqualTo(KeyValueCollectionBehavior.denyList("authorization", "session")) + assertThat(dataCollection.httpHeaders.request) + .isEqualTo(KeyValueCollectionBehavior.allowList("x-request-id", "content-type")) + assertThat(dataCollection.httpHeaders.response).isEqualTo(KeyValueCollectionBehavior.off()) + assertThat(dataCollection.urlQueryParams) + .isEqualTo(KeyValueCollectionBehavior.denyList("search")) + assertThat(dataCollection.graphql.document).isFalse() + assertThat(dataCollection.graphql.variables).isTrue() + assertThat(dataCollection.databaseQueryData).isFalse() + } + + @Test + fun `applyMetadata only overrides explicitly configured data collection options`() { + val dataCollection = + fixture.options.dataCollection.apply { + setUserInfo(true) + setHttpBodies(setOf(HttpBodyType.OUTGOING_REQUEST)) + cookies = KeyValueCollectionBehavior.allowList("existing-cookie") + httpHeaders.request = KeyValueCollectionBehavior.denyList("existing-request-header") + httpHeaders.response = KeyValueCollectionBehavior.allowList("existing-response-header") + urlQueryParams = KeyValueCollectionBehavior.off() + graphql.setDocument(true) + graphql.setVariables(false) + setDatabaseQueryData(true) + } + val bundle = + bundleOf( + ManifestMetadataReader.DATA_COLLECTION_USER_INFO to false, + ManifestMetadataReader.DATA_COLLECTION_COOKIES + ".terms" to "manifest-cookie", + ManifestMetadataReader.DATA_COLLECTION_HTTP_REQUEST_HEADERS + ".mode" to "allow_list", + ) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertThat(fixture.options.dataCollection).isSameInstanceAs(dataCollection) + assertThat(dataCollection.userInfo).isFalse() + assertThat(dataCollection.httpBodies).containsExactly(HttpBodyType.OUTGOING_REQUEST) + assertThat(dataCollection.cookies) + .isEqualTo(KeyValueCollectionBehavior.allowList("manifest-cookie")) + assertThat(dataCollection.httpHeaders.request) + .isEqualTo(KeyValueCollectionBehavior.allowList("existing-request-header")) + assertThat(dataCollection.httpHeaders.response) + .isEqualTo(KeyValueCollectionBehavior.allowList("existing-response-header")) + assertThat(dataCollection.urlQueryParams).isEqualTo(KeyValueCollectionBehavior.off()) + assertThat(dataCollection.graphql.document).isTrue() + assertThat(dataCollection.graphql.variables).isFalse() + assertThat(dataCollection.databaseQueryData).isTrue() + } + + @Test + fun `applyMetadata reads empty HTTP bodies as disabled`() { + val bundle = bundleOf(ManifestMetadataReader.DATA_COLLECTION_HTTP_BODIES to "") + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertThat(fixture.options.dataCollection.httpBodies).isEmpty() + } + + @Test + fun `applyMetadata data collection takes precedence over send default pii`() { + val bundle = + bundleOf( + ManifestMetadataReader.SEND_DEFAULT_PII to false, + ManifestMetadataReader.DATA_COLLECTION_COOKIES + ".mode" to "off", + ) + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertThat(fixture.options.isSendDefaultPii).isFalse() + assertThat(fixture.options.dataCollectionResolver.isDataCollectionConfigured()).isTrue() + assertThat(fixture.options.dataCollectionResolver.isUserInfo).isTrue() + assertThat(fixture.options.dataCollectionResolver.cookies) + .isEqualTo(KeyValueCollectionBehavior.off()) + } + + @Test + fun `applyMetadata ignores invalid data collection body type`() { + val bundle = bundleOf(ManifestMetadataReader.DATA_COLLECTION_HTTP_BODIES to "invalid") + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertThat(fixture.options.dataCollectionResolver.isDataCollectionConfigured()).isFalse() + } + + @Test + fun `applyMetadata ignores invalid data collection mode`() { + val bundle = bundleOf(ManifestMetadataReader.DATA_COLLECTION_COOKIES + ".mode" to "invalid") + val context = fixture.getContext(metaData = bundle) + + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + assertThat(fixture.options.dataCollectionResolver.isDataCollectionConfigured()).isFalse() + } + @Test fun `applyMetadata reads frames tracking flag and keeps default value if not found`() { // Arrange diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 3bebc5e6d6..4012b914c0 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -386,6 +386,7 @@ public final class io/sentry/DataCategory : java/lang/Enum { public final class io/sentry/DataCollection { public fun ()V + public fun (Z)V public fun getCookies ()Lio/sentry/KeyValueCollectionBehavior; public fun getDatabaseQueryData ()Ljava/lang/Boolean; public fun getGraphql ()Lio/sentry/DataCollection$Graphql; @@ -393,6 +394,7 @@ public final class io/sentry/DataCollection { public fun getHttpHeaders ()Lio/sentry/DataCollection$HttpHeaders; public fun getUrlQueryParams ()Lio/sentry/KeyValueCollectionBehavior; public fun getUserInfo ()Ljava/lang/Boolean; + public fun isExplicitlyConfigured ()Z public fun setCookies (Lio/sentry/KeyValueCollectionBehavior;)V public fun setDatabaseQueryData (Z)V public fun setHttpBodies (Ljava/util/Set;)V diff --git a/sentry/src/main/java/io/sentry/DataCollection.java b/sentry/src/main/java/io/sentry/DataCollection.java index 0df43e1bd0..6b0ec5acd7 100644 --- a/sentry/src/main/java/io/sentry/DataCollection.java +++ b/sentry/src/main/java/io/sentry/DataCollection.java @@ -24,7 +24,8 @@ public DataCollection() { this(true); } - DataCollection(final boolean forceDataCollection) { + @ApiStatus.Internal + public DataCollection(final boolean forceDataCollection) { this.forceDataCollection = forceDataCollection; } @@ -82,7 +83,7 @@ public void setDatabaseQueryData(final boolean databaseQueryData) { } @ApiStatus.Internal - boolean isExplicitlyConfigured() { + public boolean isExplicitlyConfigured() { return forceDataCollection || userInfo != null || cookies != null