CVE-2023-48705 (published 2023-11-22T16:15Z)

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.

CVSS v3 base score: 5.4, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N)

Weakness information:
cwe_id: CWE-79
cwe_name: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
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.
extended_description: 
            Cross-site scripting (XSS) vulnerabilities occur when:
            
               Untrusted data enters a web application, typically from a web request.
               The web application dynamically generates a web page that contains this untrusted data.
               During page generation, the application does not prevent the data from containing content that is executable by a web browser, such as JavaScript, HTML tags, HTML attributes, mouse events, Flash, ActiveX, etc.
               A victim visits the generated web page through a web browser, which contains malicious script that was injected using the untrusted data.
               Since the script comes from a web page that was sent by the web server, the victim's web browser executes the malicious script in the context of the web server's domain.
               This effectively violates the intention of the web browser's same-origin policy, which states that scripts in one domain should not be able to access resources or run code in a different domain.
            
            There are three main kinds of XSS:
            
               
                  Type 1: Reflected XSS (or Non-Persistent) - 
         			The server reads data directly from the HTTP request and reflects it back in the HTTP response. Reflected XSS exploits occur when an attacker causes a victim to supply dangerous content to a vulnerable web application, which is then reflected back to the victim and executed by the web browser. The most common mechanism for delivering malicious content is to include it as a parameter in a URL that is posted publicly or e-mailed directly to the victim. URLs constructed in this manner constitute the core of many phishing schemes, whereby an attacker convinces a victim to visit a URL that refers to a vulnerable site. After the site reflects the attacker's content back to the victim, the content is executed by the victim's browser.
               
                  Type 2: Stored XSS (or Persistent) - 
               The application stores dangerous data in a database, message forum, visitor log, or other trusted data store. At a later time, the dangerous data is subsequently read back into the application and included in dynamic content. From an attacker's perspective, the optimal place to inject malicious content is in an area that is displayed to either many users or particularly interesting users. Interesting users typically have elevated privileges in the application or interact with sensitive data that is valuable to the attacker. If one of these users executes malicious content, the attacker may be able to perform privileged operations on behalf of the user or gain access to sensitive data belonging to the user. For example, the attacker might inject XSS into a log message, which might not be handled properly when an administrator views the logs.
            
               
                  Type 0: DOM-Based XSS - 
               In DOM-based XSS, the client performs the injection of XSS into the page; in the other types, the server performs the injection. DOM-based XSS generally involves server-controlled, trusted script that is sent to the client, such as Javascript that performs sanity checks on a form before the user submits it. If the server-supplied script processes user-supplied data and then injects it back into the web page (such as with dynamic HTML), then DOM-based XSS is possible.
            
            
            Once the malicious script is injected, the attacker can perform a variety of malicious activities. The attacker could transfer private information, such as cookies that may include session information, from the victim's machine to the attacker. The attacker could send malicious requests to a web site on behalf of the victim, which could be especially dangerous to the site if the victim has administrator privileges to manage that site. Phishing attacks could be used to emulate trusted web sites and trick the victim into entering a password, allowing the attacker to compromise the victim's account on that web site. Finally, the script could exploit a vulnerability in the web browser itself possibly taking over the victim's machine, sometimes referred to as "drive-by hacking."
            In many cases, the attack can be launched without the victim even being aware of it. Even with careful users, attackers frequently use a variety of methods to encode the malicious portion of the attack, such as URL encoding or Unicode, so the request looks less suspicious.
         
         

Fix information:
repo_url: https://github.com/nautobot/nautobot
commit_details: repo_url: https://github.com/nautobot/nautobot
author: Glenn Matthews
author_date: 2023-11-21 16:16:21-05:00
msg: [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

---------
repository: repo_url: https://github.com/nautobot/nautobot
repo_name: nautobot/nautobot
description: Network Source of Truth & Network Automation Platform
repo_language: Python
owner: nautobot
file_changes: filename: ci_pullrequest.yml
change_type: ModificationType.MODIFY
diff: @@ -31,6 +31,17 @@ jobs:
         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:
@@ -212,6 +223,7 @@ jobs:
       - "flake8"
       - "hadolint"
       - "markdownlint"
+      - "ruff"
       - "yamllint"
   tests-mysql:
     name: "Unit tests (MySQL and Python 3.11)"
@@ -262,6 +274,7 @@ jobs:
       - "flake8"
       - "hadolint"
       - "markdownlint"
+      - "ruff"
       - "yamllint"
   tests-ui:
     runs-on: "ubuntu-20.04"
@@ -332,6 +345,7 @@ jobs:
       - "flake8"
       - "hadolint"
       - "markdownlint"
+      - "ruff"
       - "yamllint"
   migration-tests-postgres:
     name: "Run migrations against test dataset (PostgreSQL and Python 3.11)"
@@ -373,6 +387,7 @@ jobs:
       - "flake8"
       - "hadolint"
       - "markdownlint"
+      - "ruff"
       - "yamllint"
   integration-test:
     runs-on: "ubuntu-20.04"
@@ -420,6 +435,7 @@ jobs:
       - "flake8"
       - "hadolint"
       - "markdownlint"
+      - "ruff"
       - "yamllint"
   changelog:
     if: contains(fromJson('["develop","next","develop-1.6"]'), github.base_ref)
@@ -445,6 +461,7 @@ jobs:
       - "flake8"
       - "hadolint"
       - "markdownlint"
+      - "ruff"
       - "yamllint"
     steps:
       - name: "Check out repository code"

programming_language: CoffeeScript
filename: 4832.changed
change_type: ModificationType.ADD
diff: @@ -0,0 +1 @@
+Changed the `render_jinja2()` API to no longer automatically call `mark_safe()` on the output.

programming_language: SQL
filename: 4832.housekeeping
change_type: ModificationType.ADD
diff: @@ -0,0 +1 @@
+Added `ruff` to invoke tasks and CI.

programming_language: Markdown
filename: 4832.security
change_type: ModificationType.ADD
diff: @@ -0,0 +1 @@
+Fixed cross-site-scripting (XSS) potential with maliciously crafted Custom Links, Computed Fields, and Job Buttons (GHSA-cf9f-wmhp-v4pr).

programming_language: Batchfile
filename: settings.py
change_type: ModificationType.MODIFY
diff: @@ -645,7 +645,7 @@ CONSTANCE_CONFIG = {
     ),
     "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"

programming_language: Python
filename: tables.py
change_type: ModificationType.MODIFY
diff: @@ -4,7 +4,7 @@ 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
@@ -162,7 +162,7 @@ class ToggleColumn(django_tables2.CheckBoxColumn):
 
     @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):
@@ -259,7 +259,7 @@ class ChoiceFieldColumn(django_tables2.Column):
             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
 
 
@@ -269,7 +269,7 @@ class ColorColumn(django_tables2.Column):
     """
 
     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):
@@ -306,7 +306,7 @@ class LinkedCountColumn(django_tables2.Column):
             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
 
 
@@ -393,12 +393,10 @@ class CustomFieldColumn(django_tables2.Column):
         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:
@@ -406,7 +404,7 @@ class CustomFieldColumn(django_tables2.Column):
         else:
             template = escape(value)
 
-        return mark_safe(template)
+        return template
 
 
 class RelationshipColumn(django_tables2.Column):
@@ -435,30 +433,27 @@ class RelationshipColumn(django_tables2.Column):
             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)

programming_language: Python
filename: helpers.py
change_type: ModificationType.MODIFY
diff: @@ -20,9 +20,9 @@ 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 "
@@ -99,7 +99,7 @@ def placeholder(value):
     """
     if value:
         return value
-    return mark_safe(HTML_NONE)
+    return HTML_NONE
 
 
 @library.filter()
@@ -123,7 +123,7 @@ def add_html_id(element_str, id_str):
     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()
@@ -154,10 +154,10 @@ def render_boolean(value):
         '<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()
@@ -180,7 +180,7 @@ def render_markdown(value):
     # Render Markdown
     html = markdown(value, extensions=["fenced_code", "tables"])
 
-    return mark_safe(html)
+    return mark_safe(html)  # noqa: S308
 
 
 @library.filter()

programming_language: Python
filename: data.py
change_type: ModificationType.MODIFY
diff: @@ -100,7 +100,11 @@ def render_jinja2(template_code, context):
     """
     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):

programming_language: Python
filename: generic.py
change_type: ModificationType.MODIFY
diff: @@ -16,9 +16,8 @@ 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
@@ -209,7 +208,7 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
             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()
 
@@ -397,10 +396,10 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
         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")
@@ -752,7 +751,7 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                 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:

programming_language: Python
filename: mixins.py
change_type: ModificationType.MODIFY
diff: @@ -19,8 +19,7 @@ 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
@@ -622,7 +621,7 @@ class ObjectListViewMixin(NautobotViewSetMixin, mixins.ListModelMixin):
             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
@@ -748,10 +747,10 @@ class ObjectEditViewMixin(NautobotViewSetMixin, mixins.CreateModelMixin, mixins.
             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"):

programming_language: Python
filename: utils.py
change_type: ModificationType.MODIFY
diff: @@ -3,7 +3,7 @@ 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
@@ -114,13 +114,13 @@ def get_csv_form_fields_from_serializer_class(serializer_class):
                     "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
@@ -132,27 +132,27 @@ def get_csv_form_fields_from_serializer_class(serializer_class):
             "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
 
@@ -173,21 +173,20 @@ def handle_protectederror(obj_list, request, e):
     """
     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):

programming_language: Python
filename: devices.py
change_type: ModificationType.MODIFY
diff: @@ -8,10 +8,9 @@ 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
@@ -257,9 +256,11 @@ class DeviceType(PrimaryModel):
                 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,
                         )
                     }
                 )

programming_language: Python
filename: views.py
change_type: ModificationType.MODIFY
diff: @@ -13,8 +13,7 @@ from django.forms import (
 )
 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
 
@@ -2634,8 +2633,8 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
 
             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())
@@ -2684,8 +2683,8 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL
         # 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():

programming_language: Python
filename: customfields.py
change_type: ModificationType.MODIFY
diff: @@ -10,7 +10,7 @@ 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,
@@ -517,7 +517,7 @@ class CustomField(BaseModel, ChangeLoggedModel, NotesMixin):
                 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),
                     )
                 ]
 

programming_language: Python
filename: models.py
change_type: ModificationType.MODIFY
diff: @@ -330,12 +330,15 @@ class CustomLink(BaseModel, ChangeLoggedModel, NotesMixin):
     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(

programming_language: Python
filename: relationships.py
change_type: ModificationType.MODIFY
diff: @@ -9,7 +9,7 @@ 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,
@@ -19,6 +19,7 @@ from nautobot.core.forms import (
 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
@@ -275,9 +276,10 @@ 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
@@ -289,11 +291,14 @@ class RelationshipModel(models.Model):
                     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:

programming_language: Python
filename: tables.py
change_type: ModificationType.MODIFY
diff: @@ -1,7 +1,6 @@
 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
 
@@ -236,7 +235,7 @@ class CustomFieldTable(BaseTable):
 
     def render_description(self, record):
         if record.description:
-            return mark_safe(render_markdown(record.description))
+            return render_markdown(record.description)
         return self.default
 
 

programming_language: Python
filename: computed_fields.py
change_type: ModificationType.MODIFY
diff: @@ -1,7 +1,6 @@
 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
 
@@ -27,14 +26,8 @@ def computed_fields(context, obj, advanced_ui=None):
     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()),
+    )

programming_language: Python
filename: custom_links.py
change_type: ModificationType.MODIFY
diff: @@ -2,6 +2,7 @@ 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
@@ -40,7 +41,7 @@ def custom_links(context, obj):
         "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:
@@ -57,16 +58,20 @@ def custom_links(context, obj):
                 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:
@@ -74,14 +79,16 @@ def custom_links(context, obj):
                 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

programming_language: Python
filename: job_buttons.py
change_type: ModificationType.MODIFY
diff: @@ -3,6 +3,7 @@ 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
@@ -87,10 +88,11 @@ def job_buttons(context, obj):
         "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}",
@@ -121,22 +123,24 @@ def job_buttons(context, obj):
                 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 = {
@@ -154,23 +158,34 @@ def job_buttons(context, obj):
                 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

programming_language: Python
filename: plugins.py
change_type: ModificationType.MODIFY
diff: @@ -52,7 +52,7 @@ def _get_registered_content(obj, method, template_context, return_html=True):
     if not return_html:
         return objects
 
-    return mark_safe(html)
+    return mark_safe(html)  # noqa: S308
 
 
 @register.simple_tag(takes_context=True)

programming_language: Python
filename: test_customfields.py
change_type: ModificationType.MODIFY
diff: @@ -2119,7 +2119,7 @@ class CustomFieldTableTest(TestCase):
             "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>'
             ),
         }
 

programming_language: Python
filename: test_relationships.py
change_type: ModificationType.MODIFY
diff: @@ -1206,13 +1206,13 @@ class RequiredRelationshipTestMixin:
                 ],
                 "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.",
                     },
                 },

programming_language: Python
filename: test_views.py
change_type: ModificationType.MODIFY
diff: @@ -8,6 +8,7 @@ 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
@@ -25,6 +26,7 @@ from nautobot.extras.choices import (
 )
 from nautobot.extras.constants import HTTP_CONTENT_TYPE_JSON
 from nautobot.extras.models import (
+    ComputedField,
     ConfigContext,
     ConfigContextSchema,
     CustomField,
@@ -48,7 +50,6 @@ from nautobot.extras.models import (
     Status,
     Tag,
     Webhook,
-    ComputedField,
 )
 from nautobot.extras.tests.constants import BIG_GRAPHQL_DEVICE_QUERY
 from nautobot.extras.tests.test_relationships import RequiredRelationshipTestMixin
@@ -130,6 +131,63 @@ 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(
@@ -432,7 +490,9 @@ class CustomFieldTestCase(
         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):
@@ -454,6 +514,71 @@ class CustomLinkTest(TestCase):
         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,
@@ -1863,6 +1988,61 @@ class JobButtonTestCase(
         }
 
 
+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",)

programming_language: Python
filename: views.py
change_type: ModificationType.MODIFY
diff: @@ -13,9 +13,8 @@ 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
@@ -414,10 +413,10 @@ class CustomFieldEditView(generic.ObjectEditView):
                 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
@@ -652,10 +651,10 @@ class DynamicGroupEditView(generic.ObjectEditView):
                 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
@@ -1562,8 +1561,8 @@ class JobButtonRunView(ObjectPermissionRequiredMixin, View):
             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"])
 
 
@@ -2037,10 +2036,10 @@ class SecretsGroupEditView(generic.ObjectEditView):
                 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

programming_language: Python
filename: tables.py
change_type: ModificationType.MODIFY
diff: @@ -33,7 +33,7 @@ from .models import (
     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 %}

programming_language: Python
filename: views.py
change_type: ModificationType.MODIFY
diff: @@ -10,9 +10,8 @@ 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
 
@@ -575,20 +574,27 @@ class PrefixEditView(generic.ObjectEditView):
         """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,
                 ),
             )
 
@@ -597,45 +603,59 @@ class PrefixEditView(generic.ObjectEditView):
         )
 
         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,
@@ -645,20 +665,29 @@ class PrefixEditView(generic.ObjectEditView):
                 )
                 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,
                     ),
                 )
 
@@ -763,13 +792,12 @@ class IPAddressEditView(generic.ObjectEditView):
         """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")
@@ -784,20 +812,27 @@ 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}),
                     ),
                 )
 
@@ -1023,9 +1058,12 @@ class IPAddressMergeView(view_mixins.GetReturnURLMixin, view_mixins.ObjectPermis
                         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()
@@ -1041,7 +1079,7 @@ class IPAddressMergeView(view_mixins.GetReturnURLMixin, view_mixins.ObjectPermis
                     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)
 
 

programming_language: Python
filename: poetry.lock
change_type: ModificationType.MODIFY
diff: @@ -3365,6 +3365,32 @@ 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"
@@ -4033,4 +4059,4 @@ sso = ["social-auth-core"]
 [metadata]
 lock-version = "2.0"
 python-versions = ">=3.8,<3.12"
-content-hash = "5f9bca6f1bca37e007a5611ec280e9842d28d77bf72c68d4f90ff627fd569d00"
+content-hash = "4709a608499593b8cdb5a9b38b34544891123dc1d86885de7a751c901ab07c4b"

programming_language: Lua
filename: pyproject.toml
change_type: ModificationType.MODIFY
diff: @@ -199,6 +199,8 @@ isort = "~5.12.0"
 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"
 
@@ -341,6 +343,26 @@ notes = """,
 # @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"

programming_language: Python
filename: pre-commit
change_type: ModificationType.MODIFY
diff: @@ -45,6 +45,9 @@ else
     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
 

programming_language: Shell
filename: tasks.py
change_type: ModificationType.MODIFY
diff: @@ -611,6 +611,13 @@ def pylint(context, target=None, recursive=False):
         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."""
@@ -998,6 +1005,7 @@ def tests(context, lint_only=False, keepdb=False):
     hadolint(context)
     markdownlint(context)
     yamllint(context)
+    ruff(context)
     pylint(context)
     check_migrations(context)
     check_schema(context)

programming_language: Python