diff --git a/vulnerabilities/api_v3.py b/vulnerabilities/api_v3.py index 12f10ed1c..039a2cb31 100644 --- a/vulnerabilities/api_v3.py +++ b/vulnerabilities/api_v3.py @@ -30,6 +30,7 @@ from vulnerabilities.models import AdvisorySeverity from vulnerabilities.models import AdvisoryV2 from vulnerabilities.models import AdvisoryWeakness +from vulnerabilities.models import DetectionRule from vulnerabilities.models import Group from vulnerabilities.models import GroupedAdvisory from vulnerabilities.models import ImpactedPackageAffecting @@ -704,3 +705,47 @@ def get_fixing_advisories_bulk(packages): result[package.id] = grouped return result + + +class DetectionRuleFilter(filters.FilterSet): + advisory_avid = filters.CharFilter(field_name="related_advisories__avid", lookup_expr="exact") + + rule_text_contains = filters.CharFilter(field_name="rule_text", lookup_expr="icontains") + + class Meta: + model = DetectionRule + fields = ["rule_type"] + + +class DetectionRuleSerializer(serializers.ModelSerializer): + advisory_avid = serializers.SerializerMethodField() + + class Meta: + model = DetectionRule + fields = ["rule_type", "source_url", "rule_metadata", "rule_text", "advisory_avid"] + + def get_advisory_avid(self, obj): + avids = {advisory.avid for advisory in obj.related_advisories.all()} + return sorted(avids) + + +class DetectionRuleViewSet(viewsets.ReadOnlyModelViewSet): + advisories_prefetch = Prefetch( + "related_advisories", queryset=AdvisoryV2.objects.only("id", "avid").distinct() + ) + queryset = DetectionRule.objects.prefetch_related(advisories_prefetch) + serializer_class = DetectionRuleSerializer + throttle_classes = [AnonRateThrottle, PermissionBasedUserRateThrottle] + filter_backends = [filters.DjangoFilterBackend] + filterset_class = DetectionRuleFilter + + def get_queryset(self): + queryset = super().get_queryset() + query_params = ["advisory_avid", "rule_text_contains", "rule_type"] + has_query_params = any( + query_param in self.request.query_params for query_param in query_params + ) + if not has_query_params: + return queryset.none() + + return queryset diff --git a/vulnerabilities/forms.py b/vulnerabilities/forms.py index 03829cd52..2bdd49232 100644 --- a/vulnerabilities/forms.py +++ b/vulnerabilities/forms.py @@ -13,6 +13,7 @@ from django_altcha import AltchaField from vulnerabilities.models import ApiUser +from vulnerabilities.models import DetectionRuleTypes class PackageSearchForm(forms.Form): @@ -43,6 +44,35 @@ class AdvisorySearchForm(forms.Form): ) +class DetectionRuleSearchForm(forms.Form): + rule_type = forms.ChoiceField( + required=False, + label="Rule Type", + choices=[("", "All")] + DetectionRuleTypes.choices, + initial="", + ) + + advisory_avid = forms.CharField( + required=False, + label="Advisory avid", + widget=forms.TextInput( + attrs={ + "placeholder": "Search by avid: github_osv_importer_v2/GHSA-7g5f-wrx8-5ccf", + } + ), + ) + + rule_text_contains = forms.CharField( + required=False, + label="Rule Text", + widget=forms.TextInput( + attrs={ + "placeholder": "Search in rule text", + } + ), + ) + + class ApiUserCreationForm(forms.ModelForm): """Support a simplified creation for API-only users directly from the UI.""" diff --git a/vulnerabilities/migrations/0130_detectionrule.py b/vulnerabilities/migrations/0130_detectionrule.py new file mode 100644 index 000000000..62a3d6c18 --- /dev/null +++ b/vulnerabilities/migrations/0130_detectionrule.py @@ -0,0 +1,65 @@ +# Generated by Django 5.2.11 on 2026-05-15 19:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("vulnerabilities", "0129_advisorypoc"), + ] + + operations = [ + migrations.CreateModel( + name="DetectionRule", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "rule_type", + models.CharField( + choices=[ + ("yara", "Yara"), + ("yara-x", "Yara-X"), + ("sigma", "Sigma"), + ("clamav", "ClamAV"), + ("suricata", "Suricata"), + ], + help_text="The type of the detection rule content (e.g., YARA, Sigma).", + max_length=50, + ), + ), + ( + "source_url", + models.URLField( + help_text="URL to the original source or reference for this rule.", + max_length=1024, + ), + ), + ( + "rule_metadata", + models.JSONField( + blank=True, + help_text="Additional structured data such as tags, or author information.", + null=True, + ), + ), + ( + "rule_text", + models.TextField(help_text="The content of the detection signature."), + ), + ( + "related_advisories", + models.ManyToManyField( + help_text="Advisories associated with this DetectionRule.", + related_name="detection_rules", + to="vulnerabilities.advisoryv2", + ), + ), + ], + ), + ] diff --git a/vulnerabilities/models.py b/vulnerabilities/models.py index 2c9e385a3..bd01c5d5d 100644 --- a/vulnerabilities/models.py +++ b/vulnerabilities/models.py @@ -3890,3 +3890,43 @@ class AdvisoryPOC(models.Model): is_confirmed = models.BooleanField( default=False, help_text="Indicates whether this POC has been verified or confirmed." ) + + +class DetectionRuleTypes(models.TextChoices): + """Defines the supported formats for security detection rules.""" + + YARA = "yara", "Yara" + YARA_X = "yara-x", "Yara-X" + SIGMA = "sigma", "Sigma" + CLAMAV = "clamav", "ClamAV" + SURICATA = "suricata", "Suricata" + + +class DetectionRule(models.Model): + """ + A Detection Rule is code used to identify malicious activity or security threats. + """ + + rule_type = models.CharField( + max_length=50, + choices=DetectionRuleTypes.choices, + help_text="The type of the detection rule content (e.g., YARA, Sigma).", + ) + + source_url = models.URLField( + max_length=1024, help_text="URL to the original source or reference for this rule." + ) + + rule_metadata = models.JSONField( + null=True, + blank=True, + help_text="Additional structured data such as tags, or author information.", + ) + + rule_text = models.TextField(help_text="The content of the detection signature.") + + related_advisories = models.ManyToManyField( + AdvisoryV2, + related_name="detection_rules", + help_text="Advisories associated with this DetectionRule.", + ) diff --git a/vulnerabilities/templates/detection_rules.html b/vulnerabilities/templates/detection_rules.html new file mode 100644 index 000000000..21dd7b3bf --- /dev/null +++ b/vulnerabilities/templates/detection_rules.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} +{% load humanize %} +{% load widget_tweaks %} + +{% block title %} +Detection Rule Search +{% endblock %} + +{% block content %} +
+ {% include "detection_rules_box.html" %} +
+ +
+
+
+
+ {{ page_obj.paginator.count|intcomma }} results +
+ {% if is_paginated %} + {% include 'includes/rules_pagination.html' with page_obj=page_obj %} + {% endif %} +
+
+
+ +
+
+ + + + + + + + + + + + {% for detection_rule in page_obj %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
TypeMetadataTextSource URLAdvisory IDs
{{ detection_rule.rule_type }}{{ detection_rule.rule_metadata }}{{ detection_rule.rule_text|truncatewords:10 }}{{ detection_rule.source_url }} + {% for advisory in detection_rule.related_advisories.all %} + {% ifchanged advisory.avid %} + {{ advisory.avid }} +
+ {% endifchanged %} + {% endfor %} +
+ No detection rules found. +
+
+ + + {% if is_paginated %} + {% include 'includes/rules_pagination.html' with page_obj=page_obj %} + {% endif %} +
+ +{% endblock %} diff --git a/vulnerabilities/templates/detection_rules_box.html b/vulnerabilities/templates/detection_rules_box.html new file mode 100644 index 000000000..d76efeaad --- /dev/null +++ b/vulnerabilities/templates/detection_rules_box.html @@ -0,0 +1,46 @@ +{% load widget_tweaks %} +
+
+ Search for Rules + +
+
+
+
+
+
+
+ {% render_field detection_search_form.rule_type %} +
+
+
+ {% render_field detection_search_form.advisory_avid class="input" %} +
+
+ {% render_field detection_search_form.rule_text_contains class="input" %} +
+
+ +
+
+
+
+
+
diff --git a/vulnerabilities/templates/includes/rules_pagination.html b/vulnerabilities/templates/includes/rules_pagination.html new file mode 100644 index 000000000..8f7603b1e --- /dev/null +++ b/vulnerabilities/templates/includes/rules_pagination.html @@ -0,0 +1,37 @@ + \ No newline at end of file diff --git a/vulnerabilities/templates/navbar.html b/vulnerabilities/templates/navbar.html index 3d3fa0e91..5317638f7 100644 --- a/vulnerabilities/templates/navbar.html +++ b/vulnerabilities/templates/navbar.html @@ -29,6 +29,9 @@ V2 + + Detection Rules + Documentation diff --git a/vulnerabilities/views.py b/vulnerabilities/views.py index 3aff06768..478758f14 100644 --- a/vulnerabilities/views.py +++ b/vulnerabilities/views.py @@ -37,6 +37,7 @@ from vulnerabilities.forms import AdminLoginForm from vulnerabilities.forms import AdvisorySearchForm from vulnerabilities.forms import ApiUserCreationForm +from vulnerabilities.forms import DetectionRuleSearchForm from vulnerabilities.forms import PackageSearchForm from vulnerabilities.forms import PipelineSchedulePackageForm from vulnerabilities.forms import VulnerabilitySearchForm @@ -946,6 +947,44 @@ def get_queryset(self): ) +class DetectionRuleSearch(ListView): + model = models.DetectionRule + template_name = "detection_rules.html" + paginate_by = PAGE_SIZE + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + request_query = self.request.GET + context["detection_search_form"] = DetectionRuleSearchForm(request_query) + page_obj = context["page_obj"] + context["elided_page_range"] = page_obj.paginator.get_elided_page_range( + page_obj.number, on_each_side=2, on_ends=1 + ) + return context + + def get_queryset(self): + advisories_prefetch = Prefetch( + "related_advisories", queryset=AdvisoryV2.objects.only("id", "avid") + ) + + queryset = super().get_queryset().prefetch_related(advisories_prefetch) + form = DetectionRuleSearchForm(self.request.GET) + if form.is_valid(): + rule_type = form.cleaned_data.get("rule_type") + advisory_avid = form.cleaned_data.get("advisory_avid") + rule_text = form.cleaned_data.get("rule_text_contains") + + if rule_type: + queryset = queryset.filter(rule_type=rule_type) + + if advisory_avid: + queryset = queryset.filter(related_advisories__avid=advisory_avid) + + if rule_text: + queryset = queryset.filter(rule_text__icontains=rule_text) + return queryset + + class PipelineScheduleListView(VulnerableCodeListView, FormMixin): model = PipelineSchedule context_object_name = "schedule_list" diff --git a/vulnerablecode/urls.py b/vulnerablecode/urls.py index eb1bc006b..2f2d94927 100644 --- a/vulnerablecode/urls.py +++ b/vulnerablecode/urls.py @@ -27,6 +27,7 @@ from vulnerabilities.api_v2 import VulnerabilityV2ViewSet from vulnerabilities.api_v3 import AdvisoryV3ViewSet from vulnerabilities.api_v3 import AffectedByAdvisoriesViewSet +from vulnerabilities.api_v3 import DetectionRuleViewSet from vulnerabilities.api_v3 import FixingAdvisoriesViewSet from vulnerabilities.api_v3 import PackageV3ViewSet from vulnerabilities.views import AdminLoginView @@ -34,6 +35,7 @@ from vulnerabilities.views import AdvisoryPackagesDetails from vulnerabilities.views import AffectedByAdvisoriesListView from vulnerabilities.views import ApiUserCreateView +from vulnerabilities.views import DetectionRuleSearch from vulnerabilities.views import FixingAdvisoriesListView from vulnerabilities.views import HomePage from vulnerabilities.views import HomePageV2 @@ -81,6 +83,8 @@ def __init__(self, *args, **kwargs): ) api_v3_router.register("fixing-advisories", FixingAdvisoriesViewSet, basename="fixing-advisories") +api_v3_router.register("detection-rules", DetectionRuleViewSet, basename="detection-rule") + urlpatterns = [ path("admin/login/", AdminLoginView.as_view(), name="admin-login"), path("api/v2/", include(api_v2_router.urls)), @@ -124,6 +128,11 @@ def __init__(self, *args, **kwargs): AdvisoryDetails.as_view(), name="advisory_details", ), + path( + "rules/search/", + DetectionRuleSearch.as_view(), + name="detection_rule_search", + ), path( "packages/search/", PackageSearch.as_view(),