Repository: nautobot/nautobot

Issue:
Protect against Git-repo jobs clobbering sys.modules
### As ...

Patti - Platform Admin

### I want ...

To add Jobs via Git repositories without worrying about them impacting the overall stability of the platform.

After #3840 , there is a risk that a misnamed (or maliciously-named) Git repository could potentially clobber existing Python code. For example, creating a repository and assigning it the slug `nautobot` causes all sorts of havoc as it results in Nautobot unloading itself and then attempting to reimport all of Nautobot's code from the Git repository by that name.

### So that ...

The application is resilient to user error as well as mischievous/malicious actions by authenticated users.

### I know this is done when...

- Creating a GitRepository whose `slug` matches any currently loaded Python module in the Nautobot environment is rejected with an appropriate error message.

### Optional - Feature groups this request pertains to.

- [X] Automation
- [ ] Circuits
- [ ] DCIM
- [ ] IPAM
- [ ] Misc (including Data Sources)
- [ ] Organization
- [ ] Plugins (and other Extensibility)
- [X] Security (Secrets, etc)
- [ ] Image Management
- [ ] UI/UX
- [ ] Documentation
- [ ] Other (not directly a platform feature)

### Database Changes

None

### External Dependencies

None


Discussion:
As @gsnider2195 pointed out, since we add `GIT_ROOT` to the *end* of `sys.path`, we should also ensure that the git repo slug isn't shadowed by any already-importable module that would come from an earlier `sys.path` entry (e.g. standard library packages like `math` or `traceback`). Possibly `importlib.util.find_spec(gitrepo.slug)` or similar would be an appropriate check.
Add check in `clean` (see others here: https://github.com/nautobot/nautobot/blob/956c954cb9ff7e913c04e3293bd4d9aabd8fdf17/nautobot/extras/models/datasources.py#L81-L91) implementing the above https://github.com/nautobot/nautobot/issues/3859#issuecomment-1579045188

Resolving patch:
diff --git a/nautobot/extras/models/datasources.py b/nautobot/extras/models/datasources.py
--- a/nautobot/extras/models/datasources.py
+++ b/nautobot/extras/models/datasources.py
@@ -1,4 +1,5 @@
 """Models for representing external data sources."""
+from importlib.util import find_spec
 import os
 
 from django.conf import settings
@@ -81,13 +82,24 @@ def __str__(self):
     def clean(self):
         super().clean()
 
-        if self.slug != "":
-            check_if_key_is_graphql_safe(self.__class__.__name__, self.slug, "slug")
+        # Autogenerate slug now, rather than in pre_save(), if not set already, as we need to check it below.
+        if self.slug == "":
+            self._meta.get_field("slug").create_slug(self, add=(not self.present_in_database))
+
+        if self.present_in_database and self.slug != self.__initial_slug:
+            raise ValidationError(
+                f"Slug cannot be changed once set. Current slug is {self.__initial_slug}, "
+                f"requested slug is {self.slug}"
+            )
 
-            if self.present_in_database and self.slug != self.__initial_slug:
+        if not self.present_in_database:
+            check_if_key_is_graphql_safe(self.__class__.__name__, self.slug, "slug")
+            # Check on create whether the proposed slug conflicts with a module name already in the Python environment.
+            # Because we add GIT_ROOT to the end of sys.path, trying to import this repository will instead
+            # import the earlier-found Python module in its place, which would be undesirable.
+            if find_spec(self.slug) is not None:
                 raise ValidationError(
-                    f"Slug cannot be changed once set. Current slug is {self.__initial_slug}, "
-                    f"requested slug is {self.slug}"
+                    f'Please choose a different slug, as "{self.slug}" is an installed Python package or module.'
                 )
 
     def get_latest_sync(self):


Test patch:
diff --git a/nautobot/extras/tests/test_models.py b/nautobot/extras/tests/test_models.py
--- a/nautobot/extras/tests/test_models.py
+++ b/nautobot/extras/tests/test_models.py
@@ -14,7 +14,8 @@
 from django.test.utils import isolate_apps
 
 from nautobot.core.choices import ColorChoices
-from nautobot.core.testing import TestCase, TransactionTestCase
+from nautobot.core.testing import TestCase
+from nautobot.core.testing.mixins import NautobotTestCaseMixin
 from nautobot.core.testing.models import ModelTestCases
 from nautobot.dcim.models import (
     Device,
@@ -737,15 +738,12 @@ def test_delete_file_proxy(self):
         self.assertEqual(FileAttachment.objects.count(), 0)
 
 
-class GitRepositoryTest(TransactionTestCase):  # TODO: BaseModelTestCase mixin?
+class GitRepositoryTest(ModelTestCases.BaseModelTestCase):
     """
     Tests for the GitRepository model class.
-
-    Note: This is a TransactionTestCase, rather than a TestCase, because the GitRepository save() method uses
-    transaction.on_commit(), which doesn't get triggered in a normal TestCase.
     """
 
-    SAMPLE_TOKEN = "dc6542736e7b02c159d14bc08f972f9ec1e2c45fa"
+    model = GitRepository
 
     def setUp(self):
         self.repo = GitRepository(
@@ -753,11 +751,35 @@ def setUp(self):
             slug="test_git_repo",
             remote_url="http://localhost/git.git",
         )
-        self.repo.save()
+        self.repo.validated_save()
 
     def test_filesystem_path(self):
         self.assertEqual(self.repo.filesystem_path, os.path.join(settings.GIT_ROOT, self.repo.slug))
 
+    def test_slug_no_change(self):
+        """Confirm that a slug cannot be changed after creation."""
+        self.repo.slug = "a_different_slug"
+        with self.assertRaises(ValidationError):
+            self.repo.validated_save()
+
+    def test_no_module_clobbering(self):
+        """Confirm that a slug that shadows an existing Python module is safely rejected."""
+        repo = GitRepository(name=":sus:", slug="nautobot", remote_url="http://localhost/git.git")
+        with self.assertRaises(ValidationError) as handler:
+            repo.validated_save()
+        self.assertIn("Please choose a different slug", str(handler.exception))
+
+        repo.slug = "sys"
+        with self.assertRaises(ValidationError) as handler:
+            repo.validated_save()
+        self.assertIn("Please choose a different slug", str(handler.exception))
+
+        # How about part of the stdlib that we don't normally load?
+        repo.slug = "tkinter"
+        with self.assertRaises(ValidationError) as handler:
+            repo.validated_save()
+        self.assertIn("Please choose a different slug", str(handler.exception))
+
 
 class JobModelTest(ModelTestCases.BaseModelTestCase):
     """
@@ -923,9 +945,11 @@ def setUpTestData(cls):
         location_oc.validated_save()
 
 
-class RoleTest(TestCase):
+class RoleTest(NautobotTestCaseMixin, ModelTestCases.BaseModelTestCase):
     """Tests for `Role` model class."""
 
+    model = Role
+
     def test_get_for_models(self):
         """Test get_for_models returns a Roles for those models."""
 
@@ -1443,22 +1467,21 @@ def test_log_entry_creation(self):
         self.assertEqual(log_object.grouping, log.grouping)
 
 
-class WebhookTest(TestCase):  # TODO: change to BaseModelTestCase
-    def test_type_error_not_raised_when_calling_check_for_conflicts(self):
-        """
-        Test type error not raised when calling Webhook.check_for_conflicts() without passing all accepted arguments
-        """
+class WebhookTest(ModelTestCases.BaseModelTestCase):
+    model = Webhook
+
+    def setUp(self):
         device_content_type = ContentType.objects.get_for_model(Device)
-        url = "http://example.com/test"
+        self.url = "http://example.com/test"
 
-        webhooks = [
+        self.webhooks = [
             Webhook(
                 name="webhook-1",
                 enabled=True,
                 type_create=True,
                 type_update=True,
                 type_delete=False,
-                payload_url=url,
+                payload_url=self.url,
                 http_method="POST",
                 http_content_type="application/json",
             ),
@@ -1468,19 +1491,21 @@ def test_type_error_not_raised_when_calling_check_for_conflicts(self):
                 type_create=False,
                 type_update=False,
                 type_delete=True,
-                payload_url=url,
+                payload_url=self.url,
                 http_method="POST",
                 http_content_type="application/json",
             ),
         ]
-        for webhook in webhooks:
+        for webhook in self.webhooks:
             webhook.save()
             webhook.content_types.add(device_content_type)
 
-        data = {"type_create": True}
-
-        conflicts = Webhook.check_for_conflicts(instance=webhooks[1], type_create=data.get("type_create"))
+    def test_type_error_not_raised_when_calling_check_for_conflicts(self):
+        """
+        Test type error not raised when calling Webhook.check_for_conflicts() without passing all accepted arguments
+        """
+        conflicts = Webhook.check_for_conflicts(instance=self.webhooks[1], type_create=True)
         self.assertEqual(
             conflicts["type_create"],
-            [f"A webhook already exists for create on dcim | device to URL {url}"],
+            [f"A webhook already exists for create on dcim | device to URL {self.url}"],
         )