Skip to content

Commit d1a7cc1

Browse files
committed
Add Live Evaluation API endpoint #1902
* Add a new API endpoint to run live evaluation importers * Add tests for the live evaluation API endpoint Signed-off-by: Michael Ehab Mikhail <michael.ehab@hotmail.com>
1 parent bd5c1f2 commit d1a7cc1

3 files changed

Lines changed: 151 additions & 0 deletions

File tree

vulnerabilities/api_v2.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
#
99

1010

11+
from concurrent.futures import ThreadPoolExecutor
12+
from concurrent.futures import as_completed
13+
1114
from django.db.models import Prefetch
1215
from django_filters import rest_framework as filters
1316
from drf_spectacular.utils import OpenApiParameter
@@ -25,6 +28,7 @@
2528
from rest_framework.reverse import reverse
2629
from rest_framework.throttling import AnonRateThrottle
2730

31+
from vulnerabilities.importers import LIVE_IMPORTERS_REGISTRY
2832
from vulnerabilities.models import AdvisoryReference
2933
from vulnerabilities.models import AdvisorySeverity
3034
from vulnerabilities.models import AdvisoryV2
@@ -1225,3 +1229,83 @@ def lookup(self, request):
12251229
return Response(
12261230
AdvisoryPackageV2Serializer(qs, many=True, context={"request": request}).data
12271231
)
1232+
1233+
1234+
class LiveEvaluationSerializer(serializers.Serializer):
1235+
purl_string = serializers.CharField(help_text="PackageURL to evaluate")
1236+
no_threading = serializers.BooleanField(required=False, default=False)
1237+
1238+
1239+
class LiveEvaluationViewSet(viewsets.GenericViewSet):
1240+
serializer_class = LiveEvaluationSerializer
1241+
1242+
@extend_schema(
1243+
request=LiveEvaluationSerializer,
1244+
responses={
1245+
202: {"description": "Live evaluation done successfully"},
1246+
400: {"description": "Invalid request"},
1247+
500: {"description": "Internal server error"},
1248+
},
1249+
)
1250+
@action(detail=False, methods=["post"])
1251+
def evaluate(self, request):
1252+
serializer = self.get_serializer(data=request.data)
1253+
if not serializer.is_valid():
1254+
return Response(
1255+
serializer.errors,
1256+
status=status.HTTP_400_BAD_REQUEST,
1257+
)
1258+
1259+
purl_string = serializer.validated_data.get("purl_string")
1260+
no_threading = serializer.validated_data.get("no_threading", False)
1261+
1262+
try:
1263+
purl = PackageURL.from_string(purl_string) if purl_string else None
1264+
if not purl:
1265+
return Response({"error": "Invalid PackageURL"}, status=status.HTTP_400_BAD_REQUEST)
1266+
except Exception as e:
1267+
return Response(
1268+
{"error": f"Invalid PackageURL: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST
1269+
)
1270+
1271+
importers = [
1272+
importer
1273+
for importer in LIVE_IMPORTERS_REGISTRY.values()
1274+
if hasattr(importer, "supported_types")
1275+
and purl.type in getattr(importer, "supported_types", [])
1276+
]
1277+
1278+
if not importers:
1279+
return Response(
1280+
{"error": f"No live importers found for purl type '{purl.type}'"},
1281+
status=status.HTTP_400_BAD_REQUEST,
1282+
)
1283+
1284+
results = []
1285+
1286+
def run_importer(importer):
1287+
importer_name = getattr(importer, "pipeline_id", importer.__name__)
1288+
response_data = {"importer": importer_name, "purl": purl_string, "steps_completed": []}
1289+
try:
1290+
pipeline_instance = importer(purl=purl)
1291+
status_code, error = pipeline_instance.execute()
1292+
if status_code != 0:
1293+
response_data["error"] = f"Importer {importer_name} failed: {error}"
1294+
else:
1295+
response_data["steps_completed"].append("import")
1296+
except Exception as e:
1297+
response_data["error"] = f"Error running importer {importer_name}: {str(e)}"
1298+
return response_data
1299+
1300+
if not no_threading and len(importers) > 1:
1301+
with ThreadPoolExecutor(max_workers=len(importers)) as executor:
1302+
future_to_importer = {
1303+
executor.submit(run_importer, importer): importer for importer in importers
1304+
}
1305+
for future in as_completed(future_to_importer):
1306+
results.append(future.result())
1307+
else:
1308+
for importer in importers:
1309+
results.append(run_importer(importer))
1310+
1311+
return Response(results, status=status.HTTP_202_ACCEPTED)

vulnerabilities/tests/test_api_v2.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -905,3 +905,68 @@ def test_get_all_vulnerable_purls(self):
905905
response = self.client.get(url)
906906
assert response.status_code == 200
907907
assert "pkg:pypi/sample@1.0.0" in response.data
908+
909+
910+
class LiveEvaluationAPITest(APITestCase):
911+
def setUp(self):
912+
self.client = APIClient(enforce_csrf_checks=True)
913+
self.url = "/api/v2/live-evaluation/evaluate"
914+
915+
@patch("vulnerabilities.api_v2.LIVE_IMPORTERS_REGISTRY")
916+
def test_evaluate_success(self, mock_registry):
917+
class MockImporter:
918+
pipeline_id = "dummy"
919+
supported_types = ["pypi"]
920+
921+
def __init__(self, purl=None):
922+
pass
923+
924+
def execute(self):
925+
return 0, None
926+
927+
mock_registry.values.return_value = [MockImporter]
928+
data = {"purl_string": "pkg:pypi/django@3.2"}
929+
response = self.client.post(self.url, data, format="json")
930+
assert response.status_code == 202
931+
assert isinstance(response.data, list)
932+
assert response.data[0]["importer"] == "dummy"
933+
assert response.data[0]["purl"] == "pkg:pypi/django@3.2"
934+
assert "steps_completed" in response.data[0]
935+
assert "import" in response.data[0]["steps_completed"]
936+
937+
@patch("vulnerabilities.api_v2.LIVE_IMPORTERS_REGISTRY")
938+
def test_evaluate_no_importer_found(self, mock_registry):
939+
class MockImporter:
940+
pipeline_id = "dummy"
941+
supported_types = ["npm"]
942+
943+
mock_registry.values.return_value = [MockImporter]
944+
data = {"purl_string": "pkg:pypi/django@3.2"}
945+
response = self.client.post(self.url, data, format="json")
946+
assert response.status_code == 400
947+
assert "No live importers found" in response.data["error"]
948+
949+
def test_evaluate_invalid_purl(self):
950+
data = {"purl_string": "not_a_valid_purl"}
951+
response = self.client.post(self.url, data, format="json")
952+
assert response.status_code == 400
953+
assert "Invalid PackageURL" in response.data["error"]
954+
955+
@patch("vulnerabilities.api_v2.LIVE_IMPORTERS_REGISTRY")
956+
def test_evaluate_no_threading(self, mock_registry):
957+
class MockImporter:
958+
pipeline_id = "dummy"
959+
supported_types = ["pypi"]
960+
961+
def __init__(self, purl=None):
962+
pass
963+
964+
def execute(self):
965+
return 0, None
966+
967+
mock_registry.values.return_value = [MockImporter]
968+
data = {"purl_string": "pkg:pypi/django@3.2", "no_threading": True}
969+
response = self.client.post(self.url, data, format="json")
970+
assert response.status_code == 202
971+
assert isinstance(response.data, list)
972+
assert response.data[0]["importer"] == "dummy"

vulnerablecode/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from vulnerabilities.api_v2 import AdvisoriesPackageV2ViewSet
2424
from vulnerabilities.api_v2 import CodeFixV2ViewSet
2525
from vulnerabilities.api_v2 import CodeFixViewSet
26+
from vulnerabilities.api_v2 import LiveEvaluationViewSet
2627
from vulnerabilities.api_v2 import PackageV2ViewSet
2728
from vulnerabilities.api_v2 import PipelineScheduleV2ViewSet
2829
from vulnerabilities.api_v2 import VulnerabilityV2ViewSet
@@ -69,6 +70,7 @@ def __init__(self, *args, **kwargs):
6970
api_v2_router.register("codefixes", CodeFixViewSet, basename="codefix")
7071
api_v2_router.register("pipelines", PipelineScheduleV2ViewSet, basename="pipelines")
7172
api_v2_router.register("advisory-codefixes", CodeFixV2ViewSet, basename="advisory-codefix")
73+
api_v2_router.register("live-evaluation", LiveEvaluationViewSet, basename="live-evaluation")
7274

7375

7476
urlpatterns = [

0 commit comments

Comments
 (0)