Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/shared/channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/shared/listeners/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions src/shared/listeners/cache_suggestions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
76 changes: 76 additions & 0 deletions src/shared/listeners/notify_users.py
Original file line number Diff line number Diff line change
@@ -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}"
)
103 changes: 103 additions & 0 deletions src/shared/management/commands/create_test_cve.py
Original file line number Diff line number Diff line change
@@ -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}")
)
Original file line number Diff line number Diff line change
@@ -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'),
),
]
Original file line number Diff line number Diff line change
@@ -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')),
),
]
1 change: 1 addition & 0 deletions src/shared/models/nix_evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ def __str__(self) -> str:
class Meta: # type: ignore[override]
indexes = [
BTreeIndex(fields=["name"]),
BTreeIndex(fields=["attribute"]),
GinIndex(fields=["search_vector"]),
]

Expand Down
6 changes: 6 additions & 0 deletions src/shared/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

<link rel="stylesheet" type="text/css" href="/static/style.css" />
<link rel="stylesheet" type="text/css" href="/static/notifications.css" />
<link rel="stylesheet" type="text/css" href="/static/subscriptions.css" />

{% block extra_head %}{% endblock extra_head %}
</head>
Expand Down Expand Up @@ -40,6 +41,11 @@ <h1>
{% else %}
<span>{{user.username}}</span>
{% endif %}
<a
href="{% url 'webview:subscriptions:center' %}"
class="subscriptions-navbar-icon"
>
</a>
{% notifications_badge user.profile.unread_notifications_count %}
<a href="{% url 'account_logout' %}">
Logout
Expand Down
37 changes: 37 additions & 0 deletions src/webview/management/commands/list_subscriptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from argparse import ArgumentParser
from typing import Any

from django.contrib.auth.models import User
from django.core.management.base import BaseCommand, CommandError


class Command(BaseCommand):
help = "List all package subscriptions for a user"

def add_arguments(self, parser: ArgumentParser) -> None:
parser.add_argument(
"--user",
type=str,
help="Username to list subscriptions for",
required=True,
)

def handle(self, *args: Any, **options: Any) -> None:
username = options["user"]

try:
user = User.objects.get(username=username)
except User.DoesNotExist:
raise CommandError(f"User '{username}' does not exist")

subscriptions = user.profile.package_subscriptions

if not subscriptions:
self.stdout.write(f"No package subscriptions found for user '{username}'")
return

self.stdout.write(f"Package subscriptions for user '{username}':")
self.stdout.write("-" * 50)

for i, package in enumerate(subscriptions, 1):
self.stdout.write(f"{i}. {package}")
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 4.2.24 on 2025-10-16 17:12

import django.contrib.postgres.fields
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('webview', '0003_profile_unread_notifications_count'),
]

operations = [
migrations.RemoveField(
model_name='profile',
name='subscriptions',
),
migrations.AddField(
model_name='profile',
name='package_subscriptions',
field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, help_text="Package attribute names this user has subscribed to (e.g., 'firefox', 'chromium')", size=None),
),
]
10 changes: 7 additions & 3 deletions src/webview/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@
from typing import Any

from django.contrib.auth.models import User
from django.contrib.postgres import fields
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver

from shared.models import NixpkgsIssue


class Profile(models.Model):
"""
Expand All @@ -16,8 +15,13 @@ class Profile(models.Model):
"""

user = models.OneToOneField(User, on_delete=models.CASCADE)
subscriptions = models.ManyToManyField(NixpkgsIssue, related_name="subscribers")
unread_notifications_count = models.PositiveIntegerField(default=0)
package_subscriptions = fields.ArrayField(
models.CharField(max_length=255),
default=list,
blank=True,
help_text="Package attribute names this user has subscribed to (e.g., 'firefox', 'chromium')",
)

def recalculate_unread_notifications_count(self) -> None:
"""Recalculate and update the unread notifications count from the database."""
Expand Down
Loading