Repository: netbox-community/netbox

Issue:
Data source username and password are visible in the changelog
### NetBox version

v3.6.1

### Feature type

Change to existing functionality

### Proposed functionality

When creating a new data source (git) in NetBox with username/password fields populated, a changelog entry is created that contains the diff of the username/password in plain text. 

![Screenshot from 2023-09-08 13-30-35](https://github.com/netbox-community/netbox/assets/33135168/2bcf9596-a25b-439c-ae59-691ccda7a633)

This is not ideal for a number of reasons, the primary one being that it exposes credentials to other users that can view the NetBox changelog.

I'm proposing that the the username and password fields be masked or excluded entirely from the changelog entry to prevent exposing credentials.

### Use case

It would prevent exposing credentials to other users that are able to view the changelog.

### Database changes

_No response_

### External dependencies

_No response_


Discussion:
For the Amazon S3 type, it appears the `aws_access_key_id` and `aws_secret_access_key` are also displayed in plain text as well.
We probably need a custom method in the serializers for those models to obfuscate specific fields.
We set `sensitive_parameters` on the `DataBackend` class, maybe there is a way to call this within the `to_objectchange` method

https://github.com/netbox-community/netbox/blob/2dfbd72f10f8bfddbee3c54a275d73c953d0af4b/netbox/core/data_backends.py#L40
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. NetBox is governed by a small group of core maintainers which means not all opened issues may receive direct feedback. **Do not** attempt to circumvent this process by "bumping" the issue; doing so will result in its immediate closure and you may be barred from participating in any future discussions. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
This one and feels important enough to rescue, for the sake of our security posture.
I'll take a stab at this and see if it's doable
I cannot find a way to pop out sensitive parameters from both pre and post change data. The most I was able to do was remove from post change using a hack but it caused slight performance degradation. Maybe another maintainer can help fix this.
Talked about this a bit with @jeffgdotorg this morning. IIRC we have a mechanism in place already to denote sensitive parameters. I'll take a shot at incorporating this into the serialization logic for data sources.

Resolving patch:
diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py
--- a/netbox/core/models/data.py
+++ b/netbox/core/models/data.py
@@ -14,6 +14,7 @@
 from django.utils.module_loading import import_string
 from django.utils.translation import gettext as _
 
+from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
 from netbox.models import PrimaryModel
 from netbox.models.features import JobsMixin
 from netbox.registry import registry
@@ -130,6 +131,28 @@ def clean(self):
                 'source_url': f"URLs for local sources must start with file:// (or specify no scheme)"
             })
 
+    def to_objectchange(self, action):
+        objectchange = super().to_objectchange(action)
+
+        # Censor any backend parameters marked as sensitive in the serialized data
+        pre_change_params = {}
+        post_change_params = {}
+        if objectchange.prechange_data:
+            pre_change_params = objectchange.prechange_data.get('parameters') or {}  # parameters may be None
+        if objectchange.postchange_data:
+            post_change_params = objectchange.postchange_data.get('parameters') or {}
+        for param in self.backend_class.sensitive_parameters:
+            if post_change_params.get(param):
+                if post_change_params[param] != pre_change_params.get(param):
+                    # Set the "changed" token if the parameter's value has been modified
+                    post_change_params[param] = CENSOR_TOKEN_CHANGED
+                else:
+                    post_change_params[param] = CENSOR_TOKEN
+            if pre_change_params.get(param):
+                pre_change_params[param] = CENSOR_TOKEN
+
+        return objectchange
+
     def enqueue_sync_job(self, request):
         """
         Enqueue a background job to synchronize the DataSource by calling sync().
diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py
--- a/netbox/netbox/constants.py
+++ b/netbox/netbox/constants.py
@@ -36,3 +36,7 @@
     'bulk_edit': {'change'},
     'bulk_delete': {'delete'},
 }
+
+# General-purpose tokens
+CENSOR_TOKEN = '********'
+CENSOR_TOKEN_CHANGED = '***CHANGED***'


Test patch:
diff --git a/netbox/core/tests/test_models.py b/netbox/core/tests/test_models.py
new file mode 100644
--- /dev/null
+++ b/netbox/core/tests/test_models.py
@@ -0,0 +1,122 @@
+from django.test import TestCase
+
+from core.models import DataSource
+from extras.choices import ObjectChangeActionChoices
+from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
+
+
+class DataSourceChangeLoggingTestCase(TestCase):
+
+    def test_password_added_on_create(self):
+        datasource = DataSource.objects.create(
+            name='Data Source 1',
+            type='git',
+            source_url='http://localhost/',
+            parameters={
+                'username': 'jeff',
+                'password': 'foobar123',
+            }
+        )
+
+        objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_CREATE)
+        self.assertIsNone(objectchange.prechange_data)
+        self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
+        self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN_CHANGED)
+
+    def test_password_added_on_update(self):
+        datasource = DataSource.objects.create(
+            name='Data Source 1',
+            type='git',
+            source_url='http://localhost/'
+        )
+        datasource.snapshot()
+
+        # Add a blank password
+        datasource.parameters = {
+            'username': 'jeff',
+            'password': '',
+        }
+
+        objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
+        self.assertIsNone(objectchange.prechange_data['parameters'])
+        self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
+        self.assertEqual(objectchange.postchange_data['parameters']['password'], '')
+
+        # Add a password
+        datasource.parameters = {
+            'username': 'jeff',
+            'password': 'foobar123',
+        }
+
+        objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
+        self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
+        self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN_CHANGED)
+
+    def test_password_changed(self):
+        datasource = DataSource.objects.create(
+            name='Data Source 1',
+            type='git',
+            source_url='http://localhost/',
+            parameters={
+                'username': 'jeff',
+                'password': 'password1',
+            }
+        )
+        datasource.snapshot()
+
+        # Change the password
+        datasource.parameters['password'] = 'password2'
+
+        objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
+        self.assertEqual(objectchange.prechange_data['parameters']['username'], 'jeff')
+        self.assertEqual(objectchange.prechange_data['parameters']['password'], CENSOR_TOKEN)
+        self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
+        self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN_CHANGED)
+
+    def test_password_removed_on_update(self):
+        datasource = DataSource.objects.create(
+            name='Data Source 1',
+            type='git',
+            source_url='http://localhost/',
+            parameters={
+                'username': 'jeff',
+                'password': 'foobar123',
+            }
+        )
+        datasource.snapshot()
+
+        objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
+        self.assertEqual(objectchange.prechange_data['parameters']['username'], 'jeff')
+        self.assertEqual(objectchange.prechange_data['parameters']['password'], CENSOR_TOKEN)
+        self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
+        self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN)
+
+        # Remove the password
+        datasource.parameters['password'] = ''
+
+        objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
+        self.assertEqual(objectchange.prechange_data['parameters']['username'], 'jeff')
+        self.assertEqual(objectchange.prechange_data['parameters']['password'], CENSOR_TOKEN)
+        self.assertEqual(objectchange.postchange_data['parameters']['username'], 'jeff')
+        self.assertEqual(objectchange.postchange_data['parameters']['password'], '')
+
+    def test_password_not_modified(self):
+        datasource = DataSource.objects.create(
+            name='Data Source 1',
+            type='git',
+            source_url='http://localhost/',
+            parameters={
+                'username': 'username1',
+                'password': 'foobar123',
+            }
+        )
+        datasource.snapshot()
+
+        # Remove the password
+        datasource.parameters['username'] = 'username2'
+
+        objectchange = datasource.to_objectchange(ObjectChangeActionChoices.ACTION_UPDATE)
+        self.assertEqual(objectchange.prechange_data['parameters']['username'], 'username1')
+        self.assertEqual(objectchange.prechange_data['parameters']['password'], CENSOR_TOKEN)
+        self.assertEqual(objectchange.postchange_data['parameters']['username'], 'username2')
+        self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN)