CVE-2023-48705: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: Nautobot is a Network Source of Truth and Network Automation Platform built as a web application All users of Nautobot versions earlier than 1.6.6 or 2.0.5 are potentially affected by a cross-site scripting vulnerability. Due to incorrect usage of Django's `mark_safe()` API when rendering certain types of user-authored content; including custom links, job buttons, and computed fields; it is possible that users with permission to create or edit these types of content could craft a malicious payload (such as JavaScript code) that would be executed when rendering pages containing this content. The maintainers have fixed the incorrect uses of `mark_safe()` (generally by replacing them with appropriate use of `format_html()` instead) to prevent such malicious data from being executed. Users on Nautobot 1.6.x LTM should upgrade to v1.6.6 and users on Nautobot 2.0.x should upgrade to v2.0.5. Appropriate object permissions can and should be applied to restrict which users are permitted to create or edit the aforementioned types of user-authored content. Other than that, there is no direct workaround available.
CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 5.4
Published: 2023-11-22T16:15Z
Repository: https://github.com/nautobot/nautobot
Language: Python
Files changed: .github/workflows/ci_pullrequest.yml, changes/4832.changed, changes/4832.housekeeping, changes/4832.security, nautobot/core/settings.py, nautobot/core/tables.py, nautobot/core/templatetags/helpers.py, nautobot/core/utils/data.py, nautobot/core/views/generic.py, nautobot/core/views/mixins.py, nautobot/core/views/utils.py, nautobot/dcim/models/devices.py, nautobot/dcim/views.py, nautobot/extras/models/customfields.py, nautobot/extras/models/models.py, nautobot/extras/models/relationships.py, nautobot/extras/tables.py, nautobot/extras/templatetags/computed_fields.py, nautobot/extras/templatetags/custom_links.py, nautobot/extras/templatetags/job_buttons.py, nautobot/extras/templatetags/plugins.py, nautobot/extras/tests/test_customfields.py, nautobot/extras/tests/test_relationships.py, nautobot/extras/tests/test_views.py, nautobot/extras/views.py, nautobot/ipam/tables.py, nautobot/ipam/views.py, poetry.lock, pyproject.toml, scripts/git-hooks/pre-commit, tasks.py
Commit date: 2023-11-21 15:16:21 -0600
Commit message: [2.0] Fix unsafe `mark_safe` use in various features (#4832)

* Audit all uses of mark_safe() and change to format_html() where appropriate

* Test updates and change fragment

* Add ruff for linting, add S308 disables to audited code

* Test fixes

* Update change fragments

* One more change fragment

---------
Diff: diff --git a/.github/workflows/ci_pullrequest.yml b/.github/workflows/ci_pullrequest.yml
index 9d6b038751..f391010dd3 100644
--- a/.github/workflows/ci_pullrequest.yml
+++ b/.github/workflows/ci_pullrequest.yml
@@ -29,10 +29,21 @@ jobs:
         uses: "actions/checkout@v2"
       - name: "Setup environment"
         uses: "networktocode/gh-action-setup-poetry-environment@v2"
       - name: "Linting: flake8"
         run: "poetry run invoke flake8"
+  ruff:
+    runs-on: "ubuntu-20.04"
+    env:
+      INVOKE_NAUTOBOT_LOCAL: "True"
+    steps:
+      - name: "Check out repository code"
+        uses: "actions/checkout@v2"
+      - name: "Setup environment"
+        uses: "networktocode/gh-action-setup-poetry-environment@v2"
+      - name: "Linting: ruff"
+        run: "poetry run invoke ruff --output-format github"
   prettier:
     runs-on: "ubuntu-20.04"
     env:
       INVOKE_NAUTOBOT_LOCAL: "True"
     steps:
@@ -210,10 +221,11 @@ jobs:
       - "check-migrations"
       - "check-schema"
       - "flake8"
       - "hadolint"
       - "markdownlint"
+      - "ruff"
       - "yamllint"
   tests-mysql:
     name: "Unit tests (MySQL and Python 3.11)"
     runs-on: "ubuntu-20.04"
     env:
@@ -260,10 +272,11 @@ jobs:
       - "check-migrations"
       - "check-schema"
       - "flake8"
       - "hadolint"
       - "markdownlint"
+      - "ruff"
       - "yamllint"
   tests-ui:
     runs-on: "ubuntu-20.04"
     env:
       INVOKE_NAUTOBOT_LOCAL: "True"
@@ -330,10 +343,11 @@ jobs:
       - "check-migrations"
       - "check-schema"
       - "flake8"
       - "hadolint"
       - "markdownlint"
+      - "ruff"
       - "yamllint"
   migration-tests-postgres:
     name: "Run migrations against test dataset (PostgreSQL and Python 3.11)"
     runs-on: "ubuntu-20.04"
     env:
@@ -371,10 +385,11 @@ jobs:
       - "check-migrations"
       - "check-schema"
       - "flake8"
       - "hadolint"
       - "markdownlint"
+      - "ruff"
       - "yamllint"
   integration-test:
     runs-on: "ubuntu-20.04"
     env:
       INVOKE_NAUTOBOT_LOCAL: "True"
@@ -418,10 +433,11 @@ jobs:
       - "check-migrations"
       - "check-schema"
       - "flake8"
       - "hadolint"
       - "markdownlint"
+      - "ruff"
       - "yamllint"
   changelog:
     if: contains(fromJson('["develop","next","develop-1.6"]'), github.base_ref)
     runs-on: "ubuntu-20.04"
     steps:
@@ -443,10 +459,11 @@ jobs:
       - "check-migrations"
       - "check-schema"
       - "flake8"
       - "hadolint"
       - "markdownlint"
+      - "ruff"
       - "yamllint"
     steps:
       - name: "Check out repository code"
         uses: "actions/checkout@v3"
       - name: "Set up QEMU"
diff --git a/changes/4832.changed b/changes/4832.changed
new file mode 100644
index 0000000000..4b01b22c82
--- /dev/null
+++ b/changes/4832.changed
@@ -0,0 +1 @@
+Changed the `render_jinja2()` API to no longer automatically call `mark_safe()` on the output.
diff --git a/changes/4832.housekeeping b/changes/4832.housekeeping
new file mode 100644
index 0000000000..1e4879cc2e
--- /dev/null
+++ b/changes/4832.housekeeping
@@ -0,0 +1 @@
+Added `ruff` to invoke tasks and CI.
diff --git a/changes/4832.security b/changes/4832.security
new file mode 100644
index 0000000000..3750c4c7dc
--- /dev/null
+++ b/changes/4832.security
@@ -0,0 +1 @@
+Fixed cross-site-scripting (XSS) potential with maliciously crafted Custom Links, Computed Fields, and Job Buttons (GHSA-cf9f-wmhp-v4pr).
diff --git a/nautobot/core/settings.py b/nautobot/core/settings.py
index d96a8e5376..c1d749e3f0 100644
--- a/nautobot/core/settings.py
+++ b/nautobot/core/settings.py
@@ -643,11 +643,11 @@ CONSTANCE_CONFIG = {
         # Use custom field type defined above
         field_type="per_page_defaults_field",
     ),
     "NETWORK_DRIVERS": ConstanceConfigItem(
         default={},
-        help_text=mark_safe(
+        help_text=mark_safe(  # noqa: S308
             "Extend or override default Platform.network_driver translations provided by "
             '<a href="https://netutils.readthedocs.io/en/latest/user/lib_use_cases_lib_mapper/">netutils</a>. '
             "Enter a dictionary in JSON format, for example:\n"
             "<pre>{\n"
             '    "netmiko": {"my_network_driver": "cisco_ios"},\n'
diff --git a/nautobot/core/tables.py b/nautobot/core/tables.py
index ba7c54aa3f..a46bbed488 100644
--- a/nautobot/core/tables.py
+++ b/nautobot/core/tables.py
@@ -2,11 +2,11 @@ from django.contrib.auth.models import AnonymousUser
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import FieldDoesNotExist
 from django.db.models.fields.related import RelatedField
 from django.urls import reverse
-from django.utils.html import escape, format_html
+from django.utils.html import escape, format_html, format_html_join
 from django.utils.safestring import mark_safe
 from django.utils.text import Truncator
 import django_tables2
 from django_tables2.data import TableQuerysetData
 from django_tables2.utils import Accessor
@@ -160,11 +160,11 @@ class ToggleColumn(django_tables2.CheckBoxColumn):
             kwargs["attrs"] = {"td": {"class": "min-width"}}
         super().__init__(*args, default=default, visible=visible, **kwargs)
 
     @property
     def header(self):
-        return mark_safe('<input type="checkbox" class="toggle" title="Toggle all" />')
+        return mark_safe('<input type="checkbox" class="toggle" title="Toggle all" />')  # noqa: S308
 
 
 class BooleanColumn(django_tables2.Column):
     """
     Custom implementation of BooleanColumn to render a nicely-formatted checkmark or X icon instead of a Unicode
@@ -257,21 +257,21 @@ class ChoiceFieldColumn(django_tables2.Column):
     def render(self, record, bound_column, value):  # pylint: disable=arguments-differ
         if value:
             name = bound_column.name
             css_class = getattr(record, f"get_{name}_class")()
             label = getattr(record, f"get_{name}_display")()
-            return mark_safe(f'<span class="label label-{css_class}">{label}</span>')
+            return format_html('<span class="label label-{}">{}</span>', css_class, label)
         return self.default
 
 
 class ColorColumn(django_tables2.Column):
     """
     Display a color (#RRGGBB).
     """
 
     def render(self, value):
-        return mark_safe(f'<span class="label color-block" style="background-color: #{value}">&nbsp;</span>')
+        return format_html('<span class="label color-block" style="background-color: #{}">&nbsp;</span>', value)
 
 
 class ColoredLabelColumn(django_tables2.TemplateColumn):
     """
     Render a colored label (e.g. for DeviceRoles).
@@ -304,11 +304,11 @@ class LinkedCountColumn(django_tables2.Column):
     def render(self, record, value):  # pylint: disable=arguments-differ
         if value:
             url = reverse(self.viewname, kwargs=self.view_kwargs)
             if self.url_params:
                 url += "?" + "&".join([f"{k}={getattr(record, v)}" for k, v in self.url_params.items()])
-            return mark_safe(f'<a href="{url}">{value}</a>')
+            return format_html('<a href="{}">{}</a>', url, value)
         return value
 
 
 class TagColumn(django_tables2.TemplateColumn):
     """
@@ -391,24 +391,22 @@ class CustomFieldColumn(django_tables2.Column):
         kwargs["verbose_name"] = customfield.label
 
         super().__init__(*args, **kwargs)
 
     def render(self, record, bound_column, value):  # pylint: disable=arguments-differ
-        template = ""
         if self.customfield.type == choices.CustomFieldTypeChoices.TYPE_BOOLEAN:
             template = helpers.render_boolean(value)
         elif self.customfield.type == choices.CustomFieldTypeChoices.TYPE_MULTISELECT:
-            for v in value:
-                template += format_html('<span class="label label-default">{}</span> ', v)
+            template = format_html_join(" ", '<span class="label label-default">{}</span>', ((v,) for v in value))
         elif self.customfield.type == choices.CustomFieldTypeChoices.TYPE_SELECT:
             template = format_html('<span class="label label-default">{}</span>', value)
         elif self.customfield.type == choices.CustomFieldTypeChoices.TYPE_URL:
             template = format_html('<a href="{}">{}</a>', value, value)
         else:
             template = escape(value)
 
-        return mark_safe(template)
+        return template
 
 
 class RelationshipColumn(django_tables2.Column):
     """
     Display relationship association instances in the appropriate format.
@@ -433,32 +431,29 @@ class RelationshipColumn(django_tables2.Column):
             if self.side == choices.RelationshipSideChoices.SIDE_SOURCE:
                 value = [v for v in value if v.source_id == record.id]
             else:
                 value = [v for v in value if v.destination_id == record.id]
 
-        template = ""
         # Handle Symmetric Relationships
         # List `value` could be empty here [] after the filtering from above
         if len(value) < 1:
             return "—"
-        else:
-            # Handle Relationships on the many side.
-            if self.relationship.has_many(self.peer_side):
-                v = value[0]
-                meta = type(v.get_peer(record))._meta
-                name = meta.verbose_name_plural if len(value) > 1 else meta.verbose_name
-                template += format_html(
-                    '<a href="{}?relationship={}&{}_id={}">{} {}</a>',
-                    reverse("extras:relationshipassociation_list"),
-                    self.relationship.key,
-                    self.side,
-                    record.id,
-                    len(value),
-                    name,
-                )
-            # Handle Relationships on the one side.
-            else:
-                v = value[0]
-                peer = v.get_peer(record)
-                template += format_html('<a href="{}">{}</a>', peer.get_absolute_url(), peer)
 
-        return mark_safe(template)
+        # Handle Relationships on the many side.
+        if self.relationship.has_many(self.peer_side):
+            v = value[0]
+            meta = type(v.get_peer(record))._meta
+            name = meta.verbose_name_plural if len(value) > 1 else meta.verbose_name
+            return format_html(
+                '<a href="{}?relationship={}&{}_id={}">{} {}</a>',
+                reverse("extras:relationshipassociation_list"),
+                self.relationship.key,
+                self.side,
+                record.id,
+                len(value),
+                name,
+            )
+        # Handle Relationships on the one side.
+        else:
+            v = value[0]
+            peer = v.get_peer(record)
+            return format_html('<a href="{}">{}</a>', peer.get_absolute_url(), peer)
diff --git a/nautobot/core/templatetags/helpers.py b/nautobot/core/templatetags/helpers.py
index bde0453059..cf88d193e3 100644
--- a/nautobot/core/templatetags/helpers.py
+++ b/nautobot/core/templatetags/helpers.py
@@ -18,13 +18,13 @@ import yaml
 from nautobot.apps.config import get_app_settings_or_config
 from nautobot.core import forms
 from nautobot.core.utils import color, config, data, lookup
 from nautobot.core.utils.navigation import is_route_new_ui_ready
 
-HTML_TRUE = '<span class="text-success"><i class="mdi mdi-check-bold" title="Yes"></i></span>'
-HTML_FALSE = '<span class="text-danger"><i class="mdi mdi-close-thick" title="No"></i></span>'
-HTML_NONE = '<span class="text-muted">&mdash;</span>'
+HTML_TRUE = mark_safe('<span class="text-success"><i class="mdi mdi-check-bold" title="Yes"></i></span>')  # noqa: S308
+HTML_FALSE = mark_safe('<span class="text-danger"><i class="mdi mdi-close-thick" title="No"></i></span>')  # noqa: S308
+HTML_NONE = mark_safe('<span class="text-muted">&mdash;</span>')  # noqa: S308
 
 DEFAULT_SUPPORT_MESSAGE = (
     "If further assistance is required, please join the `#nautobot` channel "
     "on [Network to Code's Slack community](https://slack.networktocode.com/) and post your question."
 )
@@ -97,11 +97,11 @@ def placeholder(value):
         >>> placeholder("hello")
         "hello"
     """
     if value:
         return value
-    return mark_safe(HTML_NONE)
+    return HTML_NONE
 
 
 @library.filter()
 @register.filter()
 def add_html_id(element_str, id_str):
@@ -121,11 +121,11 @@ def add_html_id(element_str, id_str):
         '<a id="my-a" href="..." title="...">Hello!</a>'
     """
     match = re.match(r"^(.*?<\w+) ?(.*)$", element_str, flags=re.DOTALL)
     if not match:
         return element_str
-    return mark_safe(match.group(1) + format_html(' id="{}" ', id_str) + match.group(2))
+    return mark_safe(match.group(1) + format_html(' id="{}" ', id_str) + match.group(2))  # noqa: S308
 
 
 @library.filter()
 @register.filter()
 def render_boolean(value):
@@ -152,14 +152,14 @@ def render_boolean(value):
         '<span class="text-success"><i class="mdi mdi-check-bold" title="Yes"></i></span>'
         >>> render_boolean(False or "" or 0)
         '<span class="text-danger"><i class="mdi mdi-close-thick" title="No"></i></span>'
     """
     if value is None:
-        return mark_safe(HTML_NONE)
+        return HTML_NONE
     if bool(value):
-        return mark_safe(HTML_TRUE)
-    return mark_safe(HTML_FALSE)
+        return HTML_TRUE
+    return HTML_FALSE
 
 
 @library.filter()
 @register.filter(is_safe=True)
 def render_markdown(value):
@@ -178,11 +178,11 @@ def render_markdown(value):
     value = re.sub(pattern, "[\\1](\\3)", value, flags=re.IGNORECASE)
 
     # Render Markdown
     html = markdown(value, extensions=["fenced_code", "tables"])
 
-    return mark_safe(html)
+    return mark_safe(html)  # noqa: S308
 
 
 @library.filter()
 @register.filter()
 def render_json(value):
diff --git a/nautobot/core/utils/data.py b/nautobot/core/utils/data.py
index 16d3e6f50d..0d24007f7d 100644
--- a/nautobot/core/utils/data.py
+++ b/nautobot/core/utils/data.py
@@ -98,11 +98,15 @@ def render_jinja2(template_code, context):
     """
     Render a Jinja2 template with the provided context. Return the rendered content.
     """
     rendering_engine = engines["jinja"]
     template = rendering_engine.from_string(template_code)
-    return template.render(context=context)
+    # For reasons unknown to me, django-jinja2 `template.render()` implicitly calls `mark_safe()` on the rendered text.
+    # This is a security risk in general, especially so in our case because we're often using this function to render
+    # a user-provided template and don't want to open ourselves up to script injection or similar issues.
+    # There's no `mark_unsafe()` function, but concatenating a SafeString to an ordinary string (even "") suffices.
+    return "" + template.render(context=context)
 
 
 def shallow_compare_dict(source_dict, destination_dict, exclude=None):
     """
     Return a new dictionary of the different keys. The values of `destination_dict` are returned. Only the equality of
diff --git a/nautobot/core/views/generic.py b/nautobot/core/views/generic.py
index d9e7661acd..a956ec99fe 100644
--- a/nautobot/core/views/generic.py
+++ b/nautobot/core/views/generic.py
@@ -14,13 +14,12 @@ from django.core.exceptions import (
 from django.db import transaction, IntegrityError
 from django.db.models import ManyToManyField, ProtectedError
 from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput
 from django.http import HttpResponse, JsonResponse
 from django.shortcuts import get_object_or_404, redirect, render
-from django.utils.html import escape
+from django.utils.html import format_html
 from django.utils.http import is_safe_url
-from django.utils.safestring import mark_safe
 from django.views.generic import View
 from django_tables2 import RequestConfig
 from rest_framework.exceptions import ParseError
 
 from nautobot.core.api.parsers import NautobotCSVParser
@@ -207,11 +206,11 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
             filterset = self.filterset(filter_params, self.queryset)
             self.queryset = filterset.qs
             if not filterset.is_valid():
                 messages.error(
                     request,
-                    mark_safe(f"Invalid filters were specified: {filterset.errors}"),
+                    format_html("Invalid filters were specified: {}", filterset.errors),
                 )
                 self.queryset = self.queryset.none()
 
             display_filter_params = [
                 check_filter_for_display(filterset.filters, field_name, values)
@@ -395,14 +394,14 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
         """Callback after the form is successfully saved but before redirecting the user."""
         verb = "Created" if created else "Modified"
         msg = f"{verb} {self.queryset.model._meta.verbose_name}"
         logger.info(f"{msg} {obj} (PK: {obj.pk})")
         if hasattr(obj, "get_absolute_url"):
-            msg = f'{msg} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>'
+            msg = format_html('{} <a href="{}">{}</a>', msg, obj.get_absolute_url(), obj)
         else:
-            msg = f"{msg} {escape(obj)}"
-        messages.success(request, mark_safe(msg))
+            msg = format_html("{} {}", msg, obj)
+        messages.success(request, msg)
 
     def post(self, request, *args, **kwargs):
         logger = logging.getLogger(__name__ + ".ObjectEditView")
         obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs)
         form = self.model_form(data=request.POST, files=request.FILES, instance=obj)
@@ -750,11 +749,11 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 
             if not model_form.errors:
                 logger.info(f"Import object {obj} (PK: {obj.pk})")
                 messages.success(
                     request,
-                    mark_safe(f'Imported object: <a href="{obj.get_absolute_url()}">{obj}</a>'),
+                    format_html('Imported object: <a href="{}">{}</a>', obj.get_absolute_url(), obj),
                 )
 
                 if "_addanother" in request.POST:
                     return redirect(request.get_full_path())
 
diff --git a/nautobot/core/views/mixins.py b/nautobot/core/views/mixins.py
index dd99e6b7db..dd9b51010a 100644
--- a/nautobot/core/views/mixins.py
+++ b/nautobot/core/views/mixins.py
@@ -17,12 +17,11 @@ from django.http import HttpResponse
 from django.shortcuts import get_object_or_404, redirect
 from django.template.loader import select_template, TemplateDoesNotExist
 from django.urls import reverse
 from django.urls.exceptions import NoReverseMatch
 from django.utils.http import is_safe_url
-from django.utils.html import escape
-from django.utils.safestring import mark_safe
+from django.utils.html import format_html
 from django.views.generic.edit import FormView
 
 from rest_framework import mixins, exceptions
 from rest_framework.decorators import action as drf_action
 from rest_framework.parsers import FormParser, MultiPartParser
@@ -620,11 +619,11 @@ class ObjectListViewMixin(NautobotViewSetMixin, mixins.ListModelMixin):
             self.filterset = self.filterset_class(self.filter_params, queryset)
             queryset = self.filterset.qs
             if not self.filterset.is_valid():
                 messages.error(
                     self.request,
-                    mark_safe(f"Invalid filters were specified: {self.filterset.errors}"),
+                    format_html("Invalid filters were specified: {}", self.filterset.errors),
                 )
                 queryset = queryset.none()
         return queryset
 
     def check_for_export(self, request, model, content_type):
@@ -746,14 +745,14 @@ class ObjectEditViewMixin(NautobotViewSetMixin, mixins.CreateModelMixin, mixins.
                 form.save_note(instance=obj, user=request.user)
 
             msg = f'{"Created" if object_created else "Modified"} {queryset.model._meta.verbose_name}'
             self.logger.info(f"{msg} {obj} (PK: {obj.pk})")
             if hasattr(obj, "get_absolute_url"):
-                msg = f'{msg} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>'
+                msg = format_html('{} <a href="{}">{}</a>', msg, obj.get_absolute_url(), obj)
             else:
-                msg = f"{msg} { escape(obj)}"
-            messages.success(request, mark_safe(msg))
+                msg = format_html("{} {}", msg, obj)
+            messages.success(request, msg)
             if "_addanother" in request.POST:
                 # If the object has clone_fields, pre-populate a new instance of the form
                 if hasattr(obj, "clone_fields"):
                     url = f"{request.path}?{prepare_cloned_fields(obj)}"
                     self.success_url = url
diff --git a/nautobot/core/views/utils.py b/nautobot/core/views/utils.py
index 83e755dbea..b41a232aa1 100644
--- a/nautobot/core/views/utils.py
+++ b/nautobot/core/views/utils.py
@@ -1,11 +1,11 @@
 import datetime
 
 from django.contrib import messages
 from django.core.exceptions import FieldError
 from django.db.models import ForeignKey
-from django.utils.html import escape
+from django.utils.html import format_html, format_html_join
 from django.utils.safestring import mark_safe
 
 from rest_framework import serializers
 
 from nautobot.core.api.fields import ChoiceField, ContentTypeField, TimeZoneSerializerField
@@ -112,17 +112,17 @@ def get_csv_form_fields_from_serializer_class(serializer_class):
                     "required": cf_form_field.required,
                     "label": cf_form_field.label,
                     "help_text": cf_form_field.help_text,
                 }
                 if cf.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
-                    field_info["format"] = mark_safe("<code>true</code> or <code>false</code>")
+                    field_info["format"] = mark_safe("<code>true</code> or <code>false</code>")  # noqa: S308
                 elif cf.type == CustomFieldTypeChoices.TYPE_DATE:
-                    field_info["format"] = mark_safe("<code>YYYY-MM-DD</code>")
+                    field_info["format"] = mark_safe("<code>YYYY-MM-DD</code>")  # noqa: S308
                 elif cf.type == CustomFieldTypeChoices.TYPE_SELECT:
                     field_info["choices"] = {cfc.value: cfc.value for cfc in cf.custom_field_choices.all()}
                 elif cf.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
-                    field_info["format"] = mark_safe('<code>"value,value"</code>')
+                    field_info["format"] = mark_safe('<code>"value,value"</code>')  # noqa: S308
                     field_info["choices"] = {cfc.value: cfc.value for cfc in cf.custom_field_choices.all()}
                 fields.append(field_info)
             continue
 
         field_info = {
@@ -130,31 +130,31 @@ def get_csv_form_fields_from_serializer_class(serializer_class):
             "required": field.required,
             "label": field.label,
             "help_text": field.help_text,
         }
         if isinstance(field, serializers.BooleanField):
-            field_info["format"] = mark_safe("<code>true</code> or <code>false</code>")
+            field_info["format"] = mark_safe("<code>true</code> or <code>false</code>")  # noqa: S308
         elif isinstance(field, serializers.DateField):
-            field_info["format"] = mark_safe("<code>YYYY-MM-DD</code>")
+            field_info["format"] = mark_safe("<code>YYYY-MM-DD</code>")  # noqa: S308
         elif isinstance(field, TimeZoneSerializerField):
-            field_info["format"] = mark_safe(
+            field_info["format"] = mark_safe(  # noqa: S308
                 '<a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones">available options</a>'
             )
         elif isinstance(field, serializers.ManyRelatedField):
             if field.field_name == "tags":
-                field_info["format"] = mark_safe('<code>"name,name"</code> or <code>"UUID,UUID"</code>')
+                field_info["format"] = mark_safe('<code>"name,name"</code> or <code>"UUID,UUID"</code>')  # noqa: S308
             elif isinstance(field.child_relation, ContentTypeField):
-                field_info["format"] = mark_safe('<code>"app_label.model,app_label.model"</code>')
+                field_info["format"] = mark_safe('<code>"app_label.model,app_label.model"</code>')  # noqa: S308
             else:
-                field_info["format"] = mark_safe('<code>"UUID,UUID"</code>')
+                field_info["format"] = mark_safe('<code>"UUID,UUID"</code>')  # noqa: S308
         elif isinstance(field, serializers.RelatedField):
             if isinstance(field, ContentTypeField):
-                field_info["format"] = mark_safe("<code>app_label.model</code>")
+                field_info["format"] = mark_safe("<code>app_label.model</code>")  # noqa: S308
             else:
-                field_info["format"] = mark_safe("<code>UUID</code>")
+                field_info["format"] = mark_safe("<code>UUID</code>")  # noqa: S308
         elif isinstance(field, (serializers.ListField, serializers.MultipleChoiceField)):
-            field_info["format"] = mark_safe('<code>"value,value"</code>')
+            field_info["format"] = mark_safe('<code>"value,value"</code>')  # noqa: S308
         elif isinstance(field, (serializers.DictField, serializers.JSONField)):
             pass  # Not trivial to specify a format as it could be a JSON dict or a comma-separated string
 
         if isinstance(field, ChoiceField):
             field_info["choices"] = field.choices
@@ -171,25 +171,24 @@ def handle_protectederror(obj_list, request, e):
     """
     Generate a user-friendly error message in response to a ProtectedError exception.
     """
     protected_objects = list(e.protected_objects)
     protected_count = len(protected_objects) if len(protected_objects) <= 50 else "More than 50"
-    err_message = (
-        f"Unable to delete <strong>{', '.join(str(obj) for obj in obj_list)}</strong>. "
-        f"{protected_count} dependent objects were found: "
+    err_message = format_html(
+        "Unable to delete <strong>{}</strong>. {} dependent objects were found: ",
+        ", ".join(str(obj) for obj in obj_list),
+        protected_count,
     )
 
     # Append dependent objects to error message
-    dependent_objects = []
-    for dependent in protected_objects[:50]:
-        if hasattr(dependent, "get_absolute_url"):
-            dependent_objects.append(f'<a href="{dependent.get_absolute_url()}">{escape(dependent)}</a>')
-        else:
-            dependent_objects.append(str(dependent))
-    err_message += ", ".join(dependent_objects)
+    err_message += format_html_join(
+        ", ",
+        '<a href="{}">{}</a>',
+        ((dependent.get_absolute_url(), dependent) for dependent in protected_objects[:50]),
+    )
 
-    messages.error(request, mark_safe(err_message))
+    messages.error(request, err_message)
 
 
 def prepare_cloned_fields(instance):
     """
     Compile an object's `clone_fields` list into a string of URL query parameters. Tags are automatically cloned where
diff --git a/nautobot/dcim/models/devices.py b/nautobot/dcim/models/devices.py
index d0f80c1205..5bf0f9ee63 100644
--- a/nautobot/dcim/models/devices.py
+++ b/nautobot/dcim/models/devices.py
@@ -6,14 +6,13 @@ from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.serializers.json import DjangoJSONEncoder
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db.models import F, ProtectedError, Q
-from django.utils.functional import cached_property
 from django.urls import reverse
-from django.utils.functional import classproperty
-from django.utils.safestring import mark_safe
+from django.utils.functional import cached_property, classproperty
+from django.utils.html import format_html
 
 from nautobot.core.models import BaseManager
 from nautobot.core.models.fields import NaturalOrderingField
 from nautobot.core.models.generics import OrganizationalModel, PrimaryModel
 from nautobot.core.utils.config import get_settings_or_config
@@ -255,13 +254,15 @@ class DeviceType(PrimaryModel):
             racked_instance_count = Device.objects.filter(device_type=self, position__isnull=False).count()
             if racked_instance_count:
                 url = f"{reverse('dcim:device_list')}?manufacturer={self.manufacturer_id}&device_type={self.pk}"
                 raise ValidationError(
                     {
-                        "u_height": mark_safe(
-                            f'Unable to set 0U height: Found <a href="{url}">{racked_instance_count} instances</a> already '
-                            f"mounted within racks."
+                        "u_height": format_html(
+                            "Unable to set 0U height: "
+                            'Found <a href="{}">{} instances</a> already mounted within racks.',
+                            url,
+                            racked_instance_count,
                         )
                     }
                 )
 
         if (self.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT) and self.device_bay_templates.count():
diff --git a/nautobot/dcim/views.py b/nautobot/dcim/views.py
index a7dc306110..329ffbfd03 100644
--- a/nautobot/dcim/views.py
+++ b/nautobot/dcim/views.py
@@ -11,12 +11,11 @@ from django.forms import (
     MultipleHiddenInput,
     modelformset_factory,
 )
 from django.shortcuts import get_object_or_404, redirect, render
 from django.utils.functional import cached_property
-from django.utils.html import escape
-from django.utils.safestring import mark_safe
+from django.utils.html import format_html
 from django.views.generic import View
 from django_tables2 import RequestConfig
 
 from nautobot.circuits.models import Circuit
 from nautobot.core.forms import ConfirmationForm
@@ -2632,12 +2631,12 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
             data = {k: request.POST[k] for k in ["vc_position", "vc_priority"]}
             membership_form = forms.DeviceVCMembershipForm(data=data, validate_vc_position=True, instance=device)
 
             if membership_form.is_valid():
                 membership_form.save()
-                msg = f'Added member <a href="{device.get_absolute_url()}">{escape(device)}</a>'
-                messages.success(request, mark_safe(msg))
+                msg = format_html('Added member <a href="{}">{}</a>', device.get_absolute_url(), device)
+                messages.success(request, msg)
 
                 if "_addanother" in request.POST:
                     return redirect(request.get_full_path())
 
                 return redirect(self.get_return_url(request, device))
@@ -2682,12 +2681,12 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL
         form = ConfirmationForm(request.POST)
 
         # Protect master device from being removed
         virtual_chassis = VirtualChassis.objects.filter(master=device).first()
         if virtual_chassis is not None:
-            msg = f"Unable to remove master device {escape(device)} from the virtual chassis."
-            messages.error(request, mark_safe(msg))
+            msg = format_html("Unable to remove master device {} from the virtual chassis.", device)
+            messages.error(request, msg)
             return redirect(device.get_absolute_url())
 
         if form.is_valid():
             devices = Device.objects.filter(pk=device.pk)
             for device in devices:
diff --git a/nautobot/extras/models/customfields.py b/nautobot/extras/models/customfields.py
index 8e291b1770..e0cb3b7301 100644
--- a/nautobot/extras/models/customfields.py
+++ b/nautobot/extras/models/customfields.py
@@ -8,11 +8,11 @@ from django.db import models, transaction
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.serializers.json import DjangoJSONEncoder
 from django.core.validators import RegexValidator, ValidationError
 from django.forms.widgets import TextInput
-from django.utils.safestring import mark_safe
+from django.utils.html import format_html
 
 from nautobot.core.forms import (
     add_blank_choice,
     CommentField,
     CSVChoiceField,
@@ -515,11 +515,11 @@ class CustomField(BaseModel, ChangeLoggedModel, NotesMixin):
 
             if self.validation_regex:
                 field.validators = [
                     RegexValidator(
                         regex=self.validation_regex,
-                        message=mark_safe(f"Values must match this regex: <code>{self.validation_regex}</code>"),
+                        message=format_html("Values must match this regex: <code>{}</code>", self.validation_regex),
                     )
                 ]
 
         # Markdown
         elif self.type == CustomFieldTypeChoices.TYPE_MARKDOWN:
diff --git a/nautobot/extras/models/models.py b/nautobot/extras/models/models.py
index fda716d627..77f00c2afd 100644
--- a/nautobot/extras/models/models.py
+++ b/nautobot/extras/models/models.py
@@ -328,16 +328,19 @@ class CustomLink(BaseModel, ChangeLoggedModel, NotesMixin):
         related_name="custom_links",
     )
     name = models.CharField(max_length=100, unique=True)
     text = models.CharField(
         max_length=500,
-        help_text="Jinja2 template code for link text. Reference the object as <code>{{ obj }}</code> such as <code>{{ obj.platform.name }}</code>. Links which render as empty text will not be displayed.",
+        help_text="Jinja2 template code for link text. "
+        "Reference the object as <code>{{ obj }}</code> such as <code>{{ obj.platform.name }}</code>. "
+        "Links which render as empty text will not be displayed.",
     )
     target_url = models.CharField(
         max_length=500,
         verbose_name="URL",
-        help_text="Jinja2 template code for link URL. Reference the object as <code>{{ obj }}</code> such as <code>{{ obj.platform.name }}</code>.",
+        help_text="Jinja2 template code for link URL. "
+        "Reference the object as <code>{{ obj }}</code> such as <code>{{ obj.platform.name }}</code>.",
     )
     weight = models.PositiveSmallIntegerField(default=100)
     group_name = models.CharField(
         max_length=50,
         blank=True,
diff --git a/nautobot/extras/models/relationships.py b/nautobot/extras/models/relationships.py
index 5d97e75272..a13cdadb4e 100644
--- a/nautobot/extras/models/relationships.py
+++ b/nautobot/extras/models/relationships.py
@@ -7,20 +7,21 @@ from django.core.exceptions import ValidationError
 from django.core.serializers.json import DjangoJSONEncoder
 from django.db import models
 from django.db.models import Q
 from django.urls import reverse
 from django.urls.exceptions import NoReverseMatch
-from django.utils.safestring import mark_safe
+from django.utils.html import format_html
 
 from nautobot.core.forms import (
     DynamicModelChoiceField,
     DynamicModelMultipleChoiceField,
     widgets,
 )
 from nautobot.core.models import BaseManager, BaseModel
 from nautobot.core.models.fields import AutoSlugField, slugify_dashes_to_underscores
 from nautobot.core.models.querysets import RestrictedQuerySet
+from nautobot.core.templatetags.helpers import bettertitle
 from nautobot.core.utils.lookup import get_filterset_for_model, get_route_for_model
 from nautobot.extras.choices import RelationshipTypeChoices, RelationshipRequiredSideChoices, RelationshipSideChoices
 from nautobot.extras.utils import FeatureQuery, check_if_key_is_graphql_safe, extras_features
 from nautobot.extras.models import ChangeLoggedModel
 from nautobot.extras.models.mixins import NotesMixin
@@ -273,13 +274,14 @@ class RelationshipModel(models.Model):
                 )
 
                 if output_for == "ui":
                     try:
                         add_url = reverse(get_route_for_model(required_model_class, "add"))
-                        hint = (
-                            f"<a target='_blank' href='{add_url}'>Click here</a> to create "
-                            f"a {required_model_meta.verbose_name}."
+                        hint = format_html(
+                            '<a target="_blank" href="{}">Click here</a> to create a {}.',
+                            add_url,
+                            required_model_meta.verbose_name,
                         )
                     except NoReverseMatch:
                         pass
 
                 elif output_for == "api":
@@ -287,15 +289,18 @@ class RelationshipModel(models.Model):
                         api_post_url = reverse(get_route_for_model(required_model_class, "list", api=True))
                         hint = f"Create a {required_model_meta.verbose_name} by posting to {api_post_url}"
                     except NoReverseMatch:
                         pass
 
-                error_message = mark_safe(
-                    f"{name_plural[0].upper()}{name_plural[1:]} require "
-                    f"{num_required_verbose} {required_model_meta.verbose_name}, but no "
-                    f"{required_model_meta.verbose_name_plural} exist yet. {hint}"
+                error_message = format_html(
+                    "{} require {} {}, but no {} exist yet. ",
+                    bettertitle(name_plural),
+                    num_required_verbose,
+                    required_model_meta.verbose_name,
+                    required_model_meta.verbose_name_plural,
                 )
+                error_message += hint
                 field_errors[field_key].append(error_message)
 
             if initial_data is not None:
                 supplied_data = []
 
diff --git a/nautobot/extras/tables.py b/nautobot/extras/tables.py
index b88f6c9cc3..0369317269 100644
--- a/nautobot/extras/tables.py
+++ b/nautobot/extras/tables.py
@@ -1,9 +1,8 @@
 import django_tables2 as tables
 from django.conf import settings
 from django.utils.html import format_html
-from django.utils.safestring import mark_safe
 from django_tables2.utils import Accessor
 from jsonschema.exceptions import ValidationError as JSONSchemaValidationError
 
 from nautobot.core.templatetags.helpers import render_boolean, render_markdown
 from nautobot.core.tables import (
@@ -234,11 +233,11 @@ class CustomFieldTable(BaseTable):
             "weight",
         )
 
     def render_description(self, record):
         if record.description:
-            return mark_safe(render_markdown(record.description))
+            return render_markdown(record.description)
         return self.default
 
 
 class CustomLinkTable(BaseTable):
     pk = ToggleColumn()
diff --git a/nautobot/extras/templatetags/computed_fields.py b/nautobot/extras/templatetags/computed_fields.py
index 4673853541..93902b2293 100644
--- a/nautobot/extras/templatetags/computed_fields.py
+++ b/nautobot/extras/templatetags/computed_fields.py
@@ -1,9 +1,8 @@
 from django import template
 from django.contrib.contenttypes.models import ContentType
-from django.utils.html import escape
-from django.utils.safestring import mark_safe
+from django.utils.html import format_html_join
 
 from nautobot.extras.models import ComputedField
 
 register = template.Library()
 
@@ -25,16 +24,10 @@ def computed_fields(context, obj, advanced_ui=None):
     """
     fields = obj.get_computed_fields(label_as_key=True, advanced_ui=advanced_ui)
     if not computed_fields:
         return ""
 
-    template_code = ""
-
-    for label, value in fields.items():
-        escaped_label = escape(label)
-        template_code += f"""
-            <tr>
-                <td><span title="{escaped_label}">{escaped_label}</span></td>
-                <td>{escape(value)}</td>
-            <tr>
-            """
-    return mark_safe(template_code)
+    return format_html_join(
+        "\n",
+        '<tr><td><span title="{}">{}</span></td><td>{}</td></tr>',
+        ((label, label, value) for label, value in fields.items()),
+    )
diff --git a/nautobot/extras/templatetags/custom_links.py b/nautobot/extras/templatetags/custom_links.py
index acbc41514b..be6b6f9438 100644
--- a/nautobot/extras/templatetags/custom_links.py
+++ b/nautobot/extras/templatetags/custom_links.py
@@ -1,9 +1,10 @@
 from collections import OrderedDict
 
 from django import template
 from django.contrib.contenttypes.models import ContentType
+from django.utils.html import format_html
 from django.utils.safestring import mark_safe
 
 from nautobot.core.utils.data import render_jinja2
 from nautobot.extras.models import CustomLink
 
@@ -38,11 +39,11 @@ def custom_links(context, obj):
         "debug": context.get("debug", False),  # django.template.context_processors.debug
         "request": context["request"],  # django.template.context_processors.request
         "user": context["user"],  # django.contrib.auth.context_processors.auth
         "perms": context["perms"],  # django.contrib.auth.context_processors.auth
     }
-    template_code = ""
+    template_code = mark_safe("")  # noqa: S308
     group_names = OrderedDict()
 
     for cl in links:
         # Organize custom links by group
         if cl.group_name and cl.group_name in group_names:
@@ -55,33 +56,39 @@ def custom_links(context, obj):
             try:
                 text_rendered = render_jinja2(cl.text, link_context)
                 if text_rendered:
                     link_rendered = render_jinja2(cl.target_url, link_context)
                     link_target = ' target="_blank"' if cl.new_window else ""
-                    template_code += LINK_BUTTON.format(link_rendered, link_target, cl.button_class, text_rendered)
+                    template_code += format_html(
+                        LINK_BUTTON, link_rendered, link_target, cl.button_class, text_rendered
+                    )
             except Exception as e:
-                template_code += (
-                    f'<a class="btn btn-sm btn-default" disabled="disabled" title="{e}">'
-                    f'<i class="mdi mdi-alert"></i> {cl.name}</a>\n'
+                template_code += format_html(
+                    '<a class="btn btn-sm btn-default" disabled="disabled" title="{}">'
+                    '<i class="mdi mdi-alert"></i> {}</a>\n',
+                    e,
+                    cl.name,
                 )
 
     # Add grouped links to template
     for group, links in group_names.items():
-        links_rendered = []
+        links_rendered = mark_safe("")  # noqa: S308
 
         for cl in links:
             try:
                 text_rendered = render_jinja2(cl.text, link_context)
                 if text_rendered:
                     link_target = ' target="_blank"' if cl.new_window else ""
                     link_rendered = render_jinja2(cl.target_url, link_context)
-                    links_rendered.append(GROUP_LINK.format(link_rendered, link_target, text_rendered))
+                    links_rendered += format_html(GROUP_LINK, link_rendered, link_target, text_rendered)
             except Exception as e:
-                links_rendered.append(
-                    f'<li><a disabled="disabled" title="{e}"><span class="text-muted">'
-                    f'<i class="mdi mdi-alert"></i> {cl.name}</span></a></li>'
+                links_rendered += format_html(
+                    '<li><a disabled="disabled" title="{}"><span class="text-muted">'
+                    '<i class="mdi mdi-alert"></i> {}</span></a></li>',
+                    e,
+                    cl.name,
                 )
 
         if links_rendered:
-            template_code += GROUP_BUTTON.format(links[0].button_class, group, "".join(links_rendered))
+            template_code += format_html(GROUP_BUTTON, links[0].button_class, group, links_rendered)
 
-    return mark_safe(template_code)
+    return template_code
diff --git a/nautobot/extras/templatetags/job_buttons.py b/nautobot/extras/templatetags/job_buttons.py
index 25be9c6512..c369a11a3e 100644
--- a/nautobot/extras/templatetags/job_buttons.py
+++ b/nautobot/extras/templatetags/job_buttons.py
@@ -1,10 +1,11 @@
 from collections import OrderedDict
 
 from django import template
 from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
+from django.utils.html import format_html
 from django.utils.safestring import mark_safe
 
 from nautobot.extras.models import JobButton
 from nautobot.core.utils.data import render_jinja2
 
@@ -85,14 +86,15 @@ def job_buttons(context, obj):
         "debug": context.get("debug", False),  # django.template.context_processors.debug
         "request": context["request"],  # django.template.context_processors.request
         "user": context["user"],  # django.contrib.auth.context_processors.auth
         "perms": context["perms"],  # django.contrib.auth.context_processors.auth
     }
-    buttons_html = forms_html = ""
+    buttons_html = forms_html = mark_safe("")  # noqa: S308
     group_names = OrderedDict()
 
-    hidden_inputs = HIDDEN_INPUTS.format(
+    hidden_inputs = format_html(
+        HIDDEN_INPUTS,
         csrf_token=context["csrf_token"],
         object_pk=obj.pk,
         object_model_name=f"{content_type.app_label}.{content_type.model}",
         redirect_path=context["request"].path,
     )
@@ -119,26 +121,28 @@ def job_buttons(context, obj):
             try:
                 text_rendered = render_jinja2(jb.text, button_context)
                 if text_rendered:
                     template_args["button_text"] = text_rendered
                     if jb.confirmation:
-                        buttons_html += CONFIRM_BUTTON.format(**template_args)
-                        forms_html += CONFIRM_MODAL.format(**template_args)
+                        buttons_html += format_html(CONFIRM_BUTTON, **template_args)
+                        forms_html += format_html(CONFIRM_MODAL, **template_args)
                     else:
-                        buttons_html += NO_CONFIRM_BUTTON.format(**template_args)
-                        forms_html += NO_CONFIRM_FORM.format(**template_args)
+                        buttons_html += format_html(NO_CONFIRM_BUTTON, **template_args)
+                        forms_html += format_html(NO_CONFIRM_FORM, **template_args)
             except Exception as e:
-                buttons_html += (
-                    f'<a class="btn btn-sm btn-default" disabled="disabled" title="{e}">'
-                    f'<i class="mdi mdi-alert"></i> {jb.name}</a>\n'
+                buttons_html += format_html(
+                    '<a class="btn btn-sm btn-default" disabled="disabled" title="{}">'
+                    '<i class="mdi mdi-alert"></i> {}</a>\n',
+                    e,
+                    jb.name,
                 )
 
     # Add grouped buttons to template
     for group_name, buttons in group_names.items():
         group_button_class = buttons[0].button_class
 
-        buttons_rendered = ""
+        buttons_rendered = mark_safe("")  # noqa: S308
 
         for jb in buttons:
             template_args = {
                 "button_id": jb.pk,
                 "button_text": jb.text,
@@ -152,25 +156,36 @@ def job_buttons(context, obj):
             try:
                 text_rendered = render_jinja2(jb.text, button_context)
                 if text_rendered:
                     template_args["button_text"] = text_rendered
                     if jb.confirmation:
-                        buttons_rendered += "<li>" + CONFIRM_BUTTON.format(**template_args) + "</li>"
-                        forms_html += CONFIRM_MODAL.format(**template_args)
+                        buttons_rendered += (
+                            mark_safe("<li>")  # noqa: S308
+                            + format_html(CONFIRM_BUTTON, **template_args)
+                            + mark_safe("</li>")  # noqa: S308
+                        )
+                        forms_html += format_html(CONFIRM_MODAL, **template_args)
                     else:
-                        buttons_rendered += "<li>" + NO_CONFIRM_BUTTON.format(**template_args) + "</li>"
-                        forms_html += NO_CONFIRM_FORM.format(**template_args)
+                        buttons_rendered += (
+                            mark_safe("<li>")  # noqa: S308
+                            + format_html(NO_CONFIRM_BUTTON, **template_args)
+                            + mark_safe("</li>")  # noqa: S308
+                        )
+                        forms_html += format_html(NO_CONFIRM_FORM, **template_args)
             except Exception as e:
-                buttons_rendered += (
-                    f'<li><a disabled="disabled" title="{e}"><span class="text-muted">'
-                    f'<i class="mdi mdi-alert"></i> {jb.name}</span></a></li>'
+                buttons_rendered += format_html(
+                    '<li><a disabled="disabled" title="{}"><span class="text-muted">'
+                    '<i class="mdi mdi-alert"></i> {}</span></a></li>',
+                    e,
+                    jb.name,
                 )
 
         if buttons_rendered:
-            buttons_html += GROUP_DROPDOWN.format(
+            buttons_html += format_html(
+                GROUP_DROPDOWN,
                 group_button_class=group_button_class,
                 group_name=group_name,
                 grouped_buttons=buttons_rendered,
             )
 
     # We want all of the buttons first and then any modals and forms so the buttons render properly
-    return mark_safe(buttons_html + forms_html)
+    return buttons_html + forms_html
diff --git a/nautobot/extras/templatetags/plugins.py b/nautobot/extras/templatetags/plugins.py
index 301b2baa7c..6c6986c9b7 100644
--- a/nautobot/extras/templatetags/plugins.py
+++ b/nautobot/extras/templatetags/plugins.py
@@ -50,11 +50,11 @@ def _get_registered_content(obj, method, template_context, return_html=True):
             html += content
 
     if not return_html:
         return objects
 
-    return mark_safe(html)
+    return mark_safe(html)  # noqa: S308
 
 
 @register.simple_tag(takes_context=True)
 def plugin_buttons(context, obj):
     """
diff --git a/nautobot/extras/tests/test_customfields.py b/nautobot/extras/tests/test_customfields.py
index ef42abcfe8..25a991ee34 100644
--- a/nautobot/extras/tests/test_customfields.py
+++ b/nautobot/extras/tests/test_customfields.py
@@ -2117,11 +2117,11 @@ class CustomFieldTableTest(TestCase):
             "boolean_field": '<span class="text-success"><i class="mdi mdi-check-bold" title="Yes"></i></span>',
             "date_field": "2020-01-02",
             "url_field": '<a href="http://example.com/2">http://example.com/2</a>',
             "choice_field": '<span class="label label-default">Bar</span>',
             "multi_choice_field": (
-                '<span class="label label-default">Bar</span> <span class="label label-default">Baz</span> '
+                '<span class="label label-default">Bar</span> <span class="label label-default">Baz</span>'
             ),
         }
 
         bound_row = location_table.rows[0]
 
diff --git a/nautobot/extras/tests/test_relationships.py b/nautobot/extras/tests/test_relationships.py
index fb0b377414..e96b71956f 100644
--- a/nautobot/extras/tests/test_relationships.py
+++ b/nautobot/extras/tests/test_relationships.py
@@ -1204,17 +1204,17 @@ class RequiredRelationshipTestMixin:
                 "required_objects_generator": [
                     lambda: Platform.objects.create(name="New Platform 2", napalm_args="null")
                 ],
                 "expected_errors": {
                     "api": {
-                        "objects_nonexistent": "Circuit types require a platform, but no platforms exist yet. "
+                        "objects_nonexistent": "Circuit Types require a platform, but no platforms exist yet. "
                         "Create a platform by posting to /api/dcim/platforms/",
                         "objects_not_specified": 'You need to specify ["relationships"]["circuittype_platform_o2o"]'
                         '["destination"]["objects"].',
                     },
                     "ui": {
-                        "objects_nonexistent": "Circuit types require a platform, but no platforms exist yet.",
+                        "objects_nonexistent": "Circuit Types require a platform, but no platforms exist yet.",
                         "objects_not_specified": "You need to select a platform.",
                     },
                 },
             },
         ]
diff --git a/nautobot/extras/tests/test_views.py b/nautobot/extras/tests/test_views.py
index 901489c9a1..98ebce78a7 100644
--- a/nautobot/extras/tests/test_views.py
+++ b/nautobot/extras/tests/test_views.py
@@ -6,10 +6,11 @@ from django.contrib.auth import get_user_model
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.test import override_settings
 from django.urls import reverse
 from django.utils import timezone
+from django.utils.html import format_html
 from unittest import mock
 
 from nautobot.core.choices import ColorChoices
 from nautobot.core.models.fields import slugify_dashes_to_underscores
 from nautobot.core.testing import ViewTestCases, TestCase, extract_page_body, extract_form_failures
@@ -23,10 +24,11 @@ from nautobot.extras.choices import (
     SecretsGroupAccessTypeChoices,
     SecretsGroupSecretTypeChoices,
 )
 from nautobot.extras.constants import HTTP_CONTENT_TYPE_JSON
 from nautobot.extras.models import (
+    ComputedField,
     ConfigContext,
     ConfigContextSchema,
     CustomField,
     CustomLink,
     DynamicGroup,
@@ -46,11 +48,10 @@ from nautobot.extras.models import (
     SecretsGroup,
     SecretsGroupAssociation,
     Status,
     Tag,
     Webhook,
-    ComputedField,
 )
 from nautobot.extras.tests.constants import BIG_GRAPHQL_DEVICE_QUERY
 from nautobot.extras.tests.test_relationships import RequiredRelationshipTestMixin
 from nautobot.extras.utils import RoleModelsQuery, TaggableClassesQuery
 from nautobot.ipam.models import IPAddress, Prefix, VLAN, VLANGroup
@@ -128,10 +129,67 @@ class ComputedFieldTestCase(
         }
 
         cls.slug_test_object = "Computed Field Five"
 
 
+class ComputedFieldRenderingTestCase(TestCase):
+    """Tests for the inclusion of ComputedFields, distinct from tests of the ComputedField views themselves."""
+
+    user_permissions = ["dcim.view_locationtype"]
+
+    def setUp(self):
+        super().setUp()
+        self.computedfield = ComputedField(
+            content_type=ContentType.objects.get_for_model(LocationType),
+            key="test",
+            label="Computed Field",
+            template="FOO {{ obj.name }} BAR",
+            fallback_value="Fallback Value",
+            weight=100,
+        )
+        self.computedfield.validated_save()
+        self.location_type = LocationType.objects.get(name="Campus")
+
+    def test_view_object_with_computed_field(self):
+        """Ensure that the computed field template is rendered."""
+        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
+        self.assertEqual(response.status_code, 200)
+        content = extract_page_body(response.content.decode(response.charset))
+        self.assertIn(f"FOO {self.location_type.name} BAR", content, content)
+
+    def test_view_object_with_computed_field_fallback_value(self):
+        """Ensure that the fallback_value is rendered if the template fails to render."""
+        # Make the template invalid to demonstrate the fallback value
+        self.computedfield.template = "FOO {{ obj."
+        self.computedfield.validated_save()
+        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
+        self.assertEqual(response.status_code, 200)
+        content = extract_page_body(response.content.decode(response.charset))
+        self.assertIn("Fallback Value", content, content)
+
+    def test_view_object_with_computed_field_unsafe_template(self):
+        """Ensure that computed field templates can't be used as an XSS vector."""
+        self.computedfield.template = '<script>alert("Hello world!"</script>'
+        self.computedfield.validated_save()
+        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
+        self.assertEqual(response.status_code, 200)
+        content = extract_page_body(response.content.decode(response.charset))
+        self.assertNotIn("<script>alert", content, content)
+        self.assertIn("&lt;script&gt;alert", content, content)
+
+    def test_view_object_with_computed_field_unsafe_fallback_value(self):
+        """Ensure that computed field fallback values can't be used as an XSS vector."""
+        self.computedfield.template = "FOO {{ obj."
+        self.computedfield.fallback_value = '<script>alert("Hello world!"</script>'
+        self.computedfield.validated_save()
+        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
+        self.assertEqual(response.status_code, 200)
+        content = extract_page_body(response.content.decode(response.charset))
+        self.assertNotIn("<script>alert", content, content)
+        self.assertIn("&lt;script&gt;alert", content, content)
+
+
 # TODO: Change base class to PrimaryObjectViewTestCase
 # Blocked by absence of standard create/edit, bulk create views
 class ConfigContextTestCase(
     ViewTestCases.CreateObjectViewTestCase,
     ViewTestCases.GetObjectViewTestCase,
@@ -430,11 +488,13 @@ class CustomFieldTestCase(
         self.form_data = self.form_data.copy()
         self.form_data["key"] = "custom_field_boolean_2"
         super().test_create_object_with_constrained_permission()
 
 
-class CustomLinkTest(TestCase):
+class CustomLinkRenderingTestCase(TestCase):
+    """Tests for the inclusion of CustomLinks, distinct from tests of the CustomLink views themselves."""
+
     user_permissions = ["dcim.view_location"]
 
     def test_view_object_with_custom_link(self):
         customlink = CustomLink(
             content_type=ContentType.objects.get_for_model(Location),
@@ -452,10 +512,75 @@ class CustomLinkTest(TestCase):
         response = self.client.get(location.get_absolute_url(), follow=True)
         self.assertEqual(response.status_code, 200)
         content = extract_page_body(response.content.decode(response.charset))
         self.assertIn(f"FOO {location.name} BAR", content, content)
 
+    def test_view_object_with_unsafe_custom_link_text(self):
+        """Ensure that custom links can't be used as a vector for injecting scripts or breaking HTML."""
+        customlink = CustomLink(
+            content_type=ContentType.objects.get_for_model(Location),
+            name="Test",
+            text='<script>alert("Hello world!")</script>',
+            target_url="http://example.com/?location=None",
+            new_window=False,
+        )
+        customlink.validated_save()
+        location_type = LocationType.objects.get(name="Campus")
+        status = Status.objects.get_for_model(Location).first()
+        location = Location(name="Test Location", location_type=location_type, status=status)
+        location.save()
+
+        response = self.client.get(location.get_absolute_url(), follow=True)
+        self.assertEqual(response.status_code, 200)
+        content = extract_page_body(response.content.decode(response.charset))
+        self.assertNotIn("<script>alert", content, content)
+        self.assertIn("&lt;script&gt;alert", content, content)
+        self.assertIn(format_html('<a href="{}"', customlink.target_url), content, content)
+
+    def test_view_object_with_unsafe_custom_link_url(self):
+        """Ensure that custom links can't be used as a vector for injecting scripts or breaking HTML."""
+        customlink = CustomLink(
+            content_type=ContentType.objects.get_for_model(Location),
+            name="Test",
+            text="Hello",
+            target_url='"><script>alert("Hello world!")</script><a href="',
+            new_window=False,
+        )
+        customlink.validated_save()
+        location_type = LocationType.objects.get(name="Campus")
+        status = Status.objects.get_for_model(Location).first()
+        location = Location(name="Test Location", location_type=location_type, status=status)
+        location.save()
+
+        response = self.client.get(location.get_absolute_url(), follow=True)
+        self.assertEqual(response.status_code, 200)
+        content = extract_page_body(response.content.decode(response.charset))
+        self.assertNotIn("<script>alert", content, content)
+        self.assertIn("&lt;script&gt;alert", content, content)
+        self.assertIn(format_html('<a href="{}"', customlink.target_url), content, content)
+
+    def test_view_object_with_unsafe_custom_link_name(self):
+        """Ensure that custom links can't be used as a vector for injecting scripts or breaking HTML."""
+        customlink = CustomLink(
+            content_type=ContentType.objects.get_for_model(Location),
+            name='<script>alert("Hello World")</script>',
+            text="Hello",
+            target_url="http://example.com/?location={{ obj.name ",  # intentionally bad jinja2 to trigger error case
+            new_window=False,
+        )
+        customlink.validated_save()
+        location_type = LocationType.objects.get(name="Campus")
+        status = Status.objects.get_for_model(Location).first()
+        location = Location(name="Test Location", location_type=location_type, status=status)
+        location.save()
+
+        response = self.client.get(location.get_absolute_url(), follow=True)
+        self.assertEqual(response.status_code, 200)
+        content = extract_page_body(response.content.decode(response.charset))
+        self.assertNotIn("<script>alert", content, content)
+        self.assertIn("&lt;script&gt;alert", content, content)
+
 
 class DynamicGroupTestCase(
     ViewTestCases.CreateObjectViewTestCase,
     ViewTestCases.DeleteObjectViewTestCase,
     ViewTestCases.EditObjectViewTestCase,
@@ -1861,10 +1986,65 @@ class JobButtonTestCase(
             "button_class": "default",
             "confirmation": False,
         }
 
 
+class JobButtonRenderingTestCase(TestCase):
+    """Tests for the rendering of JobButtons, distinct from tests of the JobButton views themselves."""
+
+    user_permissions = ["dcim.view_locationtype"]
+
+    def setUp(self):
+        super().setUp()
+        self.job_button = JobButton(
+            name="JobButton",
+            text="JobButton {{ obj.name }}",
+            job=Job.objects.get(job_class_name="TestJobButtonReceiverSimple"),
+            confirmation=False,
+        )
+        self.job_button.validated_save()
+        self.job_button.content_types.add(ContentType.objects.get_for_model(LocationType))
+        self.location_type = LocationType.objects.get(name="Campus")
+
+    def test_view_object_with_job_button(self):
+        """Ensure that the job button is rendered."""
+        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
+        self.assertEqual(response.status_code, 200)
+        content = extract_page_body(response.content.decode(response.charset))
+        self.assertIn(f"JobButton {self.location_type.name}", content, content)
+
+    def test_view_object_with_unsafe_text(self):
+        """Ensure that JobButton text can't be used as a vector for XSS."""
+        self.job_button.text = '<script>alert("Hello world!")</script>'
+        self.job_button.validated_save()
+        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
+        self.assertEqual(response.status_code, 200)
+        content = extract_page_body(response.content.decode(response.charset))
+        self.assertNotIn("<script>alert", content, content)
+        self.assertIn("&lt;script&gt;alert", content, content)
+
+        # Make sure grouped rendering is safe too
+        self.job_button.group = '<script>alert("Goodbye")</script>'
+        self.job_button.validated_save()
+        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
+        self.assertEqual(response.status_code, 200)
+        content = extract_page_body(response.content.decode(response.charset))
+        self.assertNotIn("<script>alert", content, content)
+        self.assertIn("&lt;script&gt;alert", content, content)
+
+    def test_view_object_with_unsafe_name(self):
+        """Ensure that JobButton names can't be used as a vector for XSS."""
+        self.job_button.text = "JobButton {{ obj"
+        self.job_button.name = '<script>alert("Yo")</script>'
+        self.job_button.validated_save()
+        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
+        self.assertEqual(response.status_code, 200)
+        content = extract_page_body(response.content.decode(response.charset))
+        self.assertNotIn("<script>alert", content, content)
+        self.assertIn("&lt;script&gt;alert", content, content)
+
+
 # TODO: Convert to StandardTestCases.Views
 class ObjectChangeTestCase(TestCase):
     user_permissions = ("extras.view_objectchange",)
 
     @classmethod
diff --git a/nautobot/extras/views.py b/nautobot/extras/views.py
index 57157620d6..40181ab819 100644
--- a/nautobot/extras/views.py
+++ b/nautobot/extras/views.py
@@ -11,13 +11,12 @@ from django.forms.utils import pretty_name
 from django.http import Http404, HttpResponse, HttpResponseForbidden
 from django.shortcuts import get_object_or_404, redirect, render
 from django.template.loader import TemplateDoesNotExist, get_template
 from django.urls import reverse
 from django.utils import timezone
-from django.utils.html import escape
+from django.utils.html import format_html
 from django.utils.http import is_safe_url
-from django.utils.safestring import mark_safe
 from django.views.generic import View
 from django_tables2 import RequestConfig
 from jsonschema.validators import Draft7Validator
 
 from nautobot.core.forms import restrict_form_fields
@@ -412,14 +411,14 @@ class CustomFieldEditView(generic.ObjectEditView):
                     # <--- END difference from ObjectEditView.post()
                 verb = "Created" if object_created else "Modified"
                 msg = f"{verb} {self.queryset.model._meta.verbose_name}"
                 logger.info(f"{msg} {obj} (PK: {obj.pk})")
                 if hasattr(obj, "get_absolute_url"):
-                    msg = f'{msg} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>'
+                    msg = format_html('{} <a href="{}">{}</a>', msg, obj.get_absolute_url(), obj)
                 else:
-                    msg = f"{msg} {escape(obj)}"
-                messages.success(request, mark_safe(msg))
+                    msg = format_html("{} {}", msg, obj)
+                messages.success(request, msg)
 
                 if "_addanother" in request.POST:
                     # If the object has clone_fields, pre-populate a new instance of the form
                     if hasattr(obj, "clone_fields"):
                         url = f"{request.path}?{prepare_cloned_fields(obj)}"
@@ -650,14 +649,14 @@ class DynamicGroupEditView(generic.ObjectEditView):
                         raise RuntimeError(children.errors)
                 verb = "Created" if object_created else "Modified"
                 msg = f"{verb} {self.queryset.model._meta.verbose_name}"
                 logger.info(f"{msg} {obj} (PK: {obj.pk})")
                 if hasattr(obj, "get_absolute_url"):
-                    msg = f'{msg} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>'
+                    msg = format_html('{} <a href="{}">{}</a>', msg, obj.get_absolute_url(), obj)
                 else:
-                    msg = f"{msg} {escape(obj)}"
-                messages.success(request, mark_safe(msg))
+                    msg = format_html("{} {}", msg, obj)
+                messages.success(request, msg)
 
                 if "_addanother" in request.POST:
                     # If the object has clone_fields, pre-populate a new instance of the form
                     if hasattr(obj, "clone_fields"):
                         url = f"{request.path}?{prepare_cloned_fields(obj)}"
@@ -1560,12 +1559,12 @@ class JobButtonRunView(ObjectPermissionRequiredMixin, View):
             job_model=job_model,
             user=request.user,
             object_pk=post_data["object_pk"],
             object_model_name=post_data["object_model_name"],
         )
-        msg = f'Job enqueued. <a href="{result.get_absolute_url()}">Click here for the results.</a>'
-        messages.info(request=request, message=mark_safe(msg))
+        msg = format_html('Job enqueued. <a href="{}">Click here for the results.</a>', result.get_absolute_url())
+        messages.info(request=request, message=msg)
         return redirect(post_data["redirect_path"])
 
 
 #
 # Change logging
@@ -2035,14 +2034,14 @@ class SecretsGroupEditView(generic.ObjectEditView):
                         raise RuntimeError(secrets.errors)
                 verb = "Created" if object_created else "Modified"
                 msg = f"{verb} {self.queryset.model._meta.verbose_name}"
                 logger.info(f"{msg} {obj} (PK: {obj.pk})")
                 if hasattr(obj, "get_absolute_url"):
-                    msg = f'{msg} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>'
+                    msg = format_html('{} <a href="{}">{}</a>', msg, obj.get_absolute_url(), obj)
                 else:
-                    msg = f"{msg} {escape(obj)}"
-                messages.success(request, mark_safe(msg))
+                    msg = format_html("{} {}", msg, obj)
+                messages.success(request, msg)
 
                 if "_addanother" in request.POST:
                     # If the object has clone_fields, pre-populate a new instance of the form
                     if hasattr(obj, "clone_fields"):
                         url = f"{request.path}?{prepare_cloned_fields(obj)}"
diff --git a/nautobot/ipam/tables.py b/nautobot/ipam/tables.py
index 3f238de39a..5a02311235 100644
--- a/nautobot/ipam/tables.py
+++ b/nautobot/ipam/tables.py
@@ -31,11 +31,11 @@ from .models import (
     VRF,
     VRFDeviceAssignment,
     VRFPrefixAssignment,
 )
 
-AVAILABLE_LABEL = mark_safe('<span class="label label-success">Available</span>')
+AVAILABLE_LABEL = mark_safe('<span class="label label-success">Available</span>')  # noqa: S308
 
 UTILIZATION_GRAPH = """
 {% load helpers %}
 {% if record.present_in_database %}{% utilization_graph record.get_utilization %}{% else %}&mdash;{% endif %}
 """
diff --git a/nautobot/ipam/views.py b/nautobot/ipam/views.py
index b2862721c7..3ae2bee2bc 100644
--- a/nautobot/ipam/views.py
+++ b/nautobot/ipam/views.py
@@ -8,13 +8,12 @@ from django.db import models, transaction
 from django.db.models import Count, Prefetch, ProtectedError, Q
 from django.forms.models import model_to_dict
 from django.templatetags.static import static
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
-from django.utils.html import escape
+from django.utils.html import format_html
 from django.utils.http import urlencode
-from django.utils.safestring import mark_safe
 from django.views.generic import View
 from django_tables2 import RequestConfig
 
 from nautobot.core.utils.permissions import get_permission_for_model
 from nautobot.core.models.querysets import count_related
@@ -573,94 +572,124 @@ class PrefixEditView(generic.ObjectEditView):
 
     def successful_post(self, request, obj, created, _logger):
         """Check for data that will be invalid in a future Nautobot release and warn the user if found."""
         # 3.0 TODO: remove these checks after enabling strict enforcement of the equivalent logic in Prefix.save()
         edit_url = reverse("ipam:prefix_edit", kwargs={"pk": obj.pk})
-        warning_msg = (
-            '<p>This <a href="'
-            + static("docs/models/ipam/prefix.html")
-            + '#prefix-hierarchy">will be considered invalid data</a> in a future release.</p>'
+        warning_msg = format_html(
+            '<p>This <a href="{}#prefix-hierarchy">will be considered invalid data</a> in a future release.</p>',
+            static("docs/models/ipam/prefix.html"),
         )
         if obj.parent and obj.parent.type != constants.PREFIX_ALLOWED_PARENT_TYPES[obj.type]:
             parent_edit_url = reverse("ipam:prefix_edit", kwargs={"pk": obj.parent.pk})
             messages.warning(
                 request,
-                mark_safe(
-                    f'{obj} is a {obj.type.title()} prefix but its parent <a href="{obj.parent.get_absolute_url()}">'
-                    f"{obj.parent}</a> is a {obj.parent.type.title()}. {warning_msg} "
-                    f'Consider <a href="{edit_url}">changing the type of {obj}</a> and/or '
-                    f'<a href="{parent_edit_url}">{obj.parent}</a> to resolve this issue.'
+                format_html(
+                    '{} is a {} prefix but its parent <a href="{}">{}</a> is a {}. {} Consider '
+                    '<a href="{}">changing the type of {}</a> and/or <a href="{}">{}</a> to resolve this issue.',
+                    obj,
+                    obj.type.title(),
+                    obj.parent.get_absolute_url(),
+                    obj.parent,
+                    obj.parent.type.title(),
+                    warning_msg,
+                    edit_url,
+                    obj,
+                    parent_edit_url,
+                    obj.parent,
                 ),
             )
 
         invalid_children = obj.children.filter(
             ~Q(type__in=constants.PREFIX_ALLOWED_CHILD_TYPES[obj.type]),  # exclude valid children
         )
 
         if invalid_children.exists():
-            children_link = '<a href="' + reverse("ipam:prefix_list") + f'?parent={obj.pk}">its children</a>'
+            children_link = format_html('<a href="{}?parent={}">its children</a>', reverse("ipam:prefix_list"), obj.pk)
             if obj.type == choices.PrefixTypeChoices.TYPE_CONTAINER:
                 messages.warning(
                     request,
-                    mark_safe(
-                        f"{obj} is a Container prefix and should not contain child prefixes of type Pool. "
-                        f"{warning_msg} Consider creating an intermediary Network prefix, or changing "
-                        f"the type of {children_link} to Network, to resolve this issue."
+                    format_html(
+                        "{} is a Container prefix and should not contain child prefixes of type Pool. {} "
+                        "Consider creating an intermediary Network prefix, or changing the type of {} to Network, "
+                        "to resolve this issue.",
+                        obj,
+                        warning_msg,
+                        children_link,
                     ),
                 )
             elif obj.type == choices.PrefixTypeChoices.TYPE_NETWORK:
                 messages.warning(
                     request,
-                    mark_safe(
-                        f"{obj} is a Network prefix and should not contain child prefixes of types Container or "
-                        f'Network. {warning_msg} Consider <a href="{edit_url}">changing the type of {obj}</a> '
-                        f"to Container, or changing the type of {children_link} to Pool, to resolve this issue."
+                    format_html(
+                        "{} is a Network prefix and should not contain child prefixes of types Container or Network. "
+                        '{} Consider <a href="{}">changing the type of {}</a> to Container, '
+                        "or changing the type of {} to Pool, to resolve this issue.",
+                        obj,
+                        warning_msg,
+                        edit_url,
+                        obj,
+                        children_link,
                     ),
                 )
             else:  # TYPE_POOL
                 messages.warning(
                     request,
-                    mark_safe(
-                        f"{obj} is a Pool prefix and should not contain other prefixes. {warning_msg} "
-                        f'Consider either <a href="{edit_url}">changing the type of {obj}</a> '
-                        f"to Container or Network, or deleting {children_link}, to resolve this issue."
+                    format_html(
+                        "{} is a Pool prefix and should not contain other prefixes. {} "
+                        'Consider either <a href="{}">changing the type of {}</a> to Container or Network, '
+                        "or deleting {}, to resolve this issue.",
+                        obj,
+                        warning_msg,
+                        edit_url,
+                        obj,
+                        children_link,
                     ),
                 )
 
         if obj.ip_addresses.exists() and obj.type == choices.PrefixTypeChoices.TYPE_CONTAINER:
-            ip_warning_msg = (
-                '<p>This <a href="'
-                + static("docs/models/ipam/ipaddress.html")
-                + '#ipaddress-parenting-concrete-relationship">will be considered invalid data</a> '
-                "in a future release.</p>"
+            ip_warning_msg = format_html(
+                '<p>This <a href="{}#ipaddress-parenting-concrete-relationship">will be considered invalid data</a> '
+                "in a future release.</p>",
+                static("docs/models/ipam/ipaddress.html"),
             )
             shortest_child_mask_length = min([ip.mask_length for ip in obj.ip_addresses.all()])
             if shortest_child_mask_length > obj.prefix_length:
-                ip_link = '<a href="' + reverse("ipam:ipaddress_list") + f'?parent={obj.pk}">these IP addresses</a>'
+                ip_link = format_html(
+                    '<a href="{}?parent={}">these IP addresses</a>', reverse("ipam:ipaddress_list"), obj.pk
+                )
                 create_url = reverse("ipam:prefix_add") + urlencode(
                     {
                         "namespace": obj.namespace.pk,
                         "type": choices.PrefixTypeChoices.TYPE_NETWORK,
                         "prefix": obj.prefix,
                     }
                 )
                 messages.warning(
                     request,
-                    mark_safe(
-                        f"{obj} is a Container prefix and should not directly contain IP addresses. {ip_warning_msg} "
-                        f'Consider either <a href="{edit_url}">changing the type of {obj}</a> to Network, or '
-                        f'<a href="{create_url}">creating one or more child prefix(es) of type Network</a> to contain '
-                        f"{ip_link}, to resolve this issue."
+                    format_html(
+                        "{} is a Container prefix and should not directly contain IP addresses. {} "
+                        'Consider either <a href="{}">changing the type of {}</a> to Network, '
+                        'or <a href="{}">creating one or more child prefix(es) of type Network</a> to contain {}, '
+                        "to resolve this issue.",
+                        obj,
+                        ip_warning_msg,
+                        edit_url,
+                        obj,
+                        create_url,
+                        ip_link,
                     ),
                 )
             else:
                 messages.warning(
                     request,
-                    mark_safe(
-                        f"{obj} is a Container prefix and should not directly contain IP addresses. {ip_warning_msg} "
-                        f'Consider <a href="{edit_url}">changing the type of {obj}</a> to Network '
-                        "to resolve this issue."
+                    format_html(
+                        "{} is a Container prefix and should not directly contain IP addresses. {} "
+                        'Consider <a href="{}">changing the type of {}</a> to Network to resolve this issue.',
+                        obj,
+                        ip_warning_msg,
+                        edit_url,
+                        obj,
                     ),
                 )
 
         super().successful_post(request, obj, created, _logger)
 
@@ -761,17 +790,16 @@ class IPAddressEditView(generic.ObjectEditView):
 
     def successful_post(self, request, obj, created, _logger):
         """Check for data that will be invalid in a future Nautobot release and warn the user if found."""
         # 3.0 TODO: remove this check after enabling strict enforcement of the equivalent logic in IPAddress.save()
         if obj.parent.type == choices.PrefixTypeChoices.TYPE_CONTAINER:
-            warning_msg = (
-                '<p>This <a href="'
-                + static("docs/models/ipam/ipaddress.html")
-                + '#ipaddress-parenting-concrete-relationship">will be considered invalid data</a> '
-                "in a future release.</p>"
+            warning_msg = format_html(
+                '<p>This <a href="{}#ipaddress-parenting-concrete-relationship">will be considered invalid data</a> '
+                "in a future release.</p>",
+                static("docs/models/ipam/ipaddress.html"),
             )
-            parent_link = f'<a href="{obj.parent.get_absolute_url()}">{obj.parent}</a>'
+            parent_link = format_html('<a href="{}">{}</a>', obj.parent.get_absolute_url(), obj.parent)
             if obj.parent.prefix_length < obj.mask_length:
                 create_url = (
                     reverse("ipam:prefix_add")
                     + "?"
                     + urlencode(
@@ -782,24 +810,31 @@ class IPAddressEditView(generic.ObjectEditView):
                         }
                     )
                 )
                 messages.warning(
                     request,
-                    mark_safe(
-                        f"IP address {obj} currently has prefix {parent_link} as its parent, which is a Container. "
-                        f'{warning_msg} Consider <a href="{create_url}">creating an intermediate /{obj.mask_length} '
-                        "prefix of type Network</a> to resolve this issue."
+                    format_html(
+                        "IP address {} currently has prefix {} as its parent, which is a Container. {} "
+                        'Consider <a href="{}">creating an intermediate /{} prefix of type Network</a> '
+                        "to resolve this issue.",
+                        obj,
+                        parent_link,
+                        warning_msg,
+                        create_url,
+                        obj.mask_length,
                     ),
                 )
             else:
                 messages.warning(
                     request,
-                    mark_safe(
-                        f"IP address {obj} currently has prefix {parent_link} as its parent, which is a Container. "
-                        f'{warning_msg} Consider <a href="'
-                        + reverse("ipam:prefix_edit", kwargs={"pk": obj.parent.pk})
-                        + '">changing the prefix</a> to type Network or Pool to resolve this issue.'
+                    format_html(
+                        "IP address {} currently has prefix {} as its parent, which is a Container. {} "
+                        'Consider <a href="{}">changing the prefix</a> to type Network or Pool to resolve this issue.',
+                        obj,
+                        parent_link,
+                        warning_msg,
+                        reverse("ipam:prefix_edit", kwargs={"pk": obj.parent.pk}),
                     ),
                 )
 
         # Add IpAddress to interface if interface is in query_params
         if "interface" in request.GET or "vminterface" in request.GET:
@@ -1021,13 +1056,16 @@ class IPAddressMergeView(view_mixins.GetReturnURLMixin, view_mixins.ObjectPermis
                         deleted_count = deleted_info[IPAddress._meta.label]
                     except ProtectedError as e:
                         logger.info("Caught ProtectedError while attempting to delete objects")
                         handle_protectederror(collapsed_ips, request, e)
                         return redirect(self.get_return_url(request))
-                    msg = (
-                        f"Merged {deleted_count} {self.queryset.model._meta.verbose_name} "
-                        f'into <a href="{merged_ip.get_absolute_url()}">{escape(merged_ip)}</a>'
+                    msg = format_html(
+                        'Merged {} {} into <a href="{}">{}</a>',
+                        deleted_count,
+                        self.queryset.model._meta.verbose_name,
+                        merged_ip.get_absolute_url(),
+                        merged_ip,
                     )
                     logger_msg = f"Merged {deleted_count} {self.queryset.model._meta.verbose_name} into {merged_ip}"
                     merged_ip.validated_save()
                     # After some testing
                     # We have to update the ForeignKey fields after merged_ip is saved to make the operation valid
@@ -1039,11 +1077,11 @@ class IPAddressMergeView(view_mixins.GetReturnURLMixin, view_mixins.ObjectPermis
                     VirtualMachine.objects.filter(pk__in=vm_ip4).update(primary_ip4=merged_ip)
                     VirtualMachine.objects.filter(pk__in=vm_ip6).update(primary_ip6=merged_ip)
                     for service in services:
                         Service.objects.get(pk=service).ip_addresses.add(merged_ip)
                     logger.info(logger_msg)
-                    messages.success(request, mark_safe(msg))
+                    messages.success(request, msg)
         return self.find_duplicate_ips(request, merged_attributes)
 
 
 class IPAddressDeleteView(generic.ObjectDeleteView):
     queryset = IPAddress.objects.all()
diff --git a/poetry.lock b/poetry.lock
index d6223dfc1b..80ca5263f7 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -3363,10 +3363,36 @@ files = [
 ]
 
 [package.dependencies]
 pyasn1 = ">=0.1.3"
 
+[[package]]
+name = "ruff"
+version = "0.1.6"
+description = "An extremely fast Python linter and code formatter, written in Rust."
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "ruff-0.1.6-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:88b8cdf6abf98130991cbc9f6438f35f6e8d41a02622cc5ee130a02a0ed28703"},
+    {file = "ruff-0.1.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5c549ed437680b6105a1299d2cd30e4964211606eeb48a0ff7a93ef70b902248"},
+    {file = "ruff-0.1.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cf5f701062e294f2167e66d11b092bba7af6a057668ed618a9253e1e90cfd76"},
+    {file = "ruff-0.1.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:05991ee20d4ac4bb78385360c684e4b417edd971030ab12a4fbd075ff535050e"},
+    {file = "ruff-0.1.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87455a0c1f739b3c069e2f4c43b66479a54dea0276dd5d4d67b091265f6fd1dc"},
+    {file = "ruff-0.1.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:683aa5bdda5a48cb8266fcde8eea2a6af4e5700a392c56ea5fb5f0d4bfdc0240"},
+    {file = "ruff-0.1.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:137852105586dcbf80c1717facb6781555c4e99f520c9c827bd414fac67ddfb6"},
+    {file = "ruff-0.1.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd98138a98d48a1c36c394fd6b84cd943ac92a08278aa8ac8c0fdefcf7138f35"},
+    {file = "ruff-0.1.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a0cd909d25f227ac5c36d4e7e681577275fb74ba3b11d288aff7ec47e3ae745"},
+    {file = "ruff-0.1.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8fd1c62a47aa88a02707b5dd20c5ff20d035d634aa74826b42a1da77861b5ff"},
+    {file = "ruff-0.1.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fd89b45d374935829134a082617954120d7a1470a9f0ec0e7f3ead983edc48cc"},
+    {file = "ruff-0.1.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:491262006e92f825b145cd1e52948073c56560243b55fb3b4ecb142f6f0e9543"},
+    {file = "ruff-0.1.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ea284789861b8b5ca9d5443591a92a397ac183d4351882ab52f6296b4fdd5462"},
+    {file = "ruff-0.1.6-py3-none-win32.whl", hash = "sha256:1610e14750826dfc207ccbcdd7331b6bd285607d4181df9c1c6ae26646d6848a"},
+    {file = "ruff-0.1.6-py3-none-win_amd64.whl", hash = "sha256:4558b3e178145491e9bc3b2ee3c4b42f19d19384eaa5c59d10acf6e8f8b57e33"},
+    {file = "ruff-0.1.6-py3-none-win_arm64.whl", hash = "sha256:03910e81df0d8db0e30050725a5802441c2022ea3ae4fe0609b76081731accbc"},
+    {file = "ruff-0.1.6.tar.gz", hash = "sha256:1b09f29b16c6ead5ea6b097ef2764b42372aebe363722f1605ecbcd2b9207184"},
+]
+
 [[package]]
 name = "rx"
 version = "1.6.3"
 description = "Reactive Extensions (Rx) for Python"
 optional = false
@@ -4031,6 +4057,6 @@ remote-storage = ["django-storages"]
 sso = ["social-auth-core"]
 
 [metadata]
 lock-version = "2.0"
 python-versions = ">=3.8,<3.12"
-content-hash = "5f9bca6f1bca37e007a5611ec280e9842d28d77bf72c68d4f90ff627fd569d00"
+content-hash = "4709a608499593b8cdb5a9b38b34544891123dc1d86885de7a751c901ab07c4b"
diff --git a/pyproject.toml b/pyproject.toml
index b85db5b08c..6813211150 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -197,10 +197,12 @@ flake8 = {version = "~6.0.0", python = ">=3.8.1"}
 isort = "~5.12.0"
 # Code static analysis
 pylint = "~2.17.7"
 # Pylint extensions for Django
 pylint-django = "~2.5.3"
+# Combination linter and code formatter
+ruff = "~0.1.6"
 # YAML linting
 yamllint = "~1.30.0"
 
 [tool.poetry.group.testing.dependencies]
 # Test code coverage measurement
@@ -339,10 +341,30 @@ notes = """,
 
 [tool.pylint.typecheck]
 # @patch changes the signature of a function it's applied to; don't raise "no-value-for-parameter" here
 signature-mutators=["unittest.mock.patch"]
 
+[tool.ruff]
+line-length = 120
+target-version = "py38"
+
+[tool.ruff.lint]
+select = [
+    "E",  # pycodestyle
+    "F",  # pyflakes
+    # "I",  # isort
+    "S308",  # flake8-bandit: suspicious-mark-safe-usage
+    "W",  # pycodestyle
+]
+ignore = [
+    "E501",  # pycodestyle: line-too-long
+]
+
+[tool.ruff.lint.isort]
+lines-after-imports = 2
+force-sort-within-sections = true
+
 [tool.towncrier]
 package = "nautobot"
 directory = "changes"
 filename = "nautobot/docs/release-notes/version-2.0.md"
 template = "development/towncrier_template.j2"
diff --git a/scripts/git-hooks/pre-commit b/scripts/git-hooks/pre-commit
index 7b69cf9730..70d2487446 100755
--- a/scripts/git-hooks/pre-commit
+++ b/scripts/git-hooks/pre-commit
@@ -43,10 +43,13 @@ if [ "$INVOKE_NAUTOBOT_LOCAL" = "True" ] && [ ! -x "$(command -v markdownlint)"
 else
     echo "Checking Markdown files..."
     invoke markdownlint || EXIT=1
 fi
 
+echo "Check static analysis via ruff..."
+invoke ruff || EXIT=1
+
 echo "Check static analysis via pylint..."
 invoke pylint || EXIT=1
 
 if [ $EXIT != 0 ]; then
 	printf "${RED}COMMIT FAILED${NOCOLOR}\n"
diff --git a/tasks.py b/tasks.py
index 873c06e8c0..29815a692b 100644
--- a/tasks.py
+++ b/tasks.py
@@ -609,10 +609,17 @@ def pylint(context, target=None, recursive=False):
             command += "--recursive "
         command += " ".join(target)
         run_command(context, command)
 
 
+@task
+def ruff(context, output_format="text"):
+    """Run ruff to perform static analysis and linting."""
+    command = f"ruff --output-format {output_format} development/ examples/ nautobot/ tasks.py"
+    run_command(context, command)
+
+
 @task
 def yamllint(context):
     """Run yamllint to validate formatting applies to YAML standards."""
     # TODO: enable for directories other than nautobot/docs and fix all warnings
     command = "yamllint nautobot/docs --format standard"
@@ -996,10 +1003,11 @@ def tests(context, lint_only=False, keepdb=False):
     prettier(context)
     eslint(context)
     hadolint(context)
     markdownlint(context)
     yamllint(context)
+    ruff(context)
     pylint(context)
     check_migrations(context)
     check_schema(context)
     build_and_check_docs(context)
     if not lint_only:

Vulnerable code:         help_text=mark_safe(
from django.utils.html import escape, format_html
        return mark_safe('<input type="checkbox" class="toggle" title="Toggle all" />')
            return mark_safe(f'<span class="label label-{css_class}">{label}</span>')
        return mark_safe(f'<span class="label color-block" style="background-color: #{value}">&nbsp;</span>')
            return mark_safe(f'<a href="{url}">{value}</a>')
        template = ""
            for v in value:
                template += format_html('<span class="label label-default">{}</span> ', v)
        return mark_safe(template)
        template = ""
        else:
            # Handle Relationships on the many side.
            if self.relationship.has_many(self.peer_side):
                v = value[0]
                meta = type(v.get_peer(record))._meta
                name = meta.verbose_name_plural if len(value) > 1 else meta.verbose_name
                template += format_html(
                    '<a href="{}?relationship={}&{}_id={}">{} {}</a>',
                    reverse("extras:relationshipassociation_list"),
                    self.relationship.key,
                    self.side,
                    record.id,
                    len(value),
                    name,
                )
            # Handle Relationships on the one side.
            else:
                v = value[0]
                peer = v.get_peer(record)
                template += format_html('<a href="{}">{}</a>', peer.get_absolute_url(), peer)
        return mark_safe(template)
HTML_TRUE = '<span class="text-success"><i class="mdi mdi-check-bold" title="Yes"></i></span>'
HTML_FALSE = '<span class="text-danger"><i class="mdi mdi-close-thick" title="No"></i></span>'
HTML_NONE = '<span class="text-muted">&mdash;</span>'
    return mark_safe(HTML_NONE)
    return mark_safe(match.group(1) + format_html(' id="{}" ', id_str) + match.group(2))
        return mark_safe(HTML_NONE)
        return mark_safe(HTML_TRUE)
    return mark_safe(HTML_FALSE)
    return mark_safe(html)
    return template.render(context=context)
from django.utils.html import escape
from django.utils.safestring import mark_safe
                    mark_safe(f"Invalid filters were specified: {filterset.errors}"),
            msg = f'{msg} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>'
            msg = f"{msg} {escape(obj)}"
        messages.success(request, mark_safe(msg))
                    mark_safe(f'Imported object: <a href="{obj.get_absolute_url()}">{obj}</a>'),
from django.utils.html import escape
from django.utils.safestring import mark_safe
                    mark_safe(f"Invalid filters were specified: {self.filterset.errors}"),
                msg = f'{msg} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>'
                msg = f"{msg} { escape(obj)}"
            messages.success(request, mark_safe(msg))
from django.utils.html import escape
                    field_info["format"] = mark_safe("<code>true</code> or <code>false</code>")
                    field_info["format"] = mark_safe("<code>YYYY-MM-DD</code>")
                    field_info["format"] = mark_safe('<code>"value,value"</code>')
            field_info["format"] = mark_safe("<code>true</code> or <code>false</code>")
            field_info["format"] = mark_safe("<code>YYYY-MM-DD</code>")
            field_info["format"] = mark_safe(
                field_info["format"] = mark_safe('<code>"name,name"</code> or <code>"UUID,UUID"</code>')
                field_info["format"] = mark_safe('<code>"app_label.model,app_label.model"</code>')
                field_info["format"] = mark_safe('<code>"UUID,UUID"</code>')
                field_info["format"] = mark_safe("<code>app_label.model</code>")
                field_info["format"] = mark_safe("<code>UUID</code>")
            field_info["format"] = mark_safe('<code>"value,value"</code>')
    err_message = (
        f"Unable to delete <strong>{', '.join(str(obj) for obj in obj_list)}</strong>. "
        f"{protected_count} dependent objects were found: "
    dependent_objects = []
    for dependent in protected_objects[:50]:
        if hasattr(dependent, "get_absolute_url"):
            dependent_objects.append(f'<a href="{dependent.get_absolute_url()}">{escape(dependent)}</a>')
        else:
            dependent_objects.append(str(dependent))
    err_message += ", ".join(dependent_objects)
    messages.error(request, mark_safe(err_message))
from django.utils.functional import cached_property
from django.utils.functional import classproperty
from django.utils.safestring import mark_safe
                        "u_height": mark_safe(
                            f'Unable to set 0U height: Found <a href="{url}">{racked_instance_count} instances</a> already '
                            f"mounted within racks."
from django.utils.html import escape
from django.utils.safestring import mark_safe
                msg = f'Added member <a href="{device.get_absolute_url()}">{escape(device)}</a>'
                messages.success(request, mark_safe(msg))
            msg = f"Unable to remove master device {escape(device)} from the virtual chassis."
            messages.error(request, mark_safe(msg))
from django.utils.safestring import mark_safe
                        message=mark_safe(f"Values must match this regex: <code>{self.validation_regex}</code>"),
        help_text="Jinja2 template code for link text. Reference the object as <code>{{ obj }}</code> such as <code>{{ obj.platform.name }}</code>. Links which render as empty text will not be displayed.",
        help_text="Jinja2 template code for link URL. Reference the object as <code>{{ obj }}</code> such as <code>{{ obj.platform.name }}</code>.",
from django.utils.safestring import mark_safe
                        hint = (
                            f"<a target='_blank' href='{add_url}'>Click here</a> to create "
                            f"a {required_model_meta.verbose_name}."
                error_message = mark_safe(
                    f"{name_plural[0].upper()}{name_plural[1:]} require "
                    f"{num_required_verbose} {required_model_meta.verbose_name}, but no "
                    f"{required_model_meta.verbose_name_plural} exist yet. {hint}"
from django.utils.safestring import mark_safe
            return mark_safe(render_markdown(record.description))
from django.utils.html import escape
from django.utils.safestring import mark_safe
    template_code = ""

    for label, value in fields.items():
        escaped_label = escape(label)
        template_code += f"""
            <tr>
                <td><span title="{escaped_label}">{escaped_label}</span></td>
                <td>{escape(value)}</td>
            <tr>
            """
    return mark_safe(template_code)
    template_code = ""
                    template_code += LINK_BUTTON.format(link_rendered, link_target, cl.button_class, text_rendered)
                template_code += (
                    f'<a class="btn btn-sm btn-default" disabled="disabled" title="{e}">'
                    f'<i class="mdi mdi-alert"></i> {cl.name}</a>\n'
        links_rendered = []
                    links_rendered.append(GROUP_LINK.format(link_rendered, link_target, text_rendered))
                links_rendered.append(
                    f'<li><a disabled="disabled" title="{e}"><span class="text-muted">'
                    f'<i class="mdi mdi-alert"></i> {cl.name}</span></a></li>'
            template_code += GROUP_BUTTON.format(links[0].button_class, group, "".join(links_rendered))
    return mark_safe(template_code)
    buttons_html = forms_html = ""
    hidden_inputs = HIDDEN_INPUTS.format(
                        buttons_html += CONFIRM_BUTTON.format(**template_args)
                        forms_html += CONFIRM_MODAL.format(**template_args)
                        buttons_html += NO_CONFIRM_BUTTON.format(**template_args)
                        forms_html += NO_CONFIRM_FORM.format(**template_args)
                buttons_html += (
                    f'<a class="btn btn-sm btn-default" disabled="disabled" title="{e}">'
                    f'<i class="mdi mdi-alert"></i> {jb.name}</a>\n'
        buttons_rendered = ""
                        buttons_rendered += "<li>" + CONFIRM_BUTTON.format(**template_args) + "</li>"
                        forms_html += CONFIRM_MODAL.format(**template_args)
                        buttons_rendered += "<li>" + NO_CONFIRM_BUTTON.format(**template_args) + "</li>"
                        forms_html += NO_CONFIRM_FORM.format(**template_args)
                buttons_rendered += (
                    f'<li><a disabled="disabled" title="{e}"><span class="text-muted">'
                    f'<i class="mdi mdi-alert"></i> {jb.name}</span></a></li>'
            buttons_html += GROUP_DROPDOWN.format(
    return mark_safe(buttons_html + forms_html)
    return mark_safe(html)
                '<span class="label label-default">Bar</span> <span class="label label-default">Baz</span> '
                        "objects_nonexistent": "Circuit types require a platform, but no platforms exist yet. "
                        "objects_nonexistent": "Circuit types require a platform, but no platforms exist yet.",
    ComputedField,
class CustomLinkTest(TestCase):
from django.utils.html import escape
from django.utils.safestring import mark_safe
                    msg = f'{msg} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>'
                    msg = f"{msg} {escape(obj)}"
                messages.success(request, mark_safe(msg))
                    msg = f'{msg} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>'
                    msg = f"{msg} {escape(obj)}"
                messages.success(request, mark_safe(msg))
        msg = f'Job enqueued. <a href="{result.get_absolute_url()}">Click here for the results.</a>'
        messages.info(request=request, message=mark_safe(msg))
                    msg = f'{msg} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>'
                    msg = f"{msg} {escape(obj)}"
                messages.success(request, mark_safe(msg))
AVAILABLE_LABEL = mark_safe('<span class="label label-success">Available</span>')
from django.utils.html import escape
from django.utils.safestring import mark_safe
        warning_msg = (
            '<p>This <a href="'
            + static("docs/models/ipam/prefix.html")
            + '#prefix-hierarchy">will be considered invalid data</a> in a future release.</p>'
                mark_safe(
                    f'{obj} is a {obj.type.title()} prefix but its parent <a href="{obj.parent.get_absolute_url()}">'
                    f"{obj.parent}</a> is a {obj.parent.type.title()}. {warning_msg} "
                    f'Consider <a href="{edit_url}">changing the type of {obj}</a> and/or '
                    f'<a href="{parent_edit_url}">{obj.parent}</a> to resolve this issue.'
            children_link = '<a href="' + reverse("ipam:prefix_list") + f'?parent={obj.pk}">its children</a>'
                    mark_safe(
                        f"{obj} is a Container prefix and should not contain child prefixes of type Pool. "
                        f"{warning_msg} Consider creating an intermediary Network prefix, or changing "
                        f"the type of {children_link} to Network, to resolve this issue."
                    mark_safe(
                        f"{obj} is a Network prefix and should not contain child prefixes of types Container or "
                        f'Network. {warning_msg} Consider <a href="{edit_url}">changing the type of {obj}</a> '
                        f"to Container, or changing the type of {children_link} to Pool, to resolve this issue."
                    mark_safe(
                        f"{obj} is a Pool prefix and should not contain other prefixes. {warning_msg} "
                        f'Consider either <a href="{edit_url}">changing the type of {obj}</a> '
                        f"to Container or Network, or deleting {children_link}, to resolve this issue."
            ip_warning_msg = (
                '<p>This <a href="'
                + static("docs/models/ipam/ipaddress.html")
                + '#ipaddress-parenting-concrete-relationship">will be considered invalid data</a> '
                "in a future release.</p>"
                ip_link = '<a href="' + reverse("ipam:ipaddress_list") + f'?parent={obj.pk}">these IP addresses</a>'
                    mark_safe(
                        f"{obj} is a Container prefix and should not directly contain IP addresses. {ip_warning_msg} "
                        f'Consider either <a href="{edit_url}">changing the type of {obj}</a> to Network, or '
                        f'<a href="{create_url}">creating one or more child prefix(es) of type Network</a> to contain '
                        f"{ip_link}, to resolve this issue."
                    mark_safe(
                        f"{obj} is a Container prefix and should not directly contain IP addresses. {ip_warning_msg} "
                        f'Consider <a href="{edit_url}">changing the type of {obj}</a> to Network '
                        "to resolve this issue."
            warning_msg = (
                '<p>This <a href="'
                + static("docs/models/ipam/ipaddress.html")
                + '#ipaddress-parenting-concrete-relationship">will be considered invalid data</a> '
                "in a future release.</p>"
            parent_link = f'<a href="{obj.parent.get_absolute_url()}">{obj.parent}</a>'
                    mark_safe(
                        f"IP address {obj} currently has prefix {parent_link} as its parent, which is a Container. "
                        f'{warning_msg} Consider <a href="{create_url}">creating an intermediate /{obj.mask_length} '
                        "prefix of type Network</a> to resolve this issue."
                    mark_safe(
                        f"IP address {obj} currently has prefix {parent_link} as its parent, which is a Container. "
                        f'{warning_msg} Consider <a href="'
                        + reverse("ipam:prefix_edit", kwargs={"pk": obj.parent.pk})
                        + '">changing the prefix</a> to type Network or Pool to resolve this issue.'
                    msg = (
                        f"Merged {deleted_count} {self.queryset.model._meta.verbose_name} "
                        f'into <a href="{merged_ip.get_absolute_url()}">{escape(merged_ip)}</a>'
                    messages.success(request, mark_safe(msg))
content-hash = "5f9bca6f1bca37e007a5611ec280e9842d28d77bf72c68d4f90ff627fd569d00"
Fixed code:   ruff:
    runs-on: "ubuntu-20.04"
    env:
      INVOKE_NAUTOBOT_LOCAL: "True"
    steps:
      - name: "Check out repository code"
        uses: "actions/checkout@v2"
      - name: "Setup environment"
        uses: "networktocode/gh-action-setup-poetry-environment@v2"
      - name: "Linting: ruff"
        run: "poetry run invoke ruff --output-format github"
      - "ruff"
      - "ruff"
      - "ruff"
      - "ruff"
      - "ruff"
      - "ruff"
Changed the `render_jinja2()` API to no longer automatically call `mark_safe()` on the output.
Added `ruff` to invoke tasks and CI.
Fixed cross-site-scripting (XSS) potential with maliciously crafted Custom Links, Computed Fields, and Job Buttons (GHSA-cf9f-wmhp-v4pr).
        help_text=mark_safe(  # noqa: S308
from django.utils.html import escape, format_html, format_html_join
        return mark_safe('<input type="checkbox" class="toggle" title="Toggle all" />')  # noqa: S308
            return format_html('<span class="label label-{}">{}</span>', css_class, label)
        return format_html('<span class="label color-block" style="background-color: #{}">&nbsp;</span>', value)
            return format_html('<a href="{}">{}</a>', url, value)
            template = format_html_join(" ", '<span class="label label-default">{}</span>', ((v,) for v in value))
        return template
        # Handle Relationships on the many side.
        if self.relationship.has_many(self.peer_side):
            v = value[0]
            meta = type(v.get_peer(record))._meta
            name = meta.verbose_name_plural if len(value) > 1 else meta.verbose_name
            return format_html(
                '<a href="{}?relationship={}&{}_id={}">{} {}</a>',
                reverse("extras:relationshipassociation_list"),
                self.relationship.key,
                self.side,
                record.id,
                len(value),
                name,
            )
        # Handle Relationships on the one side.
        else:
            v = value[0]
            peer = v.get_peer(record)
            return format_html('<a href="{}">{}</a>', peer.get_absolute_url(), peer)
HTML_TRUE = mark_safe('<span class="text-success"><i class="mdi mdi-check-bold" title="Yes"></i></span>')  # noqa: S308
HTML_FALSE = mark_safe('<span class="text-danger"><i class="mdi mdi-close-thick" title="No"></i></span>')  # noqa: S308
HTML_NONE = mark_safe('<span class="text-muted">&mdash;</span>')  # noqa: S308
    return HTML_NONE
    return mark_safe(match.group(1) + format_html(' id="{}" ', id_str) + match.group(2))  # noqa: S308
        return HTML_NONE
        return HTML_TRUE
    return HTML_FALSE
    return mark_safe(html)  # noqa: S308
    # For reasons unknown to me, django-jinja2 `template.render()` implicitly calls `mark_safe()` on the rendered text.
    # This is a security risk in general, especially so in our case because we're often using this function to render
    # a user-provided template and don't want to open ourselves up to script injection or similar issues.
    # There's no `mark_unsafe()` function, but concatenating a SafeString to an ordinary string (even "") suffices.
    return "" + template.render(context=context)
from django.utils.html import format_html
                    format_html("Invalid filters were specified: {}", filterset.errors),
            msg = format_html('{} <a href="{}">{}</a>', msg, obj.get_absolute_url(), obj)
            msg = format_html("{} {}", msg, obj)
        messages.success(request, msg)
                    format_html('Imported object: <a href="{}">{}</a>', obj.get_absolute_url(), obj),
from django.utils.html import format_html
                    format_html("Invalid filters were specified: {}", self.filterset.errors),
                msg = format_html('{} <a href="{}">{}</a>', msg, obj.get_absolute_url(), obj)
                msg = format_html("{} {}", msg, obj)
            messages.success(request, msg)
from django.utils.html import format_html, format_html_join
                    field_info["format"] = mark_safe("<code>true</code> or <code>false</code>")  # noqa: S308
                    field_info["format"] = mark_safe("<code>YYYY-MM-DD</code>")  # noqa: S308
                    field_info["format"] = mark_safe('<code>"value,value"</code>')  # noqa: S308
            field_info["format"] = mark_safe("<code>true</code> or <code>false</code>")  # noqa: S308
            field_info["format"] = mark_safe("<code>YYYY-MM-DD</code>")  # noqa: S308
            field_info["format"] = mark_safe(  # noqa: S308
                field_info["format"] = mark_safe('<code>"name,name"</code> or <code>"UUID,UUID"</code>')  # noqa: S308
                field_info["format"] = mark_safe('<code>"app_label.model,app_label.model"</code>')  # noqa: S308
                field_info["format"] = mark_safe('<code>"UUID,UUID"</code>')  # noqa: S308
                field_info["format"] = mark_safe("<code>app_label.model</code>")  # noqa: S308
                field_info["format"] = mark_safe("<code>UUID</code>")  # noqa: S308
            field_info["format"] = mark_safe('<code>"value,value"</code>')  # noqa: S308
    err_message = format_html(
        "Unable to delete <strong>{}</strong>. {} dependent objects were found: ",
        ", ".join(str(obj) for obj in obj_list),
        protected_count,
    err_message += format_html_join(
        ", ",
        '<a href="{}">{}</a>',
        ((dependent.get_absolute_url(), dependent) for dependent in protected_objects[:50]),
    )
    messages.error(request, err_message)
from django.utils.functional import cached_property, classproperty
from django.utils.html import format_html
                        "u_height": format_html(
                            "Unable to set 0U height: "
                            'Found <a href="{}">{} instances</a> already mounted within racks.',
                            url,
                            racked_instance_count,
from django.utils.html import format_html
                msg = format_html('Added member <a href="{}">{}</a>', device.get_absolute_url(), device)
                messages.success(request, msg)
            msg = format_html("Unable to remove master device {} from the virtual chassis.", device)
            messages.error(request, msg)
from django.utils.html import format_html
                        message=format_html("Values must match this regex: <code>{}</code>", self.validation_regex),
        help_text="Jinja2 template code for link text. "
        "Reference the object as <code>{{ obj }}</code> such as <code>{{ obj.platform.name }}</code>. "
        "Links which render as empty text will not be displayed.",
        help_text="Jinja2 template code for link URL. "
        "Reference the object as <code>{{ obj }}</code> such as <code>{{ obj.platform.name }}</code>.",
from django.utils.html import format_html
from nautobot.core.templatetags.helpers import bettertitle
                        hint = format_html(
                            '<a target="_blank" href="{}">Click here</a> to create a {}.',
                            add_url,
                            required_model_meta.verbose_name,
                error_message = format_html(
                    "{} require {} {}, but no {} exist yet. ",
                    bettertitle(name_plural),
                    num_required_verbose,
                    required_model_meta.verbose_name,
                    required_model_meta.verbose_name_plural,
                error_message += hint
            return render_markdown(record.description)
from django.utils.html import format_html_join
    return format_html_join(
        "\n",
        '<tr><td><span title="{}">{}</span></td><td>{}</td></tr>',
        ((label, label, value) for label, value in fields.items()),
    )
from django.utils.html import format_html
    template_code = mark_safe("")  # noqa: S308
                    template_code += format_html(
                        LINK_BUTTON, link_rendered, link_target, cl.button_class, text_rendered
                    )
                template_code += format_html(
                    '<a class="btn btn-sm btn-default" disabled="disabled" title="{}">'
                    '<i class="mdi mdi-alert"></i> {}</a>\n',
                    e,
                    cl.name,
        links_rendered = mark_safe("")  # noqa: S308
                    links_rendered += format_html(GROUP_LINK, link_rendered, link_target, text_rendered)
                links_rendered += format_html(
                    '<li><a disabled="disabled" title="{}"><span class="text-muted">'
                    '<i class="mdi mdi-alert"></i> {}</span></a></li>',
                    e,
                    cl.name,
            template_code += format_html(GROUP_BUTTON, links[0].button_class, group, links_rendered)
    return template_code
from django.utils.html import format_html
    buttons_html = forms_html = mark_safe("")  # noqa: S308
    hidden_inputs = format_html(
        HIDDEN_INPUTS,
                        buttons_html += format_html(CONFIRM_BUTTON, **template_args)
                        forms_html += format_html(CONFIRM_MODAL, **template_args)
                        buttons_html += format_html(NO_CONFIRM_BUTTON, **template_args)
                        forms_html += format_html(NO_CONFIRM_FORM, **template_args)
                buttons_html += format_html(
                    '<a class="btn btn-sm btn-default" disabled="disabled" title="{}">'
                    '<i class="mdi mdi-alert"></i> {}</a>\n',
                    e,
                    jb.name,
        buttons_rendered = mark_safe("")  # noqa: S308
                        buttons_rendered += (
                            mark_safe("<li>")  # noqa: S308
                            + format_html(CONFIRM_BUTTON, **template_args)
                            + mark_safe("</li>")  # noqa: S308
                        )
                        forms_html += format_html(CONFIRM_MODAL, **template_args)
                        buttons_rendered += (
                            mark_safe("<li>")  # noqa: S308
                            + format_html(NO_CONFIRM_BUTTON, **template_args)
                            + mark_safe("</li>")  # noqa: S308
                        )
                        forms_html += format_html(NO_CONFIRM_FORM, **template_args)
                buttons_rendered += format_html(
                    '<li><a disabled="disabled" title="{}"><span class="text-muted">'
                    '<i class="mdi mdi-alert"></i> {}</span></a></li>',
                    e,
                    jb.name,
            buttons_html += format_html(
                GROUP_DROPDOWN,
    return buttons_html + forms_html
    return mark_safe(html)  # noqa: S308
                '<span class="label label-default">Bar</span> <span class="label label-default">Baz</span>'
                        "objects_nonexistent": "Circuit Types require a platform, but no platforms exist yet. "
                        "objects_nonexistent": "Circuit Types require a platform, but no platforms exist yet.",
from django.utils.html import format_html
    ComputedField,
class ComputedFieldRenderingTestCase(TestCase):
    """Tests for the inclusion of ComputedFields, distinct from tests of the ComputedField views themselves."""

    user_permissions = ["dcim.view_locationtype"]

    def setUp(self):
        super().setUp()
        self.computedfield = ComputedField(
            content_type=ContentType.objects.get_for_model(LocationType),
            key="test",
            label="Computed Field",
            template="FOO {{ obj.name }} BAR",
            fallback_value="Fallback Value",
            weight=100,
        )
        self.computedfield.validated_save()
        self.location_type = LocationType.objects.get(name="Campus")

    def test_view_object_with_computed_field(self):
        """Ensure that the computed field template is rendered."""
        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
        self.assertEqual(response.status_code, 200)
        content = extract_page_body(response.content.decode(response.charset))
        self.assertIn(f"FOO {self.location_type.name} BAR", content, content)

    def test_view_object_with_computed_field_fallback_value(self):
        """Ensure that the fallback_value is rendered if the template fails to render."""
        # Make the template invalid to demonstrate the fallback value
        self.computedfield.template = "FOO {{ obj."
        self.computedfield.validated_save()
        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
        self.assertEqual(response.status_code, 200)
        content = extract_page_body(response.content.decode(response.charset))
        self.assertIn("Fallback Value", content, content)

    def test_view_object_with_computed_field_unsafe_template(self):
        """Ensure that computed field templates can't be used as an XSS vector."""
        self.computedfield.template = '<script>alert("Hello world!"</script>'
        self.computedfield.validated_save()
        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
        self.assertEqual(response.status_code, 200)
        content = extract_page_body(response.content.decode(response.charset))
        self.assertNotIn("<script>alert", content, content)
        self.assertIn("&lt;script&gt;alert", content, content)

    def test_view_object_with_computed_field_unsafe_fallback_value(self):
        """Ensure that computed field fallback values can't be used as an XSS vector."""
        self.computedfield.template = "FOO {{ obj."
        self.computedfield.fallback_value = '<script>alert("Hello world!"</script>'
        self.computedfield.validated_save()
        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
        self.assertEqual(response.status_code, 200)
        content = extract_page_body(response.content.decode(response.charset))
        self.assertNotIn("<script>alert", content, content)
        self.assertIn("&lt;script&gt;alert", content, content)


class CustomLinkRenderingTestCase(TestCase):
    """Tests for the inclusion of CustomLinks, distinct from tests of the CustomLink views themselves."""

    def test_view_object_with_unsafe_custom_link_text(self):
        """Ensure that custom links can't be used as a vector for injecting scripts or breaking HTML."""
        customlink = CustomLink(
            content_type=ContentType.objects.get_for_model(Location),
            name="Test",
            text='<script>alert("Hello world!")</script>',
            target_url="http://example.com/?location=None",
            new_window=False,
        )
        customlink.validated_save()
        location_type = LocationType.objects.get(name="Campus")
        status = Status.objects.get_for_model(Location).first()
        location = Location(name="Test Location", location_type=location_type, status=status)
        location.save()

        response = self.client.get(location.get_absolute_url(), follow=True)
        self.assertEqual(response.status_code, 200)
        content = extract_page_body(response.content.decode(response.charset))
        self.assertNotIn("<script>alert", content, content)
        self.assertIn("&lt;script&gt;alert", content, content)
        self.assertIn(format_html('<a href="{}"', customlink.target_url), content, content)

    def test_view_object_with_unsafe_custom_link_url(self):
        """Ensure that custom links can't be used as a vector for injecting scripts or breaking HTML."""
        customlink = CustomLink(
            content_type=ContentType.objects.get_for_model(Location),
            name="Test",
            text="Hello",
            target_url='"><script>alert("Hello world!")</script><a href="',
            new_window=False,
        )
        customlink.validated_save()
        location_type = LocationType.objects.get(name="Campus")
        status = Status.objects.get_for_model(Location).first()
        location = Location(name="Test Location", location_type=location_type, status=status)
        location.save()

        response = self.client.get(location.get_absolute_url(), follow=True)
        self.assertEqual(response.status_code, 200)
        content = extract_page_body(response.content.decode(response.charset))
        self.assertNotIn("<script>alert", content, content)
        self.assertIn("&lt;script&gt;alert", content, content)
        self.assertIn(format_html('<a href="{}"', customlink.target_url), content, content)

    def test_view_object_with_unsafe_custom_link_name(self):
        """Ensure that custom links can't be used as a vector for injecting scripts or breaking HTML."""
        customlink = CustomLink(
            content_type=ContentType.objects.get_for_model(Location),
            name='<script>alert("Hello World")</script>',
            text="Hello",
            target_url="http://example.com/?location={{ obj.name ",  # intentionally bad jinja2 to trigger error case
            new_window=False,
        )
        customlink.validated_save()
        location_type = LocationType.objects.get(name="Campus")
        status = Status.objects.get_for_model(Location).first()
        location = Location(name="Test Location", location_type=location_type, status=status)
        location.save()

        response = self.client.get(location.get_absolute_url(), follow=True)
        self.assertEqual(response.status_code, 200)
        content = extract_page_body(response.content.decode(response.charset))
        self.assertNotIn("<script>alert", content, content)
        self.assertIn("&lt;script&gt;alert", content, content)

class JobButtonRenderingTestCase(TestCase):
    """Tests for the rendering of JobButtons, distinct from tests of the JobButton views themselves."""

    user_permissions = ["dcim.view_locationtype"]

    def setUp(self):
        super().setUp()
        self.job_button = JobButton(
            name="JobButton",
            text="JobButton {{ obj.name }}",
            job=Job.objects.get(job_class_name="TestJobButtonReceiverSimple"),
            confirmation=False,
        )
        self.job_button.validated_save()
        self.job_button.content_types.add(ContentType.objects.get_for_model(LocationType))
        self.location_type = LocationType.objects.get(name="Campus")

    def test_view_object_with_job_button(self):
        """Ensure that the job button is rendered."""
        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
        self.assertEqual(response.status_code, 200)
        content = extract_page_body(response.content.decode(response.charset))
        self.assertIn(f"JobButton {self.location_type.name}", content, content)

    def test_view_object_with_unsafe_text(self):
        """Ensure that JobButton text can't be used as a vector for XSS."""
        self.job_button.text = '<script>alert("Hello world!")</script>'
        self.job_button.validated_save()
        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
        self.assertEqual(response.status_code, 200)
        content = extract_page_body(response.content.decode(response.charset))
        self.assertNotIn("<script>alert", content, content)
        self.assertIn("&lt;script&gt;alert", content, content)

        # Make sure grouped rendering is safe too
        self.job_button.group = '<script>alert("Goodbye")</script>'
        self.job_button.validated_save()
        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
        self.assertEqual(response.status_code, 200)
        content = extract_page_body(response.content.decode(response.charset))
        self.assertNotIn("<script>alert", content, content)
        self.assertIn("&lt;script&gt;alert", content, content)

    def test_view_object_with_unsafe_name(self):
        """Ensure that JobButton names can't be used as a vector for XSS."""
        self.job_button.text = "JobButton {{ obj"
        self.job_button.name = '<script>alert("Yo")</script>'
        self.job_button.validated_save()
        response = self.client.get(self.location_type.get_absolute_url(), follow=True)
        self.assertEqual(response.status_code, 200)
        content = extract_page_body(response.content.decode(response.charset))
        self.assertNotIn("<script>alert", content, content)
        self.assertIn("&lt;script&gt;alert", content, content)


from django.utils.html import format_html
                    msg = format_html('{} <a href="{}">{}</a>', msg, obj.get_absolute_url(), obj)
                    msg = format_html("{} {}", msg, obj)
                messages.success(request, msg)
                    msg = format_html('{} <a href="{}">{}</a>', msg, obj.get_absolute_url(), obj)
                    msg = format_html("{} {}", msg, obj)
                messages.success(request, msg)
        msg = format_html('Job enqueued. <a href="{}">Click here for the results.</a>', result.get_absolute_url())
        messages.info(request=request, message=msg)
                    msg = format_html('{} <a href="{}">{}</a>', msg, obj.get_absolute_url(), obj)
                    msg = format_html("{} {}", msg, obj)
                messages.success(request, msg)
AVAILABLE_LABEL = mark_safe('<span class="label label-success">Available</span>')  # noqa: S308
from django.utils.html import format_html
        warning_msg = format_html(
            '<p>This <a href="{}#prefix-hierarchy">will be considered invalid data</a> in a future release.</p>',
            static("docs/models/ipam/prefix.html"),
                format_html(
                    '{} is a {} prefix but its parent <a href="{}">{}</a> is a {}. {} Consider '
                    '<a href="{}">changing the type of {}</a> and/or <a href="{}">{}</a> to resolve this issue.',
                    obj,
                    obj.type.title(),
                    obj.parent.get_absolute_url(),
                    obj.parent,
                    obj.parent.type.title(),
                    warning_msg,
                    edit_url,
                    obj,
                    parent_edit_url,
                    obj.parent,
            children_link = format_html('<a href="{}?parent={}">its children</a>', reverse("ipam:prefix_list"), obj.pk)
                    format_html(
                        "{} is a Container prefix and should not contain child prefixes of type Pool. {} "
                        "Consider creating an intermediary Network prefix, or changing the type of {} to Network, "
                        "to resolve this issue.",
                        obj,
                        warning_msg,
                        children_link,
                    format_html(
                        "{} is a Network prefix and should not contain child prefixes of types Container or Network. "
                        '{} Consider <a href="{}">changing the type of {}</a> to Container, '
                        "or changing the type of {} to Pool, to resolve this issue.",
                        obj,
                        warning_msg,
                        edit_url,
                        obj,
                        children_link,
                    format_html(
                        "{} is a Pool prefix and should not contain other prefixes. {} "
                        'Consider either <a href="{}">changing the type of {}</a> to Container or Network, '
                        "or deleting {}, to resolve this issue.",
                        obj,
                        warning_msg,
                        edit_url,
                        obj,
                        children_link,
            ip_warning_msg = format_html(
                '<p>This <a href="{}#ipaddress-parenting-concrete-relationship">will be considered invalid data</a> '
                "in a future release.</p>",
                static("docs/models/ipam/ipaddress.html"),
                ip_link = format_html(
                    '<a href="{}?parent={}">these IP addresses</a>', reverse("ipam:ipaddress_list"), obj.pk
                )
                    format_html(
                        "{} is a Container prefix and should not directly contain IP addresses. {} "
                        'Consider either <a href="{}">changing the type of {}</a> to Network, '
                        'or <a href="{}">creating one or more child prefix(es) of type Network</a> to contain {}, '
                        "to resolve this issue.",
                        obj,
                        ip_warning_msg,
                        edit_url,
                        obj,
                        create_url,
                        ip_link,
                    format_html(
                        "{} is a Container prefix and should not directly contain IP addresses. {} "
                        'Consider <a href="{}">changing the type of {}</a> to Network to resolve this issue.',
                        obj,
                        ip_warning_msg,
                        edit_url,
                        obj,
            warning_msg = format_html(
                '<p>This <a href="{}#ipaddress-parenting-concrete-relationship">will be considered invalid data</a> '
                "in a future release.</p>",
                static("docs/models/ipam/ipaddress.html"),
            parent_link = format_html('<a href="{}">{}</a>', obj.parent.get_absolute_url(), obj.parent)
                    format_html(
                        "IP address {} currently has prefix {} as its parent, which is a Container. {} "
                        'Consider <a href="{}">creating an intermediate /{} prefix of type Network</a> '
                        "to resolve this issue.",
                        obj,
                        parent_link,
                        warning_msg,
                        create_url,
                        obj.mask_length,
                    format_html(
                        "IP address {} currently has prefix {} as its parent, which is a Container. {} "
                        'Consider <a href="{}">changing the prefix</a> to type Network or Pool to resolve this issue.',
                        obj,
                        parent_link,
                        warning_msg,
                        reverse("ipam:prefix_edit", kwargs={"pk": obj.parent.pk}),
                    msg = format_html(
                        'Merged {} {} into <a href="{}">{}</a>',
                        deleted_count,
                        self.queryset.model._meta.verbose_name,
                        merged_ip.get_absolute_url(),
                        merged_ip,
                    messages.success(request, msg)
[[package]]
name = "ruff"
version = "0.1.6"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
files = [
    {file = "ruff-0.1.6-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:88b8cdf6abf98130991cbc9f6438f35f6e8d41a02622cc5ee130a02a0ed28703"},
    {file = "ruff-0.1.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5c549ed437680b6105a1299d2cd30e4964211606eeb48a0ff7a93ef70b902248"},
    {file = "ruff-0.1.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cf5f701062e294f2167e66d11b092bba7af6a057668ed618a9253e1e90cfd76"},
    {file = "ruff-0.1.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:05991ee20d4ac4bb78385360c684e4b417edd971030ab12a4fbd075ff535050e"},
    {file = "ruff-0.1.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87455a0c1f739b3c069e2f4c43b66479a54dea0276dd5d4d67b091265f6fd1dc"},
    {file = "ruff-0.1.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:683aa5bdda5a48cb8266fcde8eea2a6af4e5700a392c56ea5fb5f0d4bfdc0240"},
    {file = "ruff-0.1.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:137852105586dcbf80c1717facb6781555c4e99f520c9c827bd414fac67ddfb6"},
    {file = "ruff-0.1.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd98138a98d48a1c36c394fd6b84cd943ac92a08278aa8ac8c0fdefcf7138f35"},
    {file = "ruff-0.1.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a0cd909d25f227ac5c36d4e7e681577275fb74ba3b11d288aff7ec47e3ae745"},
    {file = "ruff-0.1.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8fd1c62a47aa88a02707b5dd20c5ff20d035d634aa74826b42a1da77861b5ff"},
    {file = "ruff-0.1.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fd89b45d374935829134a082617954120d7a1470a9f0ec0e7f3ead983edc48cc"},
    {file = "ruff-0.1.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:491262006e92f825b145cd1e52948073c56560243b55fb3b4ecb142f6f0e9543"},
    {file = "ruff-0.1.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ea284789861b8b5ca9d5443591a92a397ac183d4351882ab52f6296b4fdd5462"},
    {file = "ruff-0.1.6-py3-none-win32.whl", hash = "sha256:1610e14750826dfc207ccbcdd7331b6bd285607d4181df9c1c6ae26646d6848a"},
    {file = "ruff-0.1.6-py3-none-win_amd64.whl", hash = "sha256:4558b3e178145491e9bc3b2ee3c4b42f19d19384eaa5c59d10acf6e8f8b57e33"},
    {file = "ruff-0.1.6-py3-none-win_arm64.whl", hash = "sha256:03910e81df0d8db0e30050725a5802441c2022ea3ae4fe0609b76081731accbc"},
    {file = "ruff-0.1.6.tar.gz", hash = "sha256:1b09f29b16c6ead5ea6b097ef2764b42372aebe363722f1605ecbcd2b9207184"},
]

content-hash = "4709a608499593b8cdb5a9b38b34544891123dc1d86885de7a751c901ab07c4b"
# Combination linter and code formatter
ruff = "~0.1.6"
[tool.ruff]
line-length = 120
target-version = "py38"

[tool.ruff.lint]
select = [
    "E",  # pycodestyle
    "F",  # pyflakes
    # "I",  # isort
    "S308",  # flake8-bandit: suspicious-mark-safe-usage
    "W",  # pycodestyle
]
ignore = [
    "E501",  # pycodestyle: line-too-long
]

[tool.ruff.lint.isort]
lines-after-imports = 2
force-sort-within-sections = true

echo "Check static analysis via ruff..."
invoke ruff || EXIT=1

@task
def ruff(context, output_format="text"):
    """Run ruff to perform static analysis and linting."""
    command = f"ruff --output-format {output_format} development/ examples/ nautobot/ tasks.py"
    run_command(context, command)


    ruff(context)