From ab6579dbf195a8e0a52963af1afa96aa0c1d41eb Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Wed, 12 Mar 2025 18:15:50 +0000 Subject: [PATCH 1/2] Move typo checks to after_insert --- warehouse/packaging/models.py | 89 +++++++++++++++++++++++++++++++++ warehouse/packaging/services.py | 83 ++---------------------------- 2 files changed, 92 insertions(+), 80 deletions(-) diff --git a/warehouse/packaging/models.py b/warehouse/packaging/models.py index 0ceb3cc635a9..4a097e62cd84 100644 --- a/warehouse/packaging/models.py +++ b/warehouse/packaging/models.py @@ -34,6 +34,7 @@ Text, UniqueConstraint, cast, + event, func, or_, orm, @@ -67,6 +68,7 @@ from warehouse.classifiers.models import Classifier from warehouse.events.models import HasEvents from warehouse.forklift import metadata +from warehouse.helpdesk.interfaces import IAdminNotificationService from warehouse.integrations.vulnerabilities.models import VulnerabilityRecord from warehouse.observations.models import HasObservations from warehouse.organizations.models import ( @@ -77,6 +79,10 @@ Team, TeamProjectRole, ) +from warehouse.packaging.interfaces import ( + IProjectService, + ProjectNameUnavailableTypoSquattingError, +) from warehouse.sitemap.models import SitemapMixin from warehouse.utils import dotted_navigator, wheel from warehouse.utils.attrs import make_repr @@ -496,6 +502,89 @@ def yanked_releases(self): ) +@event.listens_for(Project, "after_insert") +def receive_after_insert(mapper, connection, project): + request = get_current_request() + project_service = request.find_service(IProjectService) + name = project.name + try: + project_service.check_project_name_after_insert(name) + except ProjectNameUnavailableTypoSquattingError as exc: + request.log.warning( + "ProjectNameUnavailableTypoSquattingError", + check_name=exc.check_name, + existing_project_name=exc.existing_project_name, + ) + # Send notification to Admins for review + notification_service = request.find_service(IAdminNotificationService) + + warehouse_domain = request.registry.settings.get("warehouse.domain") + new_project_page = request.route_url( + "packaging.project", + name=name, + _host=warehouse_domain, + ) + new_project_text = ( + f"During `file_upload`, Project Create for " + f"*<{new_project_page}|{name}>* was detected as a potential " + f"typo by the `{exc.check_name!r}` check." + ) + existing_project_page = request.route_url( + "packaging.project", + name=exc.existing_project_name, + _host=warehouse_domain, + ) + existing_project_text = ( + f"<{existing_project_page}|Existing project: " + f"{exc.existing_project_name}>" + ) + + webhook_payload = { + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "TypoSnyper :warning:", + "emoji": True, + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": new_project_text, + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": existing_project_text, + }, + }, + {"type": "divider"}, + { + "type": "context", + "elements": [ + { + "type": "plain_text", + "text": "Once reviewed/confirmed, " + "react to this message with :white_check_mark:", + "emoji": True, + } + ], + }, + ] + } + notification_service.send_notification(payload=webhook_payload) + + request.metrics.increment( + "warehouse.packaging.services.create_project.typo_squatting", + tags=[f"check_name:{exc.check_name!r}"], + ) + + class DependencyKind(enum.IntEnum): requires = 1 provides = 2 diff --git a/warehouse/packaging/services.py b/warehouse/packaging/services.py index d3b14eb1fb8f..fe44870086e3 100644 --- a/warehouse/packaging/services.py +++ b/warehouse/packaging/services.py @@ -36,7 +36,6 @@ from warehouse.admin.flags import AdminFlagValue from warehouse.email import send_pending_trusted_publisher_invalidated_email from warehouse.events.tags import EventTag -from warehouse.helpdesk.interfaces import IAdminNotificationService from warehouse.metrics import IMetricsService from warehouse.oidc.models import PendingOIDCPublisher from warehouse.packaging.interfaces import ( @@ -485,6 +484,9 @@ def check_project_name(self, name: str) -> None: ).first(): raise ProjectNameUnavailableSimilarError(similar_project_name) + return None + + def check_project_name_after_insert(self, name: str) -> None: # Check for typo-squatting. if typo_check_match := typo_check_name(canonicalize_name(name)): raise ProjectNameUnavailableTypoSquattingError( @@ -558,85 +560,6 @@ def create_project( projecthelp=request.help_url(_anchor="project-name"), ), ) from None - except ProjectNameUnavailableTypoSquattingError as exc: - # Don't yet raise an error here, as we want to allow the - # user to proceed with the project creation. We'll log a warning - # instead. - request.log.warning( - "ProjectNameUnavailableTypoSquattingError", - check_name=exc.check_name, - existing_project_name=exc.existing_project_name, - ) - # Send notification to Admins for review - notification_service = request.find_service(IAdminNotificationService) - - warehouse_domain = request.registry.settings.get("warehouse.domain") - new_project_page = request.route_url( - "packaging.project", - name=name, - _host=warehouse_domain, - ) - new_project_text = ( - f"During `file_upload`, Project Create for " - f"*<{new_project_page}|{name}>* was detected as a potential " - f"typo by the `{exc.check_name!r}` check." - ) - existing_project_page = request.route_url( - "packaging.project", - name=exc.existing_project_name, - _host=warehouse_domain, - ) - existing_project_text = ( - f"<{existing_project_page}|Existing project: " - f"{exc.existing_project_name}>" - ) - - webhook_payload = { - "blocks": [ - { - "type": "header", - "text": { - "type": "plain_text", - "text": "TypoSnyper :warning:", - "emoji": True, - }, - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": new_project_text, - }, - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": existing_project_text, - }, - }, - {"type": "divider"}, - { - "type": "context", - "elements": [ - { - "type": "plain_text", - "text": "Once reviewed/confirmed, " - "react to this message with :white_check_mark:", - "emoji": True, - } - ], - }, - ] - } - notification_service.send_notification(payload=webhook_payload) - - request.metrics.increment( - "warehouse.packaging.services.create_project.typo_squatting", - tags=[f"check_name:{exc.check_name!r}"], - ) - # and continue with the project creation - pass # The project name is valid: create it and add it project = Project(name=name) From d3a87105fc27be870d5a283690024bc63030b010 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Thu, 13 Mar 2025 14:21:35 +0000 Subject: [PATCH 2/2] Update tests --- tests/unit/packaging/test_services.py | 2 +- warehouse/packaging/models.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/unit/packaging/test_services.py b/tests/unit/packaging/test_services.py index 71f53993317c..d9e489855d59 100644 --- a/tests/unit/packaging/test_services.py +++ b/tests/unit/packaging/test_services.py @@ -1057,7 +1057,7 @@ def test_check_project_name_typosquatting_prohibited(self, db_session): ProhibitedProjectFactory.create(name="numpy") with pytest.raises(ProjectNameUnavailableTypoSquattingError): - service.check_project_name("numpi") + service.check_project_name_after_insert("numpi") def test_check_project_name_ok(self, db_session): service = ProjectService(session=db_session) diff --git a/warehouse/packaging/models.py b/warehouse/packaging/models.py index 4a097e62cd84..0d58a9623db3 100644 --- a/warehouse/packaging/models.py +++ b/warehouse/packaging/models.py @@ -505,6 +505,9 @@ def yanked_releases(self): @event.listens_for(Project, "after_insert") def receive_after_insert(mapper, connection, project): request = get_current_request() + if not request: + # Can't do anything if there isn't a request + return project_service = request.find_service(IProjectService) name = project.name try: