diff --git a/docs/docs/start/config.md b/docs/docs/start/config.md index 3709652eb4..fd40a4bde9 100644 --- a/docs/docs/start/config.md +++ b/docs/docs/start/config.md @@ -483,10 +483,14 @@ The InvenTree server can be integrated with the [sentry.io](https://sentry.io) m {{ configsetting("INVENTREE_SENTRY_ENABLED") }} Enable sentry.io integration | {{ configsetting("INVENTREE_SENTRY_DSN", default="Defaults to InvenTree developer key") }} Sentry DSN (data source name) key | {{ configsetting("INVENTREE_SENTRY_SAMPLE_RATE") }} How often to send data samples (seconds) | +{{ configsetting("INVENTREE_SENTRY_SEND_PII") }} Include personally-identifiable information (e.g. user id/email, IP address, request data) in reported events | !!! info "Default DSN" If enabled with the default DSN, server errors will be logged to a sentry.io account monitored by the InvenTree developers. +!!! warning "Personally-Identifiable Information" + `INVENTREE_SENTRY_SEND_PII` is `False` by default. Enabling it attaches the reporting user's id/email, IP address, and request data to every event sent to Sentry - consider your organization's data-handling policy before enabling this, particularly if using the default DSN, which sends data to a sentry.io account outside your control. + ## Customization Options The logo and custom messages can be changed/set: diff --git a/src/backend/InvenTree/InvenTree/sentry.py b/src/backend/InvenTree/InvenTree/sentry.py index bf1252b331..20db903b8b 100644 --- a/src/backend/InvenTree/InvenTree/sentry.py +++ b/src/backend/InvenTree/InvenTree/sentry.py @@ -41,7 +41,7 @@ def sentry_ignore_errors(): # pragma: no cover ] -def init_sentry(dsn, sample_rate, tags): # pragma: no cover +def init_sentry(dsn, sample_rate, tags, send_pii=False): # pragma: no cover """Initialize sentry.io error reporting.""" logger.info('Initializing sentry.io integration') @@ -49,7 +49,7 @@ def init_sentry(dsn, sample_rate, tags): # pragma: no cover dsn=dsn, integrations=[DjangoIntegration()], traces_sample_rate=sample_rate, - send_default_pii=True, + send_default_pii=send_pii, ignore_errors=sentry_ignore_errors(), release=InvenTree.version.INVENTREE_SW_VERSION, environment='development' diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index b42351035b..dd0b73ab9a 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -608,8 +608,15 @@ SENTRY_SAMPLE_RATE = float( get_setting('INVENTREE_SENTRY_SAMPLE_RATE', 'sentry_sample_rate', 0.1) ) +# Whether to include PII (e.g. user id/email, IP address, request data) in reported events +SENTRY_SEND_PII = get_boolean_setting( + 'INVENTREE_SENTRY_SEND_PII', 'sentry_send_pii', False +) + if SENTRY_ENABLED and SENTRY_DSN and not TESTING: # pragma: no cover - init_sentry(SENTRY_DSN, SENTRY_SAMPLE_RATE, inventree_tags) + init_sentry( + SENTRY_DSN, SENTRY_SAMPLE_RATE, inventree_tags, send_pii=SENTRY_SEND_PII + ) # OpenTelemetry tracing TRACING_ENABLED = ( diff --git a/src/backend/InvenTree/InvenTree/test_sentry.py b/src/backend/InvenTree/InvenTree/test_sentry.py new file mode 100644 index 0000000000..23026e5e37 --- /dev/null +++ b/src/backend/InvenTree/InvenTree/test_sentry.py @@ -0,0 +1,97 @@ +"""Tests for sentry.io error reporting integration.""" + +from unittest import mock + +from django.core.exceptions import ValidationError +from django.http import Http404 +from django.test import SimpleTestCase, override_settings + +import InvenTree.sentry as sentry + + +class SentryIgnoreErrorsTest(SimpleTestCase): + """Tests for sentry_ignore_errors().""" + + def test_known_error_types_are_ignored(self): + """Http404 and DRF/Django validation errors must be in the ignore list.""" + ignored = sentry.sentry_ignore_errors() + self.assertIn(Http404, ignored) + self.assertIn(ValidationError, ignored) + + +class InitSentryTest(SimpleTestCase): + """Tests for init_sentry().""" + + def call_init_sentry(self, **kwargs): + """Call init_sentry with sentry_sdk mocked out, and return the mocked sentry_sdk.init call.""" + with ( + mock.patch('InvenTree.sentry.sentry_sdk.init') as mock_init, + mock.patch('InvenTree.sentry.sentry_sdk.set_tag'), + ): + sentry.init_sentry('https://example.test/1', 0.1, {}, **kwargs) + + return mock_init + + def test_pii_disabled_by_default(self): + """Regression test: send_default_pii must default to False. + + Previously init_sentry hard-coded send_default_pii=True, meaning enabling + sentry.io reporting - which by default reports to InvenTree's own DSN - + would always attach the reporting user's id/email, IP address and request + data to every event, with no way to opt out. + """ + mock_init = self.call_init_sentry() + self.assertFalse(mock_init.call_args.kwargs['send_default_pii']) + + def test_pii_can_be_enabled(self): + """An administrator can still explicitly opt in to sending PII.""" + mock_init = self.call_init_sentry(send_pii=True) + self.assertTrue(mock_init.call_args.kwargs['send_default_pii']) + + +class ReportExceptionTest(SimpleTestCase): + """Tests for report_exception().""" + + def call_report_exception(self, exc, enabled=True, dsn='https://example.test/1'): + """Call report_exception with the given sentry settings, and sentry_sdk mocked out.""" + with ( + override_settings(TESTING=False, SENTRY_ENABLED=enabled, SENTRY_DSN=dsn), + mock.patch('InvenTree.sentry.sentry_sdk.capture_exception') as mock_capture, + ): + sentry.report_exception(exc) + + return mock_capture + + def test_skipped_if_sentry_not_enabled(self): + """No exception should be reported if sentry is not enabled.""" + mock_capture = self.call_report_exception(ValueError('boom'), enabled=False) + mock_capture.assert_not_called() + + def test_skipped_if_dsn_not_configured(self): + """No exception should be reported if no DSN is configured.""" + mock_capture = self.call_report_exception(ValueError('boom'), dsn='') + mock_capture.assert_not_called() + + def test_ignored_error_type_is_not_reported(self): + """Error types in the ignore list must not be reported.""" + mock_capture = self.call_report_exception(Http404()) + mock_capture.assert_not_called() + + def test_other_errors_are_reported(self): + """An error type not in the ignore list must be reported.""" + exc = ValueError('boom') + mock_capture = self.call_report_exception(exc) + mock_capture.assert_called_once_with(exc, scope=None) + + def test_capture_failure_is_swallowed(self): + """report_exception must not raise if sentry_sdk itself fails.""" + with ( + override_settings( + TESTING=False, SENTRY_ENABLED=True, SENTRY_DSN='https://example.test/1' + ), + mock.patch( + 'InvenTree.sentry.sentry_sdk.capture_exception', + side_effect=RuntimeError('sentry is down'), + ), + ): + sentry.report_exception(ValueError('boom')) diff --git a/src/backend/InvenTree/config_template.yaml b/src/backend/InvenTree/config_template.yaml index a3fa90bef5..16981c89f3 100644 --- a/src/backend/InvenTree/config_template.yaml +++ b/src/backend/InvenTree/config_template.yaml @@ -113,6 +113,9 @@ email: sentry_enabled: False #sentry_sample_rate: 0.1 #sentry_dsn: https://custom@custom.ingest.sentry.io/custom +# Set sentry_send_pii to True to include PII (e.g. user id/email, IP address, request data) +# in reported events - disabled by default, especially relevant if using the default DSN +#sentry_send_pii: False # OpenTelemetry tracing/metrics - disabled by default - refer to the documentation for full list of options # This can be used to send tracing data, logs and metrics to OpenTelemetry compatible backends