Kaynağa Gözat

Fixes #22566: Avoid name collisions when custom script name matches core module (#22625)

Jeremy Stretch 1 gün önce
ebeveyn
işleme
52a8e6a48d

+ 5 - 0
netbox/extras/constants.py

@@ -28,6 +28,11 @@ IMAGE_ATTACHMENT_IMAGE_FORMATS = {
 # Template Export
 # Template Export
 DEFAULT_MIME_TYPE = 'text/plain; charset=utf-8'
 DEFAULT_MIME_TYPE = 'text/plain; charset=utf-8'
 
 
+# Scripts
+# Prefix applied to dynamically-loaded script/report module names so that a script whose filename
+# matches a core app or package (e.g. "circuits.py") cannot shadow it in sys.modules.
+SCRIPT_MODULE_NAME_PREFIX = '_netbox_script_module_'
+
 # Webhooks
 # Webhooks
 HTTP_CONTENT_TYPE_JSON = 'application/json'
 HTTP_CONTENT_TYPE_JSON = 'application/json'
 
 

+ 7 - 3
netbox/extras/models/mixins.py

@@ -12,7 +12,7 @@ from django.utils.module_loading import import_string
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from core.models import ObjectType
 from core.models import ObjectType
-from extras.constants import DEFAULT_MIME_TYPE, JINJA_ENV_PARAMS_ALLOWED
+from extras.constants import DEFAULT_MIME_TYPE, JINJA_ENV_PARAMS_ALLOWED, SCRIPT_MODULE_NAME_PREFIX
 from extras.utils import filename_from_model, filename_from_object
 from extras.utils import filename_from_model, filename_from_object
 from utilities.jinja2 import render_jinja2
 from utilities.jinja2 import render_jinja2
 
 
@@ -68,12 +68,16 @@ class PythonModuleMixin:
         Load the module using importlib, but use a custom loader to use django-storages
         Load the module using importlib, but use a custom loader to use django-storages
         instead of the file system.
         instead of the file system.
         """
         """
-        spec = importlib.util.spec_from_file_location(self.python_name, self.name)
+        # Load the module under a namespaced name rather than its bare name. Using the bare name
+        # (e.g. "circuits") would replace the like-named core app package in sys.modules, breaking
+        # app and migration graph resolution.
+        module_name = f'{SCRIPT_MODULE_NAME_PREFIX}{self.python_name}'
+        spec = importlib.util.spec_from_file_location(module_name, self.name)
         if spec is None:
         if spec is None:
             raise ModuleNotFoundError(f"Could not find module: {self.python_name}")
             raise ModuleNotFoundError(f"Could not find module: {self.python_name}")
         loader = CustomStoragesLoader(self.name)
         loader = CustomStoragesLoader(self.name)
         module = importlib.util.module_from_spec(spec)
         module = importlib.util.module_from_spec(spec)
-        sys.modules[self.python_name] = module
+        sys.modules[module_name] = module
         loader.exec_module(module)
         loader.exec_module(module)
 
 
         return module
         return module

+ 8 - 2
netbox/extras/scripts.py

@@ -12,6 +12,7 @@ from django.utils.translation import gettext as _
 
 
 from core.choices import JobNotificationChoices
 from core.choices import JobNotificationChoices
 from extras.choices import LogLevelChoices
 from extras.choices import LogLevelChoices
+from extras.constants import SCRIPT_MODULE_NAME_PREFIX
 from extras.models import ScriptModule
 from extras.models import ScriptModule
 from ipam.formfields import IPAddressFormField, IPNetworkFormField
 from ipam.formfields import IPAddressFormField, IPNetworkFormField
 from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
 from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
@@ -349,7 +350,12 @@ class BaseScript:
 
 
     @classproperty
     @classproperty
     def module(self):
     def module(self):
-        return self.__module__
+        # Strip the internal prefix applied when the module is loaded (see #22566) so that
+        # user-facing names (full_name, logger namespaces) reflect the original script filename.
+        name = self.__module__
+        if name.startswith(SCRIPT_MODULE_NAME_PREFIX):
+            name = name[len(SCRIPT_MODULE_NAME_PREFIX):]
+        return name
 
 
     @classproperty
     @classproperty
     def class_name(self):
     def class_name(self):
@@ -361,7 +367,7 @@ class BaseScript:
 
 
     @classmethod
     @classmethod
     def root_module(cls):
     def root_module(cls):
-        return cls.__module__.split(".")[0]
+        return cls.module.split(".")[0]
 
 
     # Author-defined attributes
     # Author-defined attributes
 
 

+ 51 - 0
netbox/extras/tests/test_scripts.py

@@ -1,11 +1,16 @@
+import io
+import sys
 from datetime import UTC, date, datetime
 from datetime import UTC, date, datetime
 from decimal import Decimal
 from decimal import Decimal
+from unittest.mock import patch
 
 
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.test import TestCase
 from django.test import TestCase
 from netaddr import IPAddress, IPNetwork
 from netaddr import IPAddress, IPNetwork
 
 
 from dcim.models import DeviceRole
 from dcim.models import DeviceRole
+from extras.constants import SCRIPT_MODULE_NAME_PREFIX
+from extras.models import ScriptModule
 from extras.scripts import *
 from extras.scripts import *
 
 
 CHOICES = (
 CHOICES = (
@@ -388,3 +393,49 @@ class ScriptVariablesTestCase(TestCase):
         self.assertEqual(form.cleaned_data['var1'], input_datetime)
         self.assertEqual(form.cleaned_data['var1'], input_datetime)
         # Validate required=False works for this Var type
         # Validate required=False works for this Var type
         self.assertEqual(form.cleaned_data['var2'], None)
         self.assertEqual(form.cleaned_data['var2'], None)
+
+
+class ScriptModuleLoadingTestCase(TestCase):
+
+    def test_module_does_not_shadow_core_app(self):
+        """
+        Loading a custom script whose filename matches a core app label must not replace that
+        app's package in sys.modules. Regression test for issue #22566.
+        """
+        import circuits  # The real core app package
+
+        script_content = (
+            b"from extras.scripts import Script\n\n\n"
+            b"class TestScript(Script):\n    pass\n"
+        )
+
+        class _Storage:
+            def open(self, name, mode='rb'):
+                return io.BytesIO(script_content)
+
+        module = ScriptModule(file_root='scripts', file_path='circuits.py')
+        namespaced_key = f'{SCRIPT_MODULE_NAME_PREFIX}circuits'
+        self.addCleanup(lambda: sys.modules.pop(namespaced_key, None))
+
+        with patch('extras.models.mixins.storages') as mock_storages:
+            mock_storages.__getitem__.return_value = _Storage()
+            loaded = module.get_module()
+
+            # The script module is registered under the private, namespaced key, and its own
+            # __name__ matches that key (i.e. sys.modules[module.__name__] resolves to the module)
+            self.assertIs(sys.modules[namespaced_key], loaded)
+            self.assertEqual(loaded.__name__, namespaced_key)
+
+            # The namespacing must not leak into the derived script name stored in the database
+            self.assertEqual(next(iter(module.module_scripts)), 'TestScript')
+
+            # Nor into the user-facing names exposed on the Script class (used for logger
+            # namespaces, page headers, etc.): these must reflect the original filename.
+            script_class = loaded.TestScript
+            self.assertEqual(script_class.module, 'circuits')
+            self.assertEqual(script_class.full_name, 'circuits.TestScript')
+            self.assertEqual(script_class.root_module(), 'circuits')
+
+        # The real circuits app must be untouched and remain an importable package
+        self.assertIs(sys.modules['circuits'], circuits)
+        self.assertTrue(hasattr(circuits, '__path__'))