diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 00183bc9b3..22c4636acf 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -383,6 +383,40 @@ public final class io/sentry/DataCategory : java/lang/Enum { public static fun values ()[Lio/sentry/DataCategory; } +public final class io/sentry/DataCollection { + public fun ()V + public fun getCookies ()Lio/sentry/KeyValueCollectionBehavior; + public fun getDatabaseQueryData ()Ljava/lang/Boolean; + public fun getGraphql ()Lio/sentry/DataCollection$Graphql; + public fun getHttpBodies ()Ljava/util/Set; + public fun getHttpHeaders ()Lio/sentry/DataCollection$HttpHeaders; + public fun getQueryParams ()Lio/sentry/KeyValueCollectionBehavior; + public fun getQueues ()Ljava/lang/Boolean; + public fun getUserInfo ()Ljava/lang/Boolean; + public fun setCookies (Lio/sentry/KeyValueCollectionBehavior;)V + public fun setDatabaseQueryData (Z)V + public fun setHttpBodies (Ljava/util/Set;)V + public fun setQueryParams (Lio/sentry/KeyValueCollectionBehavior;)V + public fun setQueues (Z)V + public fun setUserInfo (Z)V +} + +public final class io/sentry/DataCollection$Graphql { + public fun ()V + public fun getDocument ()Ljava/lang/Boolean; + public fun getVariables ()Ljava/lang/Boolean; + public fun setDocument (Z)V + public fun setVariables (Z)V +} + +public final class io/sentry/DataCollection$HttpHeaders { + public fun ()V + public fun getRequest ()Lio/sentry/KeyValueCollectionBehavior; + public fun getResponse ()Lio/sentry/KeyValueCollectionBehavior; + public fun setRequest (Lio/sentry/KeyValueCollectionBehavior;)V + public fun setResponse (Lio/sentry/KeyValueCollectionBehavior;)V +} + public final class io/sentry/DateUtils { public static fun dateToSeconds (Ljava/util/Date;)D public static fun doubleToBigDecimal (D)Ljava/math/BigDecimal; @@ -635,6 +669,15 @@ public final class io/sentry/HostnameCache { public static fun getInstance ()Lio/sentry/HostnameCache; } +public final class io/sentry/HttpBodyType : java/lang/Enum { + public static final field INCOMING_REQUEST Lio/sentry/HttpBodyType; + public static final field INCOMING_RESPONSE Lio/sentry/HttpBodyType; + public static final field OUTGOING_REQUEST Lio/sentry/HttpBodyType; + public static final field OUTGOING_RESPONSE Lio/sentry/HttpBodyType; + public static fun valueOf (Ljava/lang/String;)Lio/sentry/HttpBodyType; + public static fun values ()[Lio/sentry/HttpBodyType; +} + public final class io/sentry/HttpStatusCodeRange { public static final field DEFAULT_MAX I public static final field DEFAULT_MIN I @@ -1381,6 +1424,24 @@ public abstract interface class io/sentry/JsonUnknown { public abstract fun setUnknown (Ljava/util/Map;)V } +public final class io/sentry/KeyValueCollectionBehavior { + public static fun allowList ([Ljava/lang/String;)Lio/sentry/KeyValueCollectionBehavior; + public static fun denyList ([Ljava/lang/String;)Lio/sentry/KeyValueCollectionBehavior; + public fun equals (Ljava/lang/Object;)Z + public fun getMode ()Lio/sentry/KeyValueCollectionBehavior$Mode; + public fun getTerms ()Ljava/util/List; + public fun hashCode ()I + public static fun off ()Lio/sentry/KeyValueCollectionBehavior; +} + +public final class io/sentry/KeyValueCollectionBehavior$Mode : java/lang/Enum { + public static final field ALLOW_LIST Lio/sentry/KeyValueCollectionBehavior$Mode; + public static final field DENY_LIST Lio/sentry/KeyValueCollectionBehavior$Mode; + public static final field OFF Lio/sentry/KeyValueCollectionBehavior$Mode; + public static fun valueOf (Ljava/lang/String;)Lio/sentry/KeyValueCollectionBehavior$Mode; + public static fun values ()[Lio/sentry/KeyValueCollectionBehavior$Mode; +} + public final class io/sentry/MainEventProcessor : io/sentry/EventProcessor, java/io/Closeable { public fun (Lio/sentry/SentryOptions;)V public fun close ()V diff --git a/sentry/src/main/java/io/sentry/DataCollection.java b/sentry/src/main/java/io/sentry/DataCollection.java new file mode 100644 index 0000000000..c188293806 --- /dev/null +++ b/sentry/src/main/java/io/sentry/DataCollection.java @@ -0,0 +1,156 @@ +package io.sentry; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.Set; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** Configures data that the SDK collects automatically. */ +public final class DataCollection { + + private boolean overridden; + private @Nullable Boolean userInfo; + private @Nullable KeyValueCollectionBehavior cookies; + private @Nullable KeyValueCollectionBehavior queryParams; + private @Nullable Set httpBodies; + private @Nullable Boolean databaseQueryData; + private @Nullable Boolean queues; + private final @NotNull HttpHeaders httpHeaders = new HttpHeaders(); + private final @NotNull Graphql graphql = new Graphql(); + + public DataCollection() { + this(true); + } + + DataCollection(final boolean overridden) { + this.overridden = overridden; + } + + public @Nullable Boolean getUserInfo() { + return userInfo; + } + + public void setUserInfo(final boolean userInfo) { + this.userInfo = userInfo; + } + + public @Nullable KeyValueCollectionBehavior getCookies() { + return cookies; + } + + public void setCookies(final @Nullable KeyValueCollectionBehavior cookies) { + this.cookies = cookies; + } + + public @Nullable KeyValueCollectionBehavior getQueryParams() { + return queryParams; + } + + public void setQueryParams(final @Nullable KeyValueCollectionBehavior queryParams) { + this.queryParams = queryParams; + } + + public @Nullable Set getHttpBodies() { + return httpBodies; + } + + public void setHttpBodies(final @Nullable Set httpBodies) { + this.httpBodies = + httpBodies == null + ? null + : httpBodies.isEmpty() + ? Collections.emptySet() + : Collections.unmodifiableSet(EnumSet.copyOf(httpBodies)); + } + + public @Nullable Boolean getDatabaseQueryData() { + return databaseQueryData; + } + + public void setDatabaseQueryData(final boolean databaseQueryData) { + this.databaseQueryData = databaseQueryData; + } + + public @Nullable Boolean getQueues() { + return queues; + } + + public void setQueues(final boolean queues) { + this.queues = queues; + } + + public @NotNull HttpHeaders getHttpHeaders() { + return httpHeaders; + } + + public @NotNull Graphql getGraphql() { + return graphql; + } + + @ApiStatus.Internal + boolean isExplicitlyConfigured() { + return overridden + || userInfo != null + || cookies != null + || queryParams != null + || httpBodies != null + || databaseQueryData != null + || queues != null + || httpHeaders.hasOverrides() + || graphql.hasOverrides(); + } + + /** Configures collection of request and response HTTP headers. */ + public static final class HttpHeaders { + private @Nullable KeyValueCollectionBehavior request; + private @Nullable KeyValueCollectionBehavior response; + + public @Nullable KeyValueCollectionBehavior getRequest() { + return request; + } + + public void setRequest(final @Nullable KeyValueCollectionBehavior request) { + this.request = request; + } + + public @Nullable KeyValueCollectionBehavior getResponse() { + return response; + } + + public void setResponse(final @Nullable KeyValueCollectionBehavior response) { + this.response = response; + } + + private boolean hasOverrides() { + return request != null || response != null; + } + } + + /** Configures collection of GraphQL document and variable content. */ + public static final class Graphql { + private @Nullable Boolean document; + private @Nullable Boolean variables; + + public @Nullable Boolean getDocument() { + return document; + } + + public void setDocument(final boolean document) { + this.document = document; + } + + public @Nullable Boolean getVariables() { + return variables; + } + + public void setVariables(final boolean variables) { + this.variables = variables; + } + + private boolean hasOverrides() { + return document != null || variables != null; + } + } +} diff --git a/sentry/src/main/java/io/sentry/HttpBodyType.java b/sentry/src/main/java/io/sentry/HttpBodyType.java new file mode 100644 index 0000000000..9b1b9a24b5 --- /dev/null +++ b/sentry/src/main/java/io/sentry/HttpBodyType.java @@ -0,0 +1,13 @@ +package io.sentry; + +/** A direction of automatically collected HTTP body content. */ +public enum HttpBodyType { + /** A request received by a server integration. */ + INCOMING_REQUEST, + /** A request sent by a client integration. */ + OUTGOING_REQUEST, + /** A response received by a client integration. */ + INCOMING_RESPONSE, + /** A response sent by a server integration. */ + OUTGOING_RESPONSE +} diff --git a/sentry/src/main/java/io/sentry/KeyValueCollectionBehavior.java b/sentry/src/main/java/io/sentry/KeyValueCollectionBehavior.java new file mode 100644 index 0000000000..d9daeb9bc0 --- /dev/null +++ b/sentry/src/main/java/io/sentry/KeyValueCollectionBehavior.java @@ -0,0 +1,76 @@ +package io.sentry; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; + +/** Controls how automatically collected key-value data is filtered. */ +public final class KeyValueCollectionBehavior { + + /** The collection strategy applied to key-value data. */ + public enum Mode { + /** Do not collect keys or values. */ + OFF, + /** Collect keys and filter values whose keys match a deny-list term. */ + DENY_LIST, + /** Collect keys and filter values unless their keys match an allow-list term. */ + ALLOW_LIST + } + + private final @NotNull Mode mode; + private final @NotNull List terms; + + private KeyValueCollectionBehavior(final @NotNull Mode mode, final @NotNull List terms) { + this.mode = mode; + this.terms = Collections.unmodifiableList(new ArrayList<>(terms)); + } + + /** Disables collection of the category. */ + public static @NotNull KeyValueCollectionBehavior off() { + return new KeyValueCollectionBehavior(Mode.OFF, Collections.emptyList()); + } + + /** + * Collects the category and filters values whose keys match the built-in sensitive deny-list or + * one of {@code terms}. + */ + public static @NotNull KeyValueCollectionBehavior denyList(final @NotNull String... terms) { + return new KeyValueCollectionBehavior(Mode.DENY_LIST, Arrays.asList(terms)); + } + + /** + * Collects the category and only includes plaintext values whose keys match one of {@code terms}. + * Values matching the built-in sensitive deny-list are still filtered. + */ + public static @NotNull KeyValueCollectionBehavior allowList(final @NotNull String... terms) { + return new KeyValueCollectionBehavior(Mode.ALLOW_LIST, Arrays.asList(terms)); + } + + public @NotNull Mode getMode() { + return mode; + } + + public @NotNull List getTerms() { + return terms; + } + + @Override + public boolean equals(final Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + final KeyValueCollectionBehavior that = (KeyValueCollectionBehavior) other; + return mode == that.mode && terms.equals(that.terms); + } + + @Override + public int hashCode() { + return Objects.hash(mode, terms); + } +} diff --git a/sentry/src/test/java/io/sentry/DataCollectionTest.kt b/sentry/src/test/java/io/sentry/DataCollectionTest.kt new file mode 100644 index 0000000000..8bc9c7af7a --- /dev/null +++ b/sentry/src/test/java/io/sentry/DataCollectionTest.kt @@ -0,0 +1,114 @@ +package io.sentry + +import com.google.common.truth.Truth.assertThat +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class DataCollectionTest { + @Test + fun `public constructor creates explicit empty configuration`() { + val dataCollection = DataCollection() + + assertThat(dataCollection.userInfo).isNull() + assertThat(dataCollection.cookies).isNull() + assertThat(dataCollection.queryParams).isNull() + assertThat(dataCollection.httpBodies).isNull() + assertThat(dataCollection.databaseQueryData).isNull() + assertThat(dataCollection.queues).isNull() + assertThat(dataCollection.httpHeaders.request).isNull() + assertThat(dataCollection.httpHeaders.response).isNull() + assertThat(dataCollection.graphql.document).isNull() + assertThat(dataCollection.graphql.variables).isNull() + assertThat(dataCollection.isExplicitlyConfigured()).isTrue() + } + + @Test + fun `SDK-owned configuration starts unconfigured`() { + val dataCollection = DataCollection(false) + + assertThat(dataCollection.isExplicitlyConfigured()).isFalse() + } + + @Test + fun `nested override makes SDK-owned configuration explicit`() { + val dataCollection = DataCollection(false) + + dataCollection.graphql.setVariables(false) + + assertThat(dataCollection.isExplicitlyConfigured()).isTrue() + } + + @Test + fun `explicit false is distinct from unset`() { + val dataCollection = DataCollection(false) + + dataCollection.setUserInfo(false) + + assertThat(dataCollection.userInfo).isFalse() + assertThat(dataCollection.isExplicitlyConfigured()).isTrue() + } + + @Test + fun `empty HTTP body set is distinct from unset`() { + val dataCollection = DataCollection(false) + + dataCollection.setHttpBodies(emptySet()) + + assertThat(dataCollection.httpBodies).isEmpty() + assertThat(dataCollection.isExplicitlyConfigured()).isTrue() + } + + @Test + fun `HTTP body set is copied and immutable`() { + val bodies = mutableSetOf(HttpBodyType.INCOMING_REQUEST) + val dataCollection = DataCollection() + + dataCollection.setHttpBodies(bodies) + bodies += HttpBodyType.OUTGOING_REQUEST + + assertThat(dataCollection.httpBodies).containsExactly(HttpBodyType.INCOMING_REQUEST) + assertFailsWith { + dataCollection.httpBodies!!.add(HttpBodyType.OUTGOING_REQUEST) + } + } + + @Test + fun `database query data false is distinct from unset`() { + val dataCollection = DataCollection(false) + + dataCollection.setDatabaseQueryData(false) + + assertThat(dataCollection.databaseQueryData).isFalse() + assertThat(dataCollection.isExplicitlyConfigured()).isTrue() + } + + @Test + fun `queues false is distinct from unset`() { + val dataCollection = DataCollection(false) + + dataCollection.setQueues(false) + + assertThat(dataCollection.queues).isFalse() + assertThat(dataCollection.isExplicitlyConfigured()).isTrue() + } + + @Test + fun `nested HTTP header override marks configuration explicit`() { + val dataCollection = DataCollection(false) + val behavior = KeyValueCollectionBehavior.denyList("authorization") + + dataCollection.httpHeaders.setRequest(behavior) + + assertThat(dataCollection.isExplicitlyConfigured()).isTrue() + } + + @Test + fun `nested GraphQL false marks configuration explicit`() { + val dataCollection = DataCollection(false) + + dataCollection.graphql.setVariables(false) + + assertThat(dataCollection.graphql.variables).isFalse() + assertThat(dataCollection.isExplicitlyConfigured()).isTrue() + } +} diff --git a/sentry/src/test/java/io/sentry/KeyValueCollectionBehaviorTest.kt b/sentry/src/test/java/io/sentry/KeyValueCollectionBehaviorTest.kt new file mode 100644 index 0000000000..7e014eec50 --- /dev/null +++ b/sentry/src/test/java/io/sentry/KeyValueCollectionBehaviorTest.kt @@ -0,0 +1,49 @@ +package io.sentry + +import com.google.common.truth.Truth.assertThat +import kotlin.test.Test + +class KeyValueCollectionBehaviorTest { + @Test + fun `off has no terms`() { + val behavior = KeyValueCollectionBehavior.off() + + assertThat(behavior.mode).isEqualTo(KeyValueCollectionBehavior.Mode.OFF) + assertThat(behavior.terms).isEmpty() + } + + @Test + fun `deny list stores terms in order`() { + val behavior = KeyValueCollectionBehavior.denyList("token", "session") + + assertThat(behavior.mode).isEqualTo(KeyValueCollectionBehavior.Mode.DENY_LIST) + assertThat(behavior.terms).containsExactly("token", "session").inOrder() + } + + @Test + fun `allow list can be empty`() { + val behavior = KeyValueCollectionBehavior.allowList() + + assertThat(behavior.mode).isEqualTo(KeyValueCollectionBehavior.Mode.ALLOW_LIST) + assertThat(behavior.terms).isEmpty() + } + + @Test + fun `terms are copied and immutable`() { + val terms = arrayOf("token") + val behavior = KeyValueCollectionBehavior.denyList(*terms) + + terms[0] = "password" + + assertThat(behavior.terms).containsExactly("token") + } + + @Test + fun `equal behaviors have equal hash codes`() { + val first = KeyValueCollectionBehavior.allowList("language", "theme") + val second = KeyValueCollectionBehavior.allowList("language", "theme") + + assertThat(first).isEqualTo(second) + assertThat(first.hashCode()).isEqualTo(second.hashCode()) + } +}