Skip to content

Commit 5639fba

Browse files
committed
Add advisory codefix V2 URL
Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com>
1 parent d8e865f commit 5639fba

File tree

7 files changed

+235
-13
lines changed

7 files changed

+235
-13
lines changed

CHANGELOG.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
Release notes
22
=============
33

4+
Version v37.0.0
5+
---------------------
6+
7+
- This is a major version, this version introduces Advisory level details.
8+
- We have added new models AdvisoryV2, AdvisoryAlias, AdvisoryReference, AdvisorySeverity, AdvisoryWeakness, PackageV2 and CodeFixV2.
9+
- We are using ``avid`` as an internal advisory ID for uniquely identifying advisories.
10+
- 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.
11+
- This version introduces ``/api/v2/advisories-packages`` which has information on packages that are reported to be affected or fixing by advisories.
12+
413
Version v36.1.3
514
---------------------
615

vulnerabilities/api_v2.py

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@ def get_affected_by_vulnerabilities(self, obj):
334334
# Get code fixed for a vulnerability
335335
code_fixes = CodeFixV2.objects.filter(advisory=adv).distinct()
336336
code_fix_urls = [
337-
reverse("codefix-detail", args=[code_fix.id], request=request)
337+
reverse("advisory-codefix-detail", args=[code_fix.id], request=request)
338338
for code_fix in code_fixes
339339
]
340340

@@ -718,6 +718,58 @@ class Meta:
718718
read_only_fields = ["created_at", "updated_at"]
719719

720720

721+
class CodeFixV2Serializer(serializers.ModelSerializer):
722+
"""
723+
Serializer for the CodeFix model.
724+
Provides detailed information about a code fix.
725+
"""
726+
727+
affected_advisory_id = serializers.CharField(
728+
source="advisory.avid",
729+
read_only=True,
730+
help_text="ID of the advisory affecting the package.",
731+
)
732+
affected_package_purl = serializers.CharField(
733+
source="affected_package.package_url",
734+
read_only=True,
735+
help_text="PURL of the affected package.",
736+
)
737+
fixed_package_purl = serializers.CharField(
738+
source="fixed_package.package_url",
739+
read_only=True,
740+
help_text="PURL of the fixing package (if available).",
741+
)
742+
created_at = serializers.DateTimeField(
743+
format="%Y-%m-%dT%H:%M:%SZ",
744+
read_only=True,
745+
help_text="Timestamp when the code fix was created.",
746+
)
747+
updated_at = serializers.DateTimeField(
748+
format="%Y-%m-%dT%H:%M:%SZ",
749+
read_only=True,
750+
help_text="Timestamp when the code fix was last updated.",
751+
)
752+
753+
class Meta:
754+
model = CodeFixV2
755+
fields = [
756+
"id",
757+
"commits",
758+
"pulls",
759+
"downloads",
760+
"patch",
761+
"affected_advisory_id",
762+
"affected_package_purl",
763+
"fixed_package_purl",
764+
"notes",
765+
"references",
766+
"is_reviewed",
767+
"created_at",
768+
"updated_at",
769+
]
770+
read_only_fields = ["created_at", "updated_at"]
771+
772+
721773
class CodeFixViewSet(viewsets.ReadOnlyModelViewSet):
722774
"""
723775
API endpoint that allows viewing CodeFix entries.
@@ -740,6 +792,25 @@ def get_queryset(self):
740792
return queryset
741793

742794

795+
class CodeFixV2ViewSet(viewsets.ReadOnlyModelViewSet):
796+
"""
797+
API endpoint that allows viewing CodeFix entries.
798+
"""
799+
800+
queryset = CodeFixV2.objects.all()
801+
serializer_class = CodeFixV2Serializer
802+
803+
def get_queryset(self):
804+
"""
805+
Optionally filter by vulnerability ID.
806+
"""
807+
queryset = super().get_queryset()
808+
advisory_id = self.request.query_params.get("advisory_id")
809+
if advisory_id:
810+
queryset = queryset.filter(advisory__avid=advisory_id)
811+
return queryset
812+
813+
743814
class CreateListRetrieveUpdateViewSet(
744815
mixins.CreateModelMixin,
745816
mixins.ListModelMixin,
@@ -1049,10 +1120,10 @@ def bulk_search(self, request):
10491120
# Collect vulnerabilities associated with these packages
10501121
advisories = set()
10511122
for package in packages:
1052-
advisories.update(package.affected_by_vulnerabilities.all())
1053-
advisories.update(package.fixing_vulnerabilities.all())
1123+
advisories.update(package.affected_by_advisories.all())
1124+
advisories.update(package.fixing_advisories.all())
10541125

1055-
advisory_data = {adv.avid: VulnerabilityV2Serializer(adv).data for adv in advisories}
1126+
advisory_data = {adv.avid: AdvisoryV2Serializer(adv).data for adv in advisories}
10561127

10571128
if not purl_only:
10581129
package_data = AdvisoryPackageV2Serializer(
@@ -1077,8 +1148,8 @@ def bulk_search(self, request):
10771148
# Collect vulnerabilities associated with these packages
10781149
advisories = set()
10791150
for package in packages:
1080-
advisories.update(package.affected_by_vulnerabilities.all())
1081-
advisories.update(package.fixing_vulnerabilities.all())
1151+
advisories.update(package.affected_by_advisories.all())
1152+
advisories.update(package.fixing_advisories.all())
10821153

10831154
advisory_data = {adv.advisory_id: AdvisoryV2Serializer(adv).data for adv in advisories}
10841155

vulnerabilities/models.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,14 @@ class VulnerabilityStatusType(models.IntegerChoices):
222222
INVALID = 3, "Invalid"
223223

224224

225+
class AdvisoryStatusType(models.IntegerChoices):
226+
"""List of vulnerability statuses."""
227+
228+
PUBLISHED = 1, "Published"
229+
DISPUTED = 2, "Disputed"
230+
INVALID = 3, "Invalid"
231+
232+
225233
# FIXME: Remove when migration from Vulnerability to Advisory is completed
226234
class Vulnerability(models.Model):
227235
"""
@@ -2730,7 +2738,7 @@ class AdvisoryV2(models.Model):
27302738
)
27312739

27322740
status = models.IntegerField(
2733-
choices=VulnerabilityStatusType.choices, default=VulnerabilityStatusType.PUBLISHED
2741+
choices=AdvisoryStatusType.choices, default=AdvisoryStatusType.PUBLISHED
27342742
)
27352743

27362744
exploitability = models.DecimalField(
@@ -2781,7 +2789,7 @@ def get_absolute_url(self):
27812789
"""
27822790
Return this Vulnerability details absolute URL.
27832791
"""
2784-
return reverse("advisory_details", args=[self.id])
2792+
return reverse("advisory_details", args=[self.avid])
27852793

27862794
def to_advisory_data(self) -> "AdvisoryDataV2":
27872795
from vulnerabilities.importer import AdvisoryDataV2
@@ -2967,6 +2975,12 @@ def _vulnerable(self, vulnerable=True):
29672975
"""
29682976
return self.with_is_vulnerable().filter(is_vulnerable=vulnerable)
29692977

2978+
def vulnerable(self):
2979+
"""
2980+
Return only packages that are vulnerable.
2981+
"""
2982+
return self.filter(affected_by_advisories__isnull=False)
2983+
29702984
def with_is_vulnerable(self):
29712985
"""
29722986
Annotate Package with ``is_vulnerable`` boolean attribute.
@@ -2975,6 +2989,12 @@ def with_is_vulnerable(self):
29752989
is_vulnerable=Exists(AdvisoryV2.objects.filter(affecting_packages__pk=OuterRef("pk")))
29762990
)
29772991

2992+
def from_purl(self, purl: Union[PackageURL, str]):
2993+
"""
2994+
Return a new Package given a ``purl`` PackageURL object or PURL string.
2995+
"""
2996+
return PackageV2.objects.create(**purl_to_dict(purl=purl))
2997+
29782998

29792999
class PackageV2(PackageURLMixin):
29803000
"""

vulnerabilities/templates/package_details_v2.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@
146146
{% for advisory in affected_by_advisories %}
147147
<tr>
148148
<td>
149-
<a href="/advisories/{{advisory.id}}">
149+
<a href="{{advisory.get_absolute_url}}">
150150
{{advisory.avid }}
151151
</a>
152152
<br />
@@ -267,7 +267,7 @@
267267
{% for advisory in fixing_advisories %}
268268
<tr>
269269
<td>
270-
<a href="/advisories/{{advisory.id}}">
270+
<a href="{{advisory.get_absolute_url}}">
271271
{{advisory.avid }}
272272
</a>
273273
</td>

vulnerabilities/tests/test_api_v2.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,12 @@
1919

2020
from vulnerabilities.api_v2 import PackageV2Serializer
2121
from vulnerabilities.api_v2 import VulnerabilityListSerializer
22+
from vulnerabilities.models import AdvisoryV2
2223
from vulnerabilities.models import Alias
2324
from vulnerabilities.models import ApiUser
25+
from vulnerabilities.models import CodeFixV2
2426
from vulnerabilities.models import Package
27+
from vulnerabilities.models import PackageV2
2528
from vulnerabilities.models import PipelineRun
2629
from vulnerabilities.models import PipelineSchedule
2730
from vulnerabilities.models import Vulnerability
@@ -782,3 +785,120 @@ def test_schedule_update_with_staff_session_permitted(self, mock_create_new_job)
782785
self.assertEqual(response.status_code, status.HTTP_200_OK)
783786
self.assertNotEqual(response.status_code, status.HTTP_403_FORBIDDEN)
784787
self.assertEqual(self.schedule1.run_interval, 2)
788+
789+
790+
class CodeFixV2APITest(APITestCase):
791+
def setUp(self):
792+
self.advisory = AdvisoryV2.objects.create(
793+
datasource_id="test_source",
794+
advisory_id="TEST-2025-001",
795+
avid="test_source/TEST-2025-001",
796+
unique_content_id="a" * 64,
797+
url="https://example.com/advisory",
798+
date_collected="2025-07-01T00:00:00Z",
799+
)
800+
801+
self.affected_package = PackageV2.objects.from_purl(purl="pkg:pypi/affected_package@1.0.0")
802+
self.fixed_package = PackageV2.objects.from_purl(purl="pkg:pypi/fixed_package@1.0.1")
803+
804+
self.codefix = CodeFixV2.objects.create(
805+
advisory=self.advisory,
806+
affected_package=self.affected_package,
807+
fixed_package=self.fixed_package,
808+
notes="Security patch",
809+
is_reviewed=True,
810+
)
811+
self.user = ApiUser.objects.create_api_user(username="e@mail.com")
812+
self.auth = f"Token {self.user.auth_token.key}"
813+
self.client = APIClient(enforce_csrf_checks=True)
814+
self.client.credentials(HTTP_AUTHORIZATION=self.auth)
815+
816+
self.url = reverse("advisory-codefix-list")
817+
818+
def test_list_all_codefixes(self):
819+
response = self.client.get(self.url)
820+
assert response.status_code == status.HTTP_200_OK
821+
assert response.data["count"] == 1
822+
assert response.data["results"][0]["affected_advisory_id"] == self.advisory.avid
823+
824+
def test_filter_codefix_by_advisory_id_success(self):
825+
response = self.client.get(self.url, {"advisory_id": self.advisory.avid})
826+
assert response.status_code == status.HTTP_200_OK
827+
assert response.data["count"] == 1
828+
assert response.data["results"][0]["affected_advisory_id"] == self.advisory.avid
829+
830+
def test_filter_codefix_by_advisory_id_not_found(self):
831+
response = self.client.get(self.url, {"advisory_id": "nonexistent/ADVISORY-ID"})
832+
assert response.status_code == status.HTTP_200_OK
833+
assert response.data["count"] == 0
834+
835+
836+
class AdvisoriesPackageV2Tests(APITestCase):
837+
def setUp(self):
838+
self.advisory = AdvisoryV2.objects.create(
839+
datasource_id="ghsa",
840+
advisory_id="GHSA-1234",
841+
avid="ghsa/GHSA-1234",
842+
unique_content_id="f" * 64,
843+
url="https://example.com/advisory",
844+
date_collected="2025-07-01T00:00:00Z",
845+
)
846+
847+
self.package = PackageV2.objects.from_purl(purl="pkg:pypi/sample@1.0.0")
848+
849+
self.user = ApiUser.objects.create_api_user(username="e@mail.com")
850+
self.auth = f"Token {self.user.auth_token.key}"
851+
self.client = APIClient(enforce_csrf_checks=True)
852+
self.client.credentials(HTTP_AUTHORIZATION=self.auth)
853+
854+
self.package.affected_by_advisories.add(self.advisory)
855+
self.package.save()
856+
857+
def test_list_with_purl_filter(self):
858+
url = reverse("advisories-package-v2-list")
859+
with self.assertNumQueries(16):
860+
response = self.client.get(url, {"purl": "pkg:pypi/sample@1.0.0"})
861+
assert response.status_code == 200
862+
assert "packages" in response.data["results"]
863+
assert "advisories" in response.data["results"]
864+
assert self.advisory.avid in response.data["results"]["advisories"]
865+
866+
def test_bulk_lookup(self):
867+
url = reverse("advisories-package-v2-bulk-lookup")
868+
with self.assertNumQueries(11):
869+
response = self.client.post(url, {"purls": ["pkg:pypi/sample@1.0.0"]}, format="json")
870+
assert response.status_code == 200
871+
assert "packages" in response.data
872+
assert "advisories" in response.data
873+
assert self.advisory.avid in response.data["advisories"]
874+
875+
def test_bulk_search_plain(self):
876+
url = reverse("advisories-package-v2-bulk-search")
877+
payload = {"purls": ["pkg:pypi/sample@1.0.0"], "plain_purl": True, "purl_only": False}
878+
with self.assertNumQueries(11):
879+
response = self.client.post(url, payload, format="json")
880+
assert response.status_code == 200
881+
assert "packages" in response.data
882+
assert "advisories" in response.data
883+
884+
def test_bulk_search_purl_only(self):
885+
url = reverse("advisories-package-v2-bulk-search")
886+
payload = {"purls": ["pkg:pypi/sample@1.0.0"], "plain_purl": False, "purl_only": True}
887+
with self.assertNumQueries(11):
888+
response = self.client.post(url, payload, format="json")
889+
assert response.status_code == 200
890+
assert "pkg:pypi/sample@1.0.0" in response.data
891+
892+
def test_lookup_single_package(self):
893+
url = reverse("advisories-package-v2-lookup")
894+
with self.assertNumQueries(9):
895+
response = self.client.post(url, {"purl": "pkg:pypi/sample@1.0.0"}, format="json")
896+
assert response.status_code == 200
897+
assert any(pkg["purl"] == "pkg:pypi/sample@1.0.0" for pkg in response.data)
898+
899+
def test_get_all_vulnerable_purls(self):
900+
url = reverse("advisories-package-v2-all")
901+
with self.assertNumQueries(4):
902+
response = self.client.get(url)
903+
assert response.status_code == 200
904+
assert "pkg:pypi/sample@1.0.0" in response.data

vulnerabilities/views.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -325,8 +325,8 @@ def get_context_data(self, **kwargs):
325325
class AdvisoryDetails(DetailView):
326326
model = models.AdvisoryV2
327327
template_name = "advisory_detail.html"
328-
slug_url_kwarg = "id"
329-
slug_field = "id"
328+
slug_url_kwarg = "avid"
329+
slug_field = "avid"
330330

331331
def get_queryset(self):
332332
return (

vulnerablecode/urls.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from vulnerabilities.api import PackageViewSet
2222
from vulnerabilities.api import VulnerabilityViewSet
2323
from vulnerabilities.api_v2 import AdvisoriesPackageV2ViewSet
24+
from vulnerabilities.api_v2 import CodeFixV2ViewSet
2425
from vulnerabilities.api_v2 import CodeFixViewSet
2526
from vulnerabilities.api_v2 import PackageV2ViewSet
2627
from vulnerabilities.api_v2 import PipelineScheduleV2ViewSet
@@ -66,6 +67,7 @@ def __init__(self, *args, **kwargs):
6667
)
6768
api_v2_router.register("vulnerabilities", VulnerabilityV2ViewSet, basename="vulnerability-v2")
6869
api_v2_router.register("codefixes", CodeFixViewSet, basename="codefix")
70+
api_v2_router.register("advisory-codefixes", CodeFixV2ViewSet, basename="advisory-codefix")
6971
api_v2_router.register("schedule", PipelineScheduleV2ViewSet, basename="schedule")
7072

7173

@@ -102,7 +104,7 @@ def __init__(self, *args, **kwargs):
102104
name="home",
103105
),
104106
path(
105-
"advisories/<int:id>",
107+
"advisories/<path:avid>",
106108
AdvisoryDetails.as_view(),
107109
name="advisory_details",
108110
),

0 commit comments

Comments
 (0)