diff --git a/src/shared/channels.py b/src/shared/channels.py index 9f395234..a33f4316 100644 --- a/src/shared/channels.py +++ b/src/shared/channels.py @@ -40,14 +40,24 @@ class ContainerChannel(TriggerChannel): lock_notifications = True +# We have two channels for CVEDerivationClusterProposal for two usages: +# 1. Caching suggestions: this operation is idempotent and performance sensitive so we disable locking on this channel +# 2. Notifying subscribed users of activity on their packages: this operation is not performance sensitive and we don't want duplicate notifications so we enable locking on this channel @dataclass -class CVEDerivationClusterProposalChannel(TriggerChannel): +class CVEDerivationClusterProposalCacheChannel(TriggerChannel): model = CVEDerivationClusterProposal # We don't need to lock notifications. # If we are caching twice the same proposal, we will just replace it. lock_notifications = False +@dataclass +class CVEDerivationClusterProposalNotificationChannel(TriggerChannel): + model = CVEDerivationClusterProposal + # We don't want to trigger user notifications more than once + lock_notifications = True + + @dataclass class NixpkgsIssueChannel(TriggerChannel): model = NixpkgsIssue diff --git a/src/shared/listeners/__init__.py b/src/shared/listeners/__init__.py index fe2af1aa..d204ef25 100644 --- a/src/shared/listeners/__init__.py +++ b/src/shared/listeners/__init__.py @@ -2,3 +2,5 @@ import shared.listeners.nix_evaluation # noqa import shared.listeners.automatic_linkage # noqa import shared.listeners.cache_suggestions # noqa +import shared.listeners.notify_users # noqa +import shared.listeners.cache_issues # noqa diff --git a/src/shared/listeners/cache_suggestions.py b/src/shared/listeners/cache_suggestions.py index 4f65d376..6b13fdb4 100644 --- a/src/shared/listeners/cache_suggestions.py +++ b/src/shared/listeners/cache_suggestions.py @@ -8,7 +8,7 @@ import pgpubsub from django.db.models import Prefetch -from shared.channels import CVEDerivationClusterProposalChannel +from shared.channels import CVEDerivationClusterProposalCacheChannel from shared.models import NixDerivation, NixMaintainer from shared.models.cached import CachedSuggestions from shared.models.cve import AffectedProduct, Metric, Version @@ -170,7 +170,7 @@ def cache_new_suggestions(suggestion: CVEDerivationClusterProposal) -> None: # CachedSuggestions.objects.filter(pk=new.pk).delete() -@pgpubsub.post_insert_listener(CVEDerivationClusterProposalChannel) +@pgpubsub.post_insert_listener(CVEDerivationClusterProposalCacheChannel) def cache_new_suggestions_following_new_container( old: CVEDerivationClusterProposal, new: CVEDerivationClusterProposal ) -> None: diff --git a/src/shared/listeners/notify_users.py b/src/shared/listeners/notify_users.py new file mode 100644 index 00000000..01a5c216 --- /dev/null +++ b/src/shared/listeners/notify_users.py @@ -0,0 +1,76 @@ +import logging + +import pgpubsub +from django.contrib.auth.models import User + +from shared.channels import CVEDerivationClusterProposalNotificationChannel +from shared.models.linkage import CVEDerivationClusterProposal +from webview.models import Notification + +logger = logging.getLogger(__name__) + + +def create_package_subscription_notifications( + suggestion: CVEDerivationClusterProposal, +) -> None: + """ + Create notifications for users subscribed to packages affected by the suggestion. + """ + # Extract all affected package names from the suggestion + affected_packages = list( + suggestion.derivations.values_list("attribute", flat=True).distinct() + ) + + if not affected_packages: + logger.debug(f"No packages found for suggestion {suggestion.pk}") + return + + # Find users subscribed to ANY of these packages + subscribed_users = User.objects.filter( + profile__package_subscriptions__overlap=affected_packages + ).select_related("profile") + + if not subscribed_users.exists(): + logger.debug(f"No subscribed users found for packages: {affected_packages}") + return + + logger.info( + f"Creating notifications for {subscribed_users.count()} users for CVE {suggestion.cve.cve_id}" + ) + + for user in subscribed_users: + # Find which of their subscribed packages are actually affected + user_affected_packages = [ + pkg + for pkg in user.profile.package_subscriptions + if pkg in affected_packages + ] + + # Create notification + try: + Notification.objects.create_for_user( + user=user, + title=f"New security suggestion affects: {', '.join(user_affected_packages)}", + message=f"CVE {suggestion.cve.cve_id} may affect packages you're subscribed to. " + f"Affected packages: {', '.join(user_affected_packages)}. ", + ) + logger.debug( + f"Created notification for user {user.username} for packages: {user_affected_packages}" + ) + except Exception as e: + logger.error(f"Failed to create notification for user {user.username}: {e}") + + +@pgpubsub.post_insert_listener(CVEDerivationClusterProposalNotificationChannel) +def notify_subscribed_users_following_suggestion_insert( + old: CVEDerivationClusterProposal, new: CVEDerivationClusterProposal +) -> None: + """ + Notify users subscribed to packages when a new security suggestion is created. + """ + try: + create_package_subscription_notifications(new) + except Exception as e: + logger.error( + f"Failed to create package subscription notifications for suggestion {new.pk}: {e}" + ) diff --git a/src/shared/management/commands/create_test_cve.py b/src/shared/management/commands/create_test_cve.py new file mode 100644 index 00000000..26bd9d31 --- /dev/null +++ b/src/shared/management/commands/create_test_cve.py @@ -0,0 +1,103 @@ +from argparse import ArgumentParser +from datetime import datetime +from typing import Any + +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from shared.models import ( + AffectedProduct, + Container, + CveRecord, + Description, + Organization, + Version, +) + + +class Command(BaseCommand): + help = "Create a test CVE for a specific package" + + def add_arguments(self, parser: ArgumentParser) -> None: + parser.add_argument( + "package_name", + type=str, + help="Package name to create a CVE for", + ) + parser.add_argument( + "--cve-id", + type=str, + help="Custom CVE ID (default: auto-generated)", + ) + + def handle(self, *args: Any, **options: Any) -> None: + package_name = options["package_name"] + cve_id = options.get("cve_id") + + # Generate CVE ID if not provided + if not cve_id: + current_year = datetime.now().year + existing_cves = CveRecord.objects.filter( + cve_id__startswith=f"CVE-{current_year}-" + ).count() + cve_id = f"CVE-{current_year}-{(existing_cves + 1):04d}" + + # Check if CVE already exists + if CveRecord.objects.filter(cve_id=cve_id).exists(): + raise CommandError(f"CVE {cve_id} already exists") + + with transaction.atomic(): + # Create organization + org, _ = Organization.objects.get_or_create( + short_name="TEST_ORG", + defaults={"uuid": "12345678-1234-5678-9abc-123456789012"}, + ) + + # Create CVE record + cve_record = CveRecord.objects.create( + cve_id=cve_id, + state=CveRecord.RecordState.PUBLISHED, + assigner=org, + date_published=datetime.now(), + date_updated=datetime.now(), + triaged=False, + ) + + # Create description + description = Description.objects.create( + lang="en", + value=f"Test vulnerability in {package_name} package.", + ) + + # Create container + container = Container.objects.create( + _type=Container.Type.CNA, + cve=cve_record, + provider=org, + title=f"Vulnerability in {package_name}", + date_public=datetime.now(), + ) + container.descriptions.add(description) + + # Create affected product + affected_product = AffectedProduct.objects.create( + vendor="nixpkgs", + product=package_name, + package_name=package_name, + default_status=AffectedProduct.Status.AFFECTED, + ) + + # Add version constraint + version_affected = Version.objects.create( + status=Version.Status.AFFECTED, + version_type="semver", + less_than="*", + ) + affected_product.versions.add(version_affected) + + # Link to container + container.affected.add(affected_product) + + self.stdout.write( + self.style.SUCCESS(f"Created CVE {cve_id} for {package_name}") + ) diff --git a/src/shared/migrations/0057_nixderivation_shared_nixd_attribu_5fcca7_btree.py b/src/shared/migrations/0057_nixderivation_shared_nixd_attribu_5fcca7_btree.py new file mode 100644 index 00000000..cb428f48 --- /dev/null +++ b/src/shared/migrations/0057_nixderivation_shared_nixd_attribu_5fcca7_btree.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.24 on 2025-10-16 17:12 + +import django.contrib.postgres.indexes +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('shared', '0056_packageeditevent_packageeditevent_append_only'), + ] + + operations = [ + migrations.AddIndex( + model_name='nixderivation', + index=django.contrib.postgres.indexes.BTreeIndex(fields=['attribute'], name='shared_nixd_attribu_5fcca7_btree'), + ), + ] diff --git a/src/shared/migrations/0058_remove_cvederivationclusterproposal_pgpubsub_8c7ef_and_more.py b/src/shared/migrations/0058_remove_cvederivationclusterproposal_pgpubsub_8c7ef_and_more.py new file mode 100644 index 00000000..45b95a7c --- /dev/null +++ b/src/shared/migrations/0058_remove_cvederivationclusterproposal_pgpubsub_8c7ef_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.24 on 2025-10-31 13:17 + +from django.db import migrations +import pgtrigger.compiler +import pgtrigger.migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('shared', '0057_nixderivation_shared_nixd_attribu_5fcca7_btree'), + ] + + operations = [ + pgtrigger.migrations.RemoveTrigger( + model_name='cvederivationclusterproposal', + name='pgpubsub_8c7ef', + ), + pgtrigger.migrations.AddTrigger( + model_name='cvederivationclusterproposal', + trigger=pgtrigger.compiler.Trigger(name='pgpubsub_6aede', sql=pgtrigger.compiler.UpsertTriggerSql(declare='DECLARE payload JSONB; notification_context_text TEXT;', func='\n \n payload := \'{"app": "shared", "model": "CVEDerivationClusterProposal"}\'::jsonb;\n payload := jsonb_insert(payload, \'{old}\', COALESCE(to_jsonb(OLD), \'null\'));\n payload := jsonb_insert(payload, \'{new}\', COALESCE(to_jsonb(NEW), \'null\'));\n SELECT current_setting(\'pgpubsub.notification_context\', True) INTO notification_context_text;\n IF COALESCE(notification_context_text, \'\') = \'\' THEN\n notification_context_text := \'{}\';\n END IF;\n payload := jsonb_insert(payload, \'{context}\', notification_context_text::jsonb);\n \n \n perform pg_notify(\'pgpubsub_6aede\', payload::text);\n RETURN NEW;\n ', hash='3fc8ef40725c909c32771363fbb4ae378e50302c', operation='INSERT', pgid='pgtrigger_pgpubsub_6aede_fc173', table='shared_cvederivationclusterproposal', when='AFTER')), + ), + pgtrigger.migrations.AddTrigger( + model_name='cvederivationclusterproposal', + trigger=pgtrigger.compiler.Trigger(name='pgpubsub_07e32', sql=pgtrigger.compiler.UpsertTriggerSql(declare='DECLARE payload JSONB; notification_context_text TEXT;', func='\n \n payload := \'{"app": "shared", "model": "CVEDerivationClusterProposal"}\'::jsonb;\n payload := jsonb_insert(payload, \'{old}\', COALESCE(to_jsonb(OLD), \'null\'));\n payload := jsonb_insert(payload, \'{new}\', COALESCE(to_jsonb(NEW), \'null\'));\n SELECT current_setting(\'pgpubsub.notification_context\', True) INTO notification_context_text;\n IF COALESCE(notification_context_text, \'\') = \'\' THEN\n notification_context_text := \'{}\';\n END IF;\n payload := jsonb_insert(payload, \'{context}\', notification_context_text::jsonb);\n \n \n INSERT INTO pgpubsub_notification (channel, payload)\n VALUES (\'pgpubsub_07e32\', payload);\n \n perform pg_notify(\'pgpubsub_07e32\', payload::text);\n RETURN NEW;\n ', hash='eef760200ecd9145771d2d5b5c1d9bc1f96fb2ac', operation='INSERT', pgid='pgtrigger_pgpubsub_07e32_acce7', table='shared_cvederivationclusterproposal', when='AFTER')), + ), + ] diff --git a/src/shared/models/nix_evaluation.py b/src/shared/models/nix_evaluation.py index 53051937..a2ce3fa5 100644 --- a/src/shared/models/nix_evaluation.py +++ b/src/shared/models/nix_evaluation.py @@ -304,6 +304,7 @@ def __str__(self) -> str: class Meta: # type: ignore[override] indexes = [ BTreeIndex(fields=["name"]), + BTreeIndex(fields=["attribute"]), GinIndex(fields=["search_vector"]), ] diff --git a/src/shared/templates/base.html b/src/shared/templates/base.html index b28f5321..e6017df7 100644 --- a/src/shared/templates/base.html +++ b/src/shared/templates/base.html @@ -11,6 +11,7 @@ + {% block extra_head %}{% endblock extra_head %} @@ -40,6 +41,11 @@
Subscribe to receive notifications about security alerts suggestions that may affect this package.
+NIXPKGS-[0-9]{4}-[0-9]{4,19})$",