diff --git a/vulnerabilities/api_v2.py b/vulnerabilities/api_v2.py
index 6e0ab9213..c9d4d3596 100644
--- a/vulnerabilities/api_v2.py
+++ b/vulnerabilities/api_v2.py
@@ -26,8 +26,11 @@
from rest_framework.reverse import reverse
from rest_framework.throttling import AnonRateThrottle
+from vulnerabilities.models import AdvisoryV2
from vulnerabilities.models import CodeFix
from vulnerabilities.models import CodeFixV2
+from vulnerabilities.models import DetectionRule
+from vulnerabilities.models import ImpactedPackage
from vulnerabilities.models import Package
from vulnerabilities.models import PipelineRun
from vulnerabilities.models import PipelineSchedule
@@ -849,3 +852,36 @@ def get_view_name(self):
if self.detail:
return "Pipeline Instance"
return "Pipeline Jobs"
+
+
+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 = set(advisory.avid for advisory in obj.related_advisories.all())
+ return sorted(list(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
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/models.py b/vulnerabilities/models.py
index 45d8acf55..99d93e764 100644
--- a/vulnerabilities/models.py
+++ b/vulnerabilities/models.py
@@ -3740,3 +3740,43 @@ class GroupedAdvisory(NamedTuple):
weighted_severity: Optional[float]
exploitability: Optional[float]
risk_score: Optional[float]
+
+
+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..e665a8eff
--- /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 %}
+
+
+
+
+
+
+
+
+
+ | Type |
+ Metadata |
+ Text |
+ Source URL |
+ Advisory IDs |
+
+
+
+ {% for detection_rule in page_obj %}
+
+ | {{ 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 %}
+ |
+
+ {% empty %}
+
+ |
+ No detection rules found.
+ |
+
+ {% endfor %}
+
+
+
+
+
+ {% 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 %}
+
+
+
+
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 b984fbb51..a56bedeb6 100644
--- a/vulnerabilities/views.py
+++ b/vulnerabilities/views.py
@@ -36,6 +36,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
@@ -944,6 +945,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..89807447b 100644
--- a/vulnerablecode/urls.py
+++ b/vulnerablecode/urls.py
@@ -22,6 +22,7 @@
from vulnerabilities.api import VulnerabilityViewSet
from vulnerabilities.api_v2 import CodeFixV2ViewSet
from vulnerabilities.api_v2 import CodeFixViewSet
+from vulnerabilities.api_v2 import DetectionRuleViewSet
from vulnerabilities.api_v2 import PackageV2ViewSet
from vulnerabilities.api_v2 import PipelineScheduleV2ViewSet
from vulnerabilities.api_v2 import VulnerabilityV2ViewSet
@@ -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(),