diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index b47fa7939b7ec5..3a8f254050efb0 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -837,7 +837,7 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str: # Settings for encrypted database fields. DATABASE_ENCRYPTION_SETTINGS: EncryptedFieldSettings = { - "method": "plaintext", + "method": "fernet", "fernet_primary_key_id": os.getenv("DATABASE_ENCRYPTION_FERNET_PRIMARY_KEY_ID"), "fernet_keys_location": os.getenv("DATABASE_ENCRYPTION_FERNET_KEYS_LOCATION"), } diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 99d7d4b2666f20..78ed3997bb9f7c 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -3497,13 +3497,13 @@ # Database field encryption method # Supported values: -# - 'plaintext': No encryption (default) -# - 'fernet': Fernet symmetric encryption +# - 'plaintext': No encryption +# - 'fernet': Fernet symmetric encryption (default) # - 'keysets': (Future) Google Tink keysets for key rotation register( "database.encryption.method", type=String, - default="plaintext", + default="fernet", flags=FLAG_PRIORITIZE_DISK | FLAG_AUTOMATOR_MODIFIABLE, ) diff --git a/src/sentry_plugins/jira/plugin.py b/src/sentry_plugins/jira/plugin.py index fb9427e71702ad..3120184b2cf55b 100644 --- a/src/sentry_plugins/jira/plugin.py +++ b/src/sentry_plugins/jira/plugin.py @@ -2,11 +2,11 @@ import re from urllib.parse import parse_qs, quote_plus, unquote_plus, urlencode, urlsplit, urlunsplit -from django.conf import settings from django.urls import re_path from rest_framework.request import Request from rest_framework.response import Response +from sentry.db.models.fields.encryption import EncryptedTextField from sentry.exceptions import PluginError from sentry.integrations.base import FeatureDescription, IntegrationFeatures from sentry.models.groupmeta import GroupMeta @@ -44,6 +44,30 @@ class JiraPlugin(CorePluginMixin, IssuePlugin2): IntegrationFeatures.ISSUE_BASIC, ) ] + _password_field = EncryptedTextField() + + def set_option(self, key, value, project=None, user=None) -> None: + if key == "password" and isinstance(value, str) and value: + # Avoid re-encrypting already-encrypted values. + if not value.startswith("enc:"): + value = self._password_field.get_prep_value(value) + super().set_option(key, value, project=project, user=user) + + def get_option(self, key, project=None, user=None): + value = super().get_option(key, project=project, user=user) + if key != "password" or not isinstance(value, str) or not value: + return value + + try: + decrypted = self._password_field.to_python(value) + if isinstance(decrypted, bytes): + return decrypted.decode("utf-8") + return decrypted + except Exception: + logger.warning( + "jira.password.decrypt.failed", extra={"project_id": getattr(project, "id", None)} + ) + return None def get_group_urls(self): _patterns = super().get_group_urls() diff --git a/tests/sentry/integrations/models/test_integration_security.py b/tests/sentry/integrations/models/test_integration_security.py new file mode 100644 index 00000000000000..2bf976235c6697 --- /dev/null +++ b/tests/sentry/integrations/models/test_integration_security.py @@ -0,0 +1,8 @@ +from sentry.db.models.fields.encryption import EncryptedJSONField +from sentry.integrations.models.integration import Integration +from sentry.testutils.cases import TestCase + + +class IntegrationSecurityTest(TestCase): + def test_metadata_field_is_encrypted_json(self) -> None: + assert isinstance(Integration._meta.get_field("metadata"), EncryptedJSONField) diff --git a/tests/sentry/users/models/test_identity.py b/tests/sentry/users/models/test_identity.py index 727f49626da39f..a6c12c4d85c906 100644 --- a/tests/sentry/users/models/test_identity.py +++ b/tests/sentry/users/models/test_identity.py @@ -1,11 +1,16 @@ from sentry.identity import register from sentry.identity.providers.dummy import DummyProvider +from sentry.db.models.fields.encryption import EncryptedJSONField +from sentry.users.models.identity import Identity from sentry.testutils.cases import TestCase from sentry.testutils.silo import control_silo_test @control_silo_test class IdentityTestCase(TestCase): + def test_data_field_is_encrypted_json(self) -> None: + assert isinstance(Identity._meta.get_field("data"), EncryptedJSONField) + def test_get_provider(self) -> None: integration = self.create_integration( organization=self.organization, provider="dummy", external_id="tester_id" diff --git a/tests/sentry_plugins/jira/test_plugin.py b/tests/sentry_plugins/jira/test_plugin.py index b2436f6e25c4b8..4b39640f2db57d 100644 --- a/tests/sentry_plugins/jira/test_plugin.py +++ b/tests/sentry_plugins/jira/test_plugin.py @@ -9,6 +9,7 @@ from django.test import RequestFactory from django.urls import reverse +from sentry.models.options.project_option import ProjectOption from sentry.testutils.cases import TestCase from sentry.testutils.requests import drf_request_from_request from sentry_plugins.jira.plugin import JiraPlugin @@ -302,6 +303,20 @@ def test_no_secrets(self) -> None: assert password_config.get("hasSavedValue") is True assert password_config.get("prefix") == "" + def test_password_is_encrypted_in_project_option_storage(self) -> None: + self.plugin.set_option("password", "abcdef", self.project) + + stored = ProjectOption.objects.get(project=self.project, key="jira:password").value + assert isinstance(stored, str) + assert stored.startswith("enc:") + assert stored != "abcdef" + assert self.plugin.get_option("password", self.project) == "abcdef" + + def test_plaintext_password_is_still_readable(self) -> None: + ProjectOption.objects.set_value(self.project, "jira:password", "legacy-plaintext") + + assert self.plugin.get_option("password", self.project) == "legacy-plaintext" + def test_get_formatted_user(self) -> None: assert self.plugin._get_formatted_user( {"displayName": "Foo Bar", "emailAddress": "foo@sentry.io", "name": "foobar"}