Skip to content

Commit 6af1942

Browse files
committed
Add advisory codefix V2 URL
Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com>
1 parent 8801c90 commit 6af1942

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
@@ -331,7 +331,7 @@ def get_affected_by_vulnerabilities(self, obj):
331331
# Get code fixed for a vulnerability
332332
code_fixes = CodeFixV2.objects.filter(advisory=adv).distinct()
333333
code_fix_urls = [
334-
reverse("codefix-detail", args=[code_fix.id], request=request)
334+
reverse("advisory-codefix-detail", args=[code_fix.id], request=request)
335335
for code_fix in code_fixes
336336
]
337337

@@ -714,6 +714,58 @@ class Meta:
714714
read_only_fields = ["created_at", "updated_at"]
715715

716716

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

737789

790+
class CodeFixV2ViewSet(viewsets.ReadOnlyModelViewSet):
791+
"""
792+
API endpoint that allows viewing CodeFix entries.
793+
"""
794+
795+
queryset = CodeFixV2.objects.all()
796+
serializer_class = CodeFixV2Serializer
797+
798+
def get_queryset(self):
799+
"""
800+
Optionally filter by vulnerability ID.
801+
"""
802+
queryset = super().get_queryset()
803+
advisory_id = self.request.query_params.get("advisory_id")
804+
if advisory_id:
805+
queryset = queryset.filter(advisory__avid=advisory_id)
806+
return queryset
807+
808+
738809
class CreateListRetrieveUpdateViewSet(
739810
mixins.CreateModelMixin,
740811
mixins.ListModelMixin,
@@ -1043,10 +1114,10 @@ def bulk_search(self, request):
10431114
# Collect vulnerabilities associated with these packages
10441115
advisories = set()
10451116
for package in packages:
1046-
advisories.update(package.affected_by_vulnerabilities.all())
1047-
advisories.update(package.fixing_vulnerabilities.all())
1117+
advisories.update(package.affected_by_advisories.all())
1118+
advisories.update(package.fixing_advisories.all())
10481119

1049-
advisory_data = {adv.avid: VulnerabilityV2Serializer(adv).data for adv in advisories}
1120+
advisory_data = {adv.avid: AdvisoryV2Serializer(adv).data for adv in advisories}
10501121

10511122
if not purl_only:
10521123
package_data = AdvisoryPackageV2Serializer(
@@ -1071,8 +1142,8 @@ def bulk_search(self, request):
10711142
# Collect vulnerabilities associated with these packages
10721143
advisories = set()
10731144
for package in packages:
1074-
advisories.update(package.affected_by_vulnerabilities.all())
1075-
advisories.update(package.fixing_vulnerabilities.all())
1145+
advisories.update(package.affected_by_advisories.all())
1146+
advisories.update(package.fixing_advisories.all())
10761147

10771148
advisory_data = {adv.advisory_id: AdvisoryV2Serializer(adv).data for adv in advisories}
10781149

vulnerabilities/models.py

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

223223

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

27152723
status = models.IntegerField(
2716-
choices=VulnerabilityStatusType.choices, default=VulnerabilityStatusType.PUBLISHED
2724+
choices=AdvisoryStatusType.choices, default=AdvisoryStatusType.PUBLISHED
27172725
)
27182726

27192727
exploitability = models.DecimalField(
@@ -2764,7 +2772,7 @@ def get_absolute_url(self):
27642772
"""
27652773
Return this Vulnerability details absolute URL.
27662774
"""
2767-
return reverse("advisory_details", args=[self.id])
2775+
return reverse("advisory_details", args=[self.avid])
27682776

27692777
def to_advisory_data(self) -> "AdvisoryDataV2":
27702778
from vulnerabilities.importer import AdvisoryDataV2
@@ -2950,6 +2958,12 @@ def _vulnerable(self, vulnerable=True):
29502958
"""
29512959
return self.with_is_vulnerable().filter(is_vulnerable=vulnerable)
29522960

2961+
def vulnerable(self):
2962+
"""
2963+
Return only packages that are vulnerable.
2964+
"""
2965+
return self.filter(affected_by_advisories__isnull=False)
2966+
29532967
def with_is_vulnerable(self):
29542968
"""
29552969
Annotate Package with ``is_vulnerable`` boolean attribute.
@@ -2958,6 +2972,12 @@ def with_is_vulnerable(self):
29582972
is_vulnerable=Exists(AdvisoryV2.objects.filter(affecting_packages__pk=OuterRef("pk")))
29592973
)
29602974

2975+
def from_purl(self, purl: Union[PackageURL, str]):
2976+
"""
2977+
Return a new Package given a ``purl`` PackageURL object or PURL string.
2978+
"""
2979+
return PackageV2.objects.create(**purl_to_dict(purl=purl))
2980+
29612981

29622982
class PackageV2(PackageURLMixin):
29632983
"""

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