From ea9db15bb1aaaff40681de76cd5b94fce56b53f9 Mon Sep 17 00:00:00 2001 From: Tushar Goel Date: Thu, 3 Jul 2025 19:18:09 +0530 Subject: [PATCH 1/4] Add advisory codefix V2 URL Signed-off-by: Tushar Goel --- CHANGELOG.rst | 9 ++ vulnerabilities/api_v2.py | 83 +++++++++++- vulnerabilities/models.py | 24 +++- .../templates/package_details_v2.html | 4 +- vulnerabilities/tests/test_api_v2.py | 120 ++++++++++++++++++ vulnerabilities/views.py | 4 +- vulnerablecode/urls.py | 4 +- 7 files changed, 235 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e138ebef4..f8dc1a351 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,15 @@ Release notes ============= +Version v37.0.0 +--------------------- + +- This is a major version, this version introduces Advisory level details. +- We have added new models AdvisoryV2, AdvisoryAlias, AdvisoryReference, AdvisorySeverity, AdvisoryWeakness, PackageV2 and CodeFixV2. +- We are using ``avid`` as an internal advisory ID for uniquely identifying advisories. +- We have a new route ``/v2`` which only support package search which has information on packages that are reported to be affected or fixing by advisories. +- This version introduces ``/api/v2/advisories-packages`` which has information on packages that are reported to be affected or fixing by advisories. + Version v36.1.3 --------------------- diff --git a/vulnerabilities/api_v2.py b/vulnerabilities/api_v2.py index d5bae0914..aa26e5f39 100644 --- a/vulnerabilities/api_v2.py +++ b/vulnerabilities/api_v2.py @@ -334,7 +334,7 @@ def get_affected_by_vulnerabilities(self, obj): # Get code fixed for a vulnerability code_fixes = CodeFixV2.objects.filter(advisory=adv).distinct() code_fix_urls = [ - reverse("codefix-detail", args=[code_fix.id], request=request) + reverse("advisory-codefix-detail", args=[code_fix.id], request=request) for code_fix in code_fixes ] @@ -718,6 +718,58 @@ class Meta: read_only_fields = ["created_at", "updated_at"] +class CodeFixV2Serializer(serializers.ModelSerializer): + """ + Serializer for the CodeFix model. + Provides detailed information about a code fix. + """ + + affected_advisory_id = serializers.CharField( + source="advisory.avid", + read_only=True, + help_text="ID of the advisory affecting the package.", + ) + affected_package_purl = serializers.CharField( + source="affected_package.package_url", + read_only=True, + help_text="PURL of the affected package.", + ) + fixed_package_purl = serializers.CharField( + source="fixed_package.package_url", + read_only=True, + help_text="PURL of the fixing package (if available).", + ) + created_at = serializers.DateTimeField( + format="%Y-%m-%dT%H:%M:%SZ", + read_only=True, + help_text="Timestamp when the code fix was created.", + ) + updated_at = serializers.DateTimeField( + format="%Y-%m-%dT%H:%M:%SZ", + read_only=True, + help_text="Timestamp when the code fix was last updated.", + ) + + class Meta: + model = CodeFixV2 + fields = [ + "id", + "commits", + "pulls", + "downloads", + "patch", + "affected_advisory_id", + "affected_package_purl", + "fixed_package_purl", + "notes", + "references", + "is_reviewed", + "created_at", + "updated_at", + ] + read_only_fields = ["created_at", "updated_at"] + + class CodeFixViewSet(viewsets.ReadOnlyModelViewSet): """ API endpoint that allows viewing CodeFix entries. @@ -740,6 +792,25 @@ def get_queryset(self): return queryset +class CodeFixV2ViewSet(viewsets.ReadOnlyModelViewSet): + """ + API endpoint that allows viewing CodeFix entries. + """ + + queryset = CodeFixV2.objects.all() + serializer_class = CodeFixV2Serializer + + def get_queryset(self): + """ + Optionally filter by vulnerability ID. + """ + queryset = super().get_queryset() + advisory_id = self.request.query_params.get("advisory_id") + if advisory_id: + queryset = queryset.filter(advisory__avid=advisory_id) + return queryset + + class CreateListRetrieveUpdateViewSet( mixins.CreateModelMixin, mixins.ListModelMixin, @@ -1061,10 +1132,10 @@ def bulk_search(self, request): # Collect vulnerabilities associated with these packages advisories = set() for package in packages: - advisories.update(package.affected_by_vulnerabilities.all()) - advisories.update(package.fixing_vulnerabilities.all()) + advisories.update(package.affected_by_advisories.all()) + advisories.update(package.fixing_advisories.all()) - advisory_data = {adv.avid: VulnerabilityV2Serializer(adv).data for adv in advisories} + advisory_data = {adv.avid: AdvisoryV2Serializer(adv).data for adv in advisories} if not purl_only: package_data = AdvisoryPackageV2Serializer( @@ -1089,8 +1160,8 @@ def bulk_search(self, request): # Collect vulnerabilities associated with these packages advisories = set() for package in packages: - advisories.update(package.affected_by_vulnerabilities.all()) - advisories.update(package.fixing_vulnerabilities.all()) + advisories.update(package.affected_by_advisories.all()) + advisories.update(package.fixing_advisories.all()) advisory_data = {adv.advisory_id: AdvisoryV2Serializer(adv).data for adv in advisories} diff --git a/vulnerabilities/models.py b/vulnerabilities/models.py index 77781b055..ba7b4215e 100644 --- a/vulnerabilities/models.py +++ b/vulnerabilities/models.py @@ -222,6 +222,14 @@ class VulnerabilityStatusType(models.IntegerChoices): INVALID = 3, "Invalid" +class AdvisoryStatusType(models.IntegerChoices): + """List of vulnerability statuses.""" + + PUBLISHED = 1, "Published" + DISPUTED = 2, "Disputed" + INVALID = 3, "Invalid" + + # FIXME: Remove when migration from Vulnerability to Advisory is completed class Vulnerability(models.Model): """ @@ -2738,7 +2746,7 @@ class AdvisoryV2(models.Model): ) status = models.IntegerField( - choices=VulnerabilityStatusType.choices, default=VulnerabilityStatusType.PUBLISHED + choices=AdvisoryStatusType.choices, default=AdvisoryStatusType.PUBLISHED ) exploitability = models.DecimalField( @@ -2789,7 +2797,7 @@ def get_absolute_url(self): """ Return this Vulnerability details absolute URL. """ - return reverse("advisory_details", args=[self.id]) + return reverse("advisory_details", args=[self.avid]) def to_advisory_data(self) -> "AdvisoryDataV2": from vulnerabilities.importer import AdvisoryDataV2 @@ -2975,6 +2983,12 @@ def _vulnerable(self, vulnerable=True): """ return self.with_is_vulnerable().filter(is_vulnerable=vulnerable) + def vulnerable(self): + """ + Return only packages that are vulnerable. + """ + return self.filter(affected_by_advisories__isnull=False) + def with_is_vulnerable(self): """ Annotate Package with ``is_vulnerable`` boolean attribute. @@ -2983,6 +2997,12 @@ def with_is_vulnerable(self): is_vulnerable=Exists(AdvisoryV2.objects.filter(affecting_packages__pk=OuterRef("pk"))) ) + def from_purl(self, purl: Union[PackageURL, str]): + """ + Return a new Package given a ``purl`` PackageURL object or PURL string. + """ + return PackageV2.objects.create(**purl_to_dict(purl=purl)) + class PackageV2(PackageURLMixin): """ diff --git a/vulnerabilities/templates/package_details_v2.html b/vulnerabilities/templates/package_details_v2.html index 54cb8ffed..be0af62fe 100644 --- a/vulnerabilities/templates/package_details_v2.html +++ b/vulnerabilities/templates/package_details_v2.html @@ -146,7 +146,7 @@ {% for advisory in affected_by_advisories %} - + {{advisory.avid }}
@@ -267,7 +267,7 @@ {% for advisory in fixing_advisories %} - + {{advisory.avid }} diff --git a/vulnerabilities/tests/test_api_v2.py b/vulnerabilities/tests/test_api_v2.py index 13907b787..923cb95b2 100644 --- a/vulnerabilities/tests/test_api_v2.py +++ b/vulnerabilities/tests/test_api_v2.py @@ -19,9 +19,12 @@ from vulnerabilities.api_v2 import PackageV2Serializer from vulnerabilities.api_v2 import VulnerabilityListSerializer +from vulnerabilities.models import AdvisoryV2 from vulnerabilities.models import Alias from vulnerabilities.models import ApiUser +from vulnerabilities.models import CodeFixV2 from vulnerabilities.models import Package +from vulnerabilities.models import PackageV2 from vulnerabilities.models import PipelineRun from vulnerabilities.models import PipelineSchedule from vulnerabilities.models import Vulnerability @@ -782,3 +785,120 @@ def test_schedule_update_with_staff_session_permitted(self, mock_create_new_job) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertNotEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(self.schedule1.run_interval, 2) + + +class CodeFixV2APITest(APITestCase): + def setUp(self): + self.advisory = AdvisoryV2.objects.create( + datasource_id="test_source", + advisory_id="TEST-2025-001", + avid="test_source/TEST-2025-001", + unique_content_id="a" * 64, + url="https://example.com/advisory", + date_collected="2025-07-01T00:00:00Z", + ) + + self.affected_package = PackageV2.objects.from_purl(purl="pkg:pypi/affected_package@1.0.0") + self.fixed_package = PackageV2.objects.from_purl(purl="pkg:pypi/fixed_package@1.0.1") + + self.codefix = CodeFixV2.objects.create( + advisory=self.advisory, + affected_package=self.affected_package, + fixed_package=self.fixed_package, + notes="Security patch", + is_reviewed=True, + ) + self.user = ApiUser.objects.create_api_user(username="e@mail.com") + self.auth = f"Token {self.user.auth_token.key}" + self.client = APIClient(enforce_csrf_checks=True) + self.client.credentials(HTTP_AUTHORIZATION=self.auth) + + self.url = reverse("advisory-codefix-list") + + def test_list_all_codefixes(self): + response = self.client.get(self.url) + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == 1 + assert response.data["results"][0]["affected_advisory_id"] == self.advisory.avid + + def test_filter_codefix_by_advisory_id_success(self): + response = self.client.get(self.url, {"advisory_id": self.advisory.avid}) + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == 1 + assert response.data["results"][0]["affected_advisory_id"] == self.advisory.avid + + def test_filter_codefix_by_advisory_id_not_found(self): + response = self.client.get(self.url, {"advisory_id": "nonexistent/ADVISORY-ID"}) + assert response.status_code == status.HTTP_200_OK + assert response.data["count"] == 0 + + +class AdvisoriesPackageV2Tests(APITestCase): + def setUp(self): + self.advisory = AdvisoryV2.objects.create( + datasource_id="ghsa", + advisory_id="GHSA-1234", + avid="ghsa/GHSA-1234", + unique_content_id="f" * 64, + url="https://example.com/advisory", + date_collected="2025-07-01T00:00:00Z", + ) + + self.package = PackageV2.objects.from_purl(purl="pkg:pypi/sample@1.0.0") + + self.user = ApiUser.objects.create_api_user(username="e@mail.com") + self.auth = f"Token {self.user.auth_token.key}" + self.client = APIClient(enforce_csrf_checks=True) + self.client.credentials(HTTP_AUTHORIZATION=self.auth) + + self.package.affected_by_advisories.add(self.advisory) + self.package.save() + + def test_list_with_purl_filter(self): + url = reverse("advisories-package-v2-list") + with self.assertNumQueries(16): + response = self.client.get(url, {"purl": "pkg:pypi/sample@1.0.0"}) + assert response.status_code == 200 + assert "packages" in response.data["results"] + assert "advisories" in response.data["results"] + assert self.advisory.avid in response.data["results"]["advisories"] + + def test_bulk_lookup(self): + url = reverse("advisories-package-v2-bulk-lookup") + with self.assertNumQueries(11): + response = self.client.post(url, {"purls": ["pkg:pypi/sample@1.0.0"]}, format="json") + assert response.status_code == 200 + assert "packages" in response.data + assert "advisories" in response.data + assert self.advisory.avid in response.data["advisories"] + + def test_bulk_search_plain(self): + url = reverse("advisories-package-v2-bulk-search") + payload = {"purls": ["pkg:pypi/sample@1.0.0"], "plain_purl": True, "purl_only": False} + with self.assertNumQueries(11): + response = self.client.post(url, payload, format="json") + assert response.status_code == 200 + assert "packages" in response.data + assert "advisories" in response.data + + def test_bulk_search_purl_only(self): + url = reverse("advisories-package-v2-bulk-search") + payload = {"purls": ["pkg:pypi/sample@1.0.0"], "plain_purl": False, "purl_only": True} + with self.assertNumQueries(11): + response = self.client.post(url, payload, format="json") + assert response.status_code == 200 + assert "pkg:pypi/sample@1.0.0" in response.data + + def test_lookup_single_package(self): + url = reverse("advisories-package-v2-lookup") + with self.assertNumQueries(9): + response = self.client.post(url, {"purl": "pkg:pypi/sample@1.0.0"}, format="json") + assert response.status_code == 200 + assert any(pkg["purl"] == "pkg:pypi/sample@1.0.0" for pkg in response.data) + + def test_get_all_vulnerable_purls(self): + url = reverse("advisories-package-v2-all") + with self.assertNumQueries(4): + response = self.client.get(url) + assert response.status_code == 200 + assert "pkg:pypi/sample@1.0.0" in response.data diff --git a/vulnerabilities/views.py b/vulnerabilities/views.py index 079472570..6d9b7ea87 100644 --- a/vulnerabilities/views.py +++ b/vulnerabilities/views.py @@ -325,8 +325,8 @@ def get_context_data(self, **kwargs): class AdvisoryDetails(DetailView): model = models.AdvisoryV2 template_name = "advisory_detail.html" - slug_url_kwarg = "id" - slug_field = "id" + slug_url_kwarg = "avid" + slug_field = "avid" def get_queryset(self): return ( diff --git a/vulnerablecode/urls.py b/vulnerablecode/urls.py index c15ee928c..cb9e318eb 100644 --- a/vulnerablecode/urls.py +++ b/vulnerablecode/urls.py @@ -21,6 +21,7 @@ from vulnerabilities.api import PackageViewSet from vulnerabilities.api import VulnerabilityViewSet from vulnerabilities.api_v2 import AdvisoriesPackageV2ViewSet +from vulnerabilities.api_v2 import CodeFixV2ViewSet from vulnerabilities.api_v2 import CodeFixViewSet from vulnerabilities.api_v2 import PackageV2ViewSet from vulnerabilities.api_v2 import PipelineScheduleV2ViewSet @@ -67,6 +68,7 @@ def __init__(self, *args, **kwargs): api_v2_router.register("vulnerabilities", VulnerabilityV2ViewSet, basename="vulnerability-v2") api_v2_router.register("codefixes", CodeFixViewSet, basename="codefix") api_v2_router.register("pipelines", PipelineScheduleV2ViewSet, basename="pipelines") +api_v2_router.register("advisory-codefixes", CodeFixV2ViewSet, basename="advisory-codefix") urlpatterns = [ @@ -102,7 +104,7 @@ def __init__(self, *args, **kwargs): name="home", ), path( - "advisories/", + "advisories/", AdvisoryDetails.as_view(), name="advisory_details", ), From 14f5cd91877f342800b9be6e9311a92c8d9b11aa Mon Sep 17 00:00:00 2001 From: Tushar Goel Date: Thu, 3 Jul 2025 19:21:58 +0530 Subject: [PATCH 2/4] Add num query tests for codefix viewset Signed-off-by: Tushar Goel --- vulnerabilities/tests/test_api_v2.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/vulnerabilities/tests/test_api_v2.py b/vulnerabilities/tests/test_api_v2.py index 923cb95b2..3ac2cff52 100644 --- a/vulnerabilities/tests/test_api_v2.py +++ b/vulnerabilities/tests/test_api_v2.py @@ -816,19 +816,22 @@ def setUp(self): self.url = reverse("advisory-codefix-list") def test_list_all_codefixes(self): - response = self.client.get(self.url) + with self.assertNumQueries(10): + response = self.client.get(self.url) assert response.status_code == status.HTTP_200_OK assert response.data["count"] == 1 assert response.data["results"][0]["affected_advisory_id"] == self.advisory.avid def test_filter_codefix_by_advisory_id_success(self): - response = self.client.get(self.url, {"advisory_id": self.advisory.avid}) + with self.assertNumQueries(10): + response = self.client.get(self.url, {"advisory_id": self.advisory.avid}) assert response.status_code == status.HTTP_200_OK assert response.data["count"] == 1 assert response.data["results"][0]["affected_advisory_id"] == self.advisory.avid def test_filter_codefix_by_advisory_id_not_found(self): - response = self.client.get(self.url, {"advisory_id": "nonexistent/ADVISORY-ID"}) + with self.assertNumQueries(6): + response = self.client.get(self.url, {"advisory_id": "nonexistent/ADVISORY-ID"}) assert response.status_code == status.HTTP_200_OK assert response.data["count"] == 0 From 9a4441fdd1297f5715ed30d0284bf0308035527c Mon Sep 17 00:00:00 2001 From: Tushar Goel Date: Thu, 3 Jul 2025 19:32:28 +0530 Subject: [PATCH 3/4] Add num query tests for codefix viewset Signed-off-by: Tushar Goel --- CHANGELOG.rst | 6 +++++- vulnerabilities/tests/test_api_v2.py | 12 ++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f8dc1a351..8cf967d4a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,11 +4,15 @@ Release notes Version v37.0.0 --------------------- -- This is a major version, this version introduces Advisory level details. +- This is a major version, this version introduces Advisory level details + https://github.com/aboutcode-org/vulnerablecode/issues/1796 + https://github.com/aboutcode-org/vulnerablecode/issues/1393 - We have added new models AdvisoryV2, AdvisoryAlias, AdvisoryReference, AdvisorySeverity, AdvisoryWeakness, PackageV2 and CodeFixV2. - We are using ``avid`` as an internal advisory ID for uniquely identifying advisories. - We have a new route ``/v2`` which only support package search which has information on packages that are reported to be affected or fixing by advisories. - This version introduces ``/api/v2/advisories-packages`` which has information on packages that are reported to be affected or fixing by advisories. +- Pipeline Dashboard improvements #1920. +- Throttle API requests based on user permissions #1909. Version v36.1.3 --------------------- diff --git a/vulnerabilities/tests/test_api_v2.py b/vulnerabilities/tests/test_api_v2.py index 3ac2cff52..432c7c10f 100644 --- a/vulnerabilities/tests/test_api_v2.py +++ b/vulnerabilities/tests/test_api_v2.py @@ -859,7 +859,7 @@ def setUp(self): def test_list_with_purl_filter(self): url = reverse("advisories-package-v2-list") - with self.assertNumQueries(16): + with self.assertNumQueries(18): response = self.client.get(url, {"purl": "pkg:pypi/sample@1.0.0"}) assert response.status_code == 200 assert "packages" in response.data["results"] @@ -868,7 +868,7 @@ def test_list_with_purl_filter(self): def test_bulk_lookup(self): url = reverse("advisories-package-v2-bulk-lookup") - with self.assertNumQueries(11): + with self.assertNumQueries(13): response = self.client.post(url, {"purls": ["pkg:pypi/sample@1.0.0"]}, format="json") assert response.status_code == 200 assert "packages" in response.data @@ -878,7 +878,7 @@ def test_bulk_lookup(self): def test_bulk_search_plain(self): url = reverse("advisories-package-v2-bulk-search") payload = {"purls": ["pkg:pypi/sample@1.0.0"], "plain_purl": True, "purl_only": False} - with self.assertNumQueries(11): + with self.assertNumQueries(13): response = self.client.post(url, payload, format="json") assert response.status_code == 200 assert "packages" in response.data @@ -887,21 +887,21 @@ def test_bulk_search_plain(self): def test_bulk_search_purl_only(self): url = reverse("advisories-package-v2-bulk-search") payload = {"purls": ["pkg:pypi/sample@1.0.0"], "plain_purl": False, "purl_only": True} - with self.assertNumQueries(11): + with self.assertNumQueries(13): response = self.client.post(url, payload, format="json") assert response.status_code == 200 assert "pkg:pypi/sample@1.0.0" in response.data def test_lookup_single_package(self): url = reverse("advisories-package-v2-lookup") - with self.assertNumQueries(9): + with self.assertNumQueries(11): response = self.client.post(url, {"purl": "pkg:pypi/sample@1.0.0"}, format="json") assert response.status_code == 200 assert any(pkg["purl"] == "pkg:pypi/sample@1.0.0" for pkg in response.data) def test_get_all_vulnerable_purls(self): url = reverse("advisories-package-v2-all") - with self.assertNumQueries(4): + with self.assertNumQueries(6): response = self.client.get(url) assert response.status_code == 200 assert "pkg:pypi/sample@1.0.0" in response.data From 405725ad58c84fb2cc6ca4606522d1fa0c383b0d Mon Sep 17 00:00:00 2001 From: Tushar Goel Date: Thu, 3 Jul 2025 19:35:37 +0530 Subject: [PATCH 4/4] Modify CHANGELOG Signed-off-by: Tushar Goel --- CHANGELOG.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8cf967d4a..ba0d0b198 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,12 +7,16 @@ Version v37.0.0 - This is a major version, this version introduces Advisory level details https://github.com/aboutcode-org/vulnerablecode/issues/1796 https://github.com/aboutcode-org/vulnerablecode/issues/1393 + https://github.com/aboutcode-org/vulnerablecode/issues/1883 + https://github.com/aboutcode-org/vulnerablecode/issues/1882 + https://github.com/aboutcode-org/vulnerablecode/pull/1866 - We have added new models AdvisoryV2, AdvisoryAlias, AdvisoryReference, AdvisorySeverity, AdvisoryWeakness, PackageV2 and CodeFixV2. - We are using ``avid`` as an internal advisory ID for uniquely identifying advisories. - We have a new route ``/v2`` which only support package search which has information on packages that are reported to be affected or fixing by advisories. - This version introduces ``/api/v2/advisories-packages`` which has information on packages that are reported to be affected or fixing by advisories. - Pipeline Dashboard improvements #1920. - Throttle API requests based on user permissions #1909. +- Add pipeline to compute Advisory ToDos #1764 Version v36.1.3 ---------------------