Skip to content

Commit c5087aa

Browse files
committed
add a manage_triage_rulesets action on Product API endpoint
Signed-off-by: tdruez <tdruez@aboutcode.org>
1 parent fa83063 commit c5087aa

2 files changed

Lines changed: 169 additions & 0 deletions

File tree

product_portfolio/api.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
#
88

99
from django.core.exceptions import ValidationError
10+
from django.db import transaction
11+
from django.shortcuts import get_object_or_404
1012

1113
import django_filters
1214
from rest_framework import permissions
@@ -51,7 +53,11 @@
5153
from product_portfolio.models import ProductPolicyViolation
5254
from product_portfolio.models import ScanCodeProject
5355
from vulnerabilities.api import VulnerabilityAnalysisSerializer
56+
from vulnerabilities.triage.engine import delete_triage_records_for_assignment
57+
from vulnerabilities.triage.engine import reevaluate_product_rulesets
58+
from vulnerabilities.triage.models import ProductTriageRuleset
5459
from vulnerabilities.triage.models import TriageRecord
60+
from vulnerabilities.triage.models import TriageRuleset
5561

5662
base_extra_kwargs = {
5763
"licenses": {
@@ -382,6 +388,14 @@ class Meta:
382388
)
383389

384390

391+
class TriageRulesetAssignmentSerializer(serializers.Serializer):
392+
uuid = serializers.UUIDField(read_only=True)
393+
name = serializers.CharField(read_only=True)
394+
recommended_action = serializers.CharField(read_only=True)
395+
precedence = serializers.IntegerField(read_only=True)
396+
assigned = serializers.BooleanField(read_only=True)
397+
398+
385399
class ProductViewSet(
386400
ObjectPermissionsMixin,
387401
SendAboutFilesMixin,
@@ -470,6 +484,59 @@ def triage_records(self, request, uuid):
470484
serializer = TriageRecordSerializer(records, many=True)
471485
return Response(serializer.data)
472486

487+
@action(detail=True, methods=["get", "post"], url_path="manage_triage_rulesets")
488+
def manage_triage_rulesets(self, request, uuid):
489+
"""
490+
GET: list every enabled triage ruleset in this product's dataspace, each flagged
491+
with whether it is currently assigned to this product.
492+
493+
POST: assign or unassign a single ruleset for this product.
494+
Body: {"ruleset": "<uuid>", "assigned": true}
495+
"""
496+
product = self.get_object()
497+
498+
if request.method == "POST":
499+
if not isinstance(request.data, dict):
500+
return Response(
501+
{"error": "Expected a JSON object with 'ruleset' and 'assigned'."},
502+
status=status.HTTP_400_BAD_REQUEST,
503+
)
504+
ruleset_uuid = request.data.get("ruleset")
505+
assigned = request.data.get("assigned")
506+
if ruleset_uuid is None or assigned is None:
507+
return Response(
508+
{"error": "Both 'ruleset' and 'assigned' are required."},
509+
status=status.HTTP_400_BAD_REQUEST,
510+
)
511+
ruleset = get_object_or_404(
512+
TriageRuleset.objects.scope(product.dataspace).filter(enabled=True),
513+
uuid=ruleset_uuid,
514+
)
515+
with transaction.atomic():
516+
assignment = ProductTriageRuleset.objects.filter(
517+
product=product, ruleset=ruleset
518+
).first()
519+
if assigned and not assignment:
520+
ProductTriageRuleset.objects.create(
521+
product=product, ruleset=ruleset, dataspace=product.dataspace
522+
)
523+
elif not assigned and assignment:
524+
assignment.delete()
525+
delete_triage_records_for_assignment(ruleset=ruleset, product=product)
526+
reevaluate_product_rulesets(product)
527+
return Response(status=status.HTTP_200_OK)
528+
529+
assigned_ruleset_ids = set(
530+
product.product_triage_rulesets.values_list("ruleset_id", flat=True)
531+
)
532+
rulesets = TriageRuleset.objects.filter(dataspace=product.dataspace, enabled=True).order_by(
533+
"-precedence", "name"
534+
)
535+
for ruleset in rulesets:
536+
ruleset.assigned = ruleset.id in assigned_ruleset_ids
537+
serializer = TriageRulesetAssignmentSerializer(rulesets, many=True)
538+
return Response(serializer.data)
539+
473540
@action(detail=True, methods=["post"], serializer_class=LoadSBOMsFormSerializer)
474541
def load_sboms(self, request, *args, **kwargs):
475542
"""

product_portfolio/tests/test_api.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
from vulnerabilities.tests import make_vulnerability
5353
from vulnerabilities.tests import make_vulnerability_analysis
5454
from vulnerabilities.triage.engine import evaluate_ruleset
55+
from vulnerabilities.triage.models import ProductTriageRuleset
5556
from vulnerabilities.triage.models import TriageAction
5657
from vulnerabilities.triage.tests import make_product_triage_ruleset
5758
from vulnerabilities.triage.tests import make_triage_ruleset
@@ -801,6 +802,107 @@ def test_api_product_endpoint_triage_records_action(self):
801802
self.assertIn("detected_date", entry)
802803
self.assertIn("last_checked", entry)
803804

805+
def test_api_product_endpoint_manage_triage_rulesets_get(self):
806+
url = reverse("api_v2:product-manage-triage-rulesets", args=[self.product1.uuid])
807+
self.client.login(username=self.base_user.username, password="secret")
808+
response = self.client.get(url)
809+
self.assertEqual(status.HTTP_404_NOT_FOUND, response.status_code)
810+
811+
add_perm(self.base_user, "add_product")
812+
assign_perm("view_product", self.base_user, self.product1)
813+
814+
assigned_ruleset = make_triage_ruleset(self.dataspace, name="Assigned Ruleset")
815+
make_product_triage_ruleset(self.product1, ruleset=assigned_ruleset)
816+
unassigned_ruleset = make_triage_ruleset(self.dataspace, name="Unassigned Ruleset")
817+
make_triage_ruleset(self.dataspace, name="Disabled Ruleset", enabled=False)
818+
819+
response = self.client.get(url)
820+
self.assertEqual(status.HTTP_200_OK, response.status_code)
821+
self.assertEqual(2, len(response.data))
822+
entries_by_name = {entry["name"]: entry for entry in response.data}
823+
self.assertTrue(entries_by_name[assigned_ruleset.name]["assigned"])
824+
self.assertFalse(entries_by_name[unassigned_ruleset.name]["assigned"])
825+
self.assertNotIn("Disabled Ruleset", entries_by_name)
826+
827+
def test_api_product_endpoint_manage_triage_rulesets_post_assigns(self):
828+
url = reverse("api_v2:product-manage-triage-rulesets", args=[self.product1.uuid])
829+
self.client.login(username=self.base_user.username, password="secret")
830+
add_perm(self.base_user, "add_product")
831+
assign_perm("view_product", self.base_user, self.product1)
832+
assign_perm("change_product", self.base_user, self.product1)
833+
834+
package = make_package(self.dataspace)
835+
make_product_package(self.product1, package=package)
836+
vulnerability = make_vulnerability(self.dataspace, affecting=package, risk_score=9.0)
837+
ruleset = make_triage_ruleset(
838+
self.dataspace,
839+
recommended_action=TriageAction.UPGRADE,
840+
rules_config={"risk_score": {"is_active": True, "min_risk_score": 8.0}},
841+
)
842+
843+
data = {"ruleset": str(ruleset.uuid), "assigned": True}
844+
response = self.client.post(url, data=data, content_type="application/json")
845+
846+
self.assertEqual(status.HTTP_200_OK, response.status_code)
847+
self.assertTrue(
848+
ProductTriageRuleset.objects.filter(product=self.product1, ruleset=ruleset).exists()
849+
)
850+
triage_record = self.product1.triage_records.get()
851+
self.assertEqual(vulnerability, triage_record.vulnerability)
852+
853+
def test_api_product_endpoint_manage_triage_rulesets_post_unassigns(self):
854+
url = reverse("api_v2:product-manage-triage-rulesets", args=[self.product1.uuid])
855+
self.client.login(username=self.base_user.username, password="secret")
856+
add_perm(self.base_user, "add_product")
857+
assign_perm("view_product", self.base_user, self.product1)
858+
assign_perm("change_product", self.base_user, self.product1)
859+
860+
ruleset = make_triage_ruleset(self.dataspace)
861+
make_product_triage_ruleset(self.product1, ruleset=ruleset)
862+
863+
data = {"ruleset": str(ruleset.uuid), "assigned": False}
864+
response = self.client.post(url, data=data, content_type="application/json")
865+
866+
self.assertEqual(status.HTTP_200_OK, response.status_code)
867+
self.assertFalse(
868+
ProductTriageRuleset.objects.filter(product=self.product1, ruleset=ruleset).exists()
869+
)
870+
871+
def test_api_product_endpoint_manage_triage_rulesets_post_requires_both_fields(self):
872+
url = reverse("api_v2:product-manage-triage-rulesets", args=[self.product1.uuid])
873+
self.client.login(username=self.base_user.username, password="secret")
874+
add_perm(self.base_user, "add_product")
875+
assign_perm("view_product", self.base_user, self.product1)
876+
assign_perm("change_product", self.base_user, self.product1)
877+
878+
response = self.client.post(url, data={}, content_type="application/json")
879+
880+
self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code)
881+
882+
def test_api_product_endpoint_manage_triage_rulesets_post_rejects_non_dict_body(self):
883+
url = reverse("api_v2:product-manage-triage-rulesets", args=[self.product1.uuid])
884+
self.client.login(username=self.base_user.username, password="secret")
885+
add_perm(self.base_user, "add_product")
886+
assign_perm("view_product", self.base_user, self.product1)
887+
assign_perm("change_product", self.base_user, self.product1)
888+
889+
response = self.client.post(url, data=[], content_type="application/json")
890+
891+
self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code)
892+
893+
def test_api_product_endpoint_manage_triage_rulesets_post_rejects_disabled_ruleset(self):
894+
url = reverse("api_v2:product-manage-triage-rulesets", args=[self.product1.uuid])
895+
self.client.login(username=self.base_user.username, password="secret")
896+
add_perm(self.base_user, "add_product")
897+
assign_perm("view_product", self.base_user, self.product1)
898+
assign_perm("change_product", self.base_user, self.product1)
899+
900+
ruleset = make_triage_ruleset(self.dataspace, enabled=False)
901+
data = {"ruleset": str(ruleset.uuid), "assigned": True}
902+
response = self.client.post(url, data=data, content_type="application/json")
903+
904+
self.assertEqual(status.HTTP_404_NOT_FOUND, response.status_code)
905+
804906

805907
class ProductRelatedAPITestCase(TestCase):
806908
def setUp(self):

0 commit comments

Comments
 (0)