From 8c51b34e7257aec6a95e8827fdb9290343a2f971 Mon Sep 17 00:00:00 2001 From: ziad hany Date: Sat, 21 Feb 2026 02:35:05 +0200 Subject: [PATCH] Add API support for PackageCommitPatch Signed-off-by: ziad hany --- vulnerabilities/api_v3.py | 104 +++++++++++++++++- .../templates/advisory_detail.html | 46 +++++++- .../advisory_package_commit_details.html | 75 +++++++++++++ vulnerabilities/tests/test_api_v3.py | 2 +- vulnerabilities/views.py | 52 +++++++++ vulnerablecode/urls.py | 6 + 6 files changed, 280 insertions(+), 5 deletions(-) create mode 100644 vulnerabilities/templates/advisory_package_commit_details.html diff --git a/vulnerabilities/api_v3.py b/vulnerabilities/api_v3.py index 12f10ed1c..00348e18b 100644 --- a/vulnerabilities/api_v3.py +++ b/vulnerabilities/api_v3.py @@ -15,6 +15,7 @@ from django.db.models import Max from django.db.models import OuterRef from django.db.models import Prefetch +from django.db.models import Q from django_filters import rest_framework as filters from drf_spectacular.utils import extend_schema from packageurl import PackageURL @@ -33,7 +34,9 @@ from vulnerabilities.models import Group from vulnerabilities.models import GroupedAdvisory from vulnerabilities.models import ImpactedPackageAffecting +from vulnerabilities.models import PackageCommitPatch from vulnerabilities.models import PackageV2 +from vulnerabilities.models import Patch from vulnerabilities.throttling import PermissionBasedUserRateThrottle from vulnerabilities.utils import TYPES_WITH_MULTIPLE_IMPORTERS from vulnerabilities.utils import get_advisories_from_groups @@ -221,18 +224,24 @@ def get_affected_by_vulnerabilities(self, package): """Return a dictionary with advisory as keys and their details, including fixed_by_packages.""" advisories = self.context["advisory_map"].get(package.id, []) impact_map = self.context["impact_map"].get(package.id, {}) + introduced_patch_map = self.context.get("introduced_patch_map", {}) + fixed_patch_map = self.context.get("fixed_patch_map", {}) if advisories: result = [] for adv in advisories: fixed = impact_map.get(adv["avid"]) + introduced_patches = introduced_patch_map.get((package.id, adv["avid"]), []) + fixed_patches = fixed_patch_map.get((package.id, adv["avid"]), []) adv.pop("avid", None) result.append( { **adv, "fixed_by_packages": fixed, + "introduced_in_patch": introduced_patches, + "fixed_in_patch": fixed_patches, } ) @@ -266,7 +275,8 @@ def get_affected_by_vulnerabilities(self, package): impact = impact_by_avid.get(advisory.avid) if not impact: continue - + introduced_patches = introduced_patch_map.get((package.id, advisory.avid), []) + fixed_patches = fixed_patch_map.get((package.id, advisory.avid), []) result.append( { "advisory_id": advisory.advisory_id.split("/")[-1], @@ -276,9 +286,10 @@ def get_affected_by_vulnerabilities(self, package): "exploitability": advisory.exploitability, "risk_score": advisory.risk_score, "fixed_by_packages": [pkg.purl for pkg in impact.fixed_by_packages.all()], + "introduced_in_patch": introduced_patches, + "fixed_in_patch": fixed_patches, } ) - return result if not advisories: @@ -386,6 +397,48 @@ def get_latest_non_vulnerable_version(self, package): return latest_non_vulnerable.version +class PackageCommitPatchSerializer(serializers.ModelSerializer): + introduced_in_advisories = serializers.SerializerMethodField() + fixed_in_advisories = serializers.SerializerMethodField() + + class Meta: + model = PackageCommitPatch + fields = [ + "commit_hash", + "vcs_url", + "commit_url", + "patch_url", + "introduced_in_advisories", + "fixed_in_advisories", + ] + + def get_introduced_in_advisories(self, obj): + impacts = obj.introduced_in_impacts.all() + return self.serialize_impacts(impacts) + + def get_fixed_in_advisories(self, obj): + impacts = obj.fixed_in_impacts.all() + return self.serialize_impacts(impacts) + + @staticmethod + def serialize_impacts(impacts): + unique_pairs = set() + for impact in impacts: + unique_pairs.add((impact.base_purl, impact.advisory.avid)) + return [{"purl": base_purl, "avid": avid} for base_purl, avid in unique_pairs] + + +class PatchSerializer(serializers.ModelSerializer): + in_advisories = serializers.SerializerMethodField() + + class Meta: + model = Patch + fields = ["patch_url", "in_advisories"] + + def get_in_advisories(self, obj): + return [advisory.avid for advisory in obj.advisories.all()] + + class PackageV3ViewSet(viewsets.GenericViewSet): queryset = PackageV2.objects.all() serializer_class = PackageV3Serializer @@ -456,6 +509,7 @@ def create(self, request, *args, **kwargs): affected_advisory_map = get_affected_advisories_bulk(page) fixing_advisory_map = get_fixing_advisories_bulk(page) impact_map = get_impacts_bulk(page) + introduced_patch_map, fixed_patch_map = get_patches_bulk(page) serializer = self.get_serializer( page, many=True, @@ -464,6 +518,8 @@ def create(self, request, *args, **kwargs): "advisory_map": affected_advisory_map, "impact_map": impact_map, "fixing_advisory_map": fixing_advisory_map, + "introduced_patch_map": introduced_patch_map, + "fixed_patch_map": fixed_patch_map, }, ) return self.get_paginated_response(serializer.data) @@ -673,6 +729,50 @@ def get_impacts_bulk(packages): return impact_map +def get_patches_bulk(packages): + """ + Returns a tuple of two dicts: + - introduced_map: (package_id, advisory_avid) -> list of introduced patch dicts + - fixed_map: (package_id, advisory_avid) -> list of fixed patch dicts + Each patch dict contains 'commit_hash' and 'vcs_url'. + Uses ImpactedPackageAffecting to link packages to impacts, then collects patches + from introduced_by_package_commit_patches and fixed_by_package_commit_patches. + """ + package_ids = [p.id for p in packages] + if not package_ids: + return {}, {} + + ipa_qs = ( + ImpactedPackageAffecting.objects.filter(package_id__in=package_ids) + .select_related("impacted_package__advisory") + .prefetch_related( + "impacted_package__introduced_by_package_commit_patches", + "impacted_package__fixed_by_package_commit_patches", + ) + ) + + introduced_map = defaultdict(list) + fixed_map = defaultdict(list) + + for ipa in ipa_qs: + pkg_id = ipa.package_id + impact = ipa.impacted_package + avid = impact.advisory.avid + key = (pkg_id, avid) + + for patch in impact.introduced_by_package_commit_patches.all(): + patch_data = {"commit_hash": patch.commit_hash, "vcs_url": patch.vcs_url} + if patch_data not in introduced_map[key]: + introduced_map[key].append(patch_data) + + for patch in impact.fixed_by_package_commit_patches.all(): + patch_data = {"commit_hash": patch.commit_hash, "vcs_url": patch.vcs_url} + if patch_data not in fixed_map[key]: + fixed_map[key].append(patch_data) + + return introduced_map, fixed_map + + def get_fixing_advisories_bulk(packages): package_ids = [p.id for p in packages] diff --git a/vulnerabilities/templates/advisory_detail.html b/vulnerabilities/templates/advisory_detail.html index 90f1d6d8b..acf3d9379 100644 --- a/vulnerabilities/templates/advisory_detail.html +++ b/vulnerabilities/templates/advisory_detail.html @@ -80,7 +80,16 @@ {% endif %} - + +
  • + + + {% with pcp_length=package_commit_patches|length %} + Patches: ({{ advisory.patches.count|add:pcp_length }}) + {% endwith %} + + +