diff --git a/component_catalog/api.py b/component_catalog/api.py index e37e6ac4..104430d9 100644 --- a/component_catalog/api.py +++ b/component_catalog/api.py @@ -697,6 +697,8 @@ class Meta: "last_modified_date", "collect_data", "risk_score", + "next_non_vulnerable_version", + "latest_non_vulnerable_version", "affected_by_vulnerabilities", ) extra_kwargs = { diff --git a/component_catalog/migrations/0015_component_latest_non_vulnerable_version_and_more.py b/component_catalog/migrations/0015_component_latest_non_vulnerable_version_and_more.py new file mode 100644 index 00000000..40616568 --- /dev/null +++ b/component_catalog/migrations/0015_component_latest_non_vulnerable_version_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.6 on 2026-08-27 07:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('component_catalog', '0014_add_detected_date_to_affected_by_vulnerability'), + ] + + operations = [ + migrations.AddField( + model_name='package', + name='latest_non_vulnerable_version', + field=models.CharField(blank=True, help_text='The latest available version that is not vulnerable.', max_length=100), + ), + migrations.AddField( + model_name='package', + name='next_non_vulnerable_version', + field=models.CharField(blank=True, help_text='The next version, following this one, that is not vulnerable.', max_length=100), + ), + ] diff --git a/component_catalog/models.py b/component_catalog/models.py index 55902b65..ba088bd7 100644 --- a/component_catalog/models.py +++ b/component_catalog/models.py @@ -1799,6 +1799,7 @@ def only_rendering_fields(self): "filename", "license_expression", "risk_score", + "latest_non_vulnerable_version", "dataspace__name", "dataspace__show_usage_policy_in_user_views", ) @@ -1977,6 +1978,17 @@ class Package( related_name="affected_%(class)ss", help_text=_("Vulnerabilities affecting this object."), ) + # Based on vulnerablecode.vulnerabilities.models.Package + next_non_vulnerable_version = models.CharField( + max_length=100, + blank=True, + help_text=_("The next version, following this one, that is not vulnerable."), + ) + latest_non_vulnerable_version = models.CharField( + max_length=100, + blank=True, + help_text=_("The latest available version that is not vulnerable."), + ) objects = DataspacedManager.from_queryset(PackageQuerySet)() diff --git a/component_catalog/templates/component_catalog/tabs/tab_vulnerabilities.html b/component_catalog/templates/component_catalog/tabs/tab_vulnerabilities.html index 1613caa8..e1e0a5a7 100644 --- a/component_catalog/templates/component_catalog/tabs/tab_vulnerabilities.html +++ b/component_catalog/templates/component_catalog/tabs/tab_vulnerabilities.html @@ -1,14 +1,28 @@ {% load i18n %} -
-
- +
+
+ Risk score -
-
{% include 'vulnerabilities/includes/risk_score_badge.html' with risk_score=package.risk_score only %} -
-
+ + {% if package.next_non_vulnerable_version %} +
+ + Next non-vulnerable version + + {{ package.next_non_vulnerable_version }} +
+ {% endif %} + {% if package.latest_non_vulnerable_version %} +
+ + Latest non-vulnerable version + + {{ package.latest_non_vulnerable_version }} +
+ {% endif %} + @@ -39,7 +53,7 @@ diff --git a/component_catalog/tests/test_api.py b/component_catalog/tests/test_api.py index 7ad52a2a..7d8cad98 100644 --- a/component_catalog/tests/test_api.py +++ b/component_catalog/tests/test_api.py @@ -1339,7 +1339,11 @@ def test_api_package_endpoint_vulnerabilities_features(self): self.client.login(username="super_user", password="secret") vulnerability1 = make_vulnerability(self.dataspace, affecting=self.package1) vulnerability2 = make_vulnerability(self.dataspace) - self.package1.update(risk_score=9.0) + self.package1.update( + risk_score=9.0, + next_non_vulnerable_version="1.2.4", + latest_non_vulnerable_version="2.0.0", + ) data = {"is_vulnerable": "yes"} response = self.client.get(self.package_list_url, data) @@ -1349,6 +1353,8 @@ def test_api_package_endpoint_vulnerabilities_features(self): results = response.data["results"] self.assertEqual("9.0", results[0]["risk_score"]) + self.assertEqual("1.2.4", results[0]["next_non_vulnerable_version"]) + self.assertEqual("2.0.0", results[0]["latest_non_vulnerable_version"]) self.assertEqual( vulnerability1.advisory_id, results[0]["affected_by_vulnerabilities"][0]["advisory_id"], diff --git a/component_catalog/tests/test_views.py b/component_catalog/tests/test_views.py index 24e5b52d..a4c0c84b 100644 --- a/component_catalog/tests/test_views.py +++ b/component_catalog/tests/test_views.py @@ -3023,6 +3023,23 @@ def test_package_details_view_tab_vulnerabilities(self): self.assertContains(response, 'id="tab_vulnerabilities"') self.assertContains(response, self.vulnerability1.advisory_id) + def test_package_details_view_tab_vulnerabilities_fixed_by_packages(self): + fixing_package = make_package(self.dataspace, package_url="pkg:pypi/idna@3.7") + self.vulnerability1.fixed_by_packages = [ + "pkg:pypi/idna@3.7", + "pkg:pypi/idna@9.9.9", + ] + self.vulnerability1.save() + + self.client.login(username=self.super_user.username, password="secret") + response = self.client.get(self.package1.details_url) + + # A known package is linked directly. + self.assertContains(response, fixing_package.get_absolute_url()) + # An unknown package offers an "Add Package" link instead. + self.assertContains(response, "idna@9.9.9") + self.assertContains(response, "package_url=pkg:pypi/idna@9.9.9") + def test_vulnerablecode_get_plain_purls(self): purls = get_plain_purls(packages=[]) self.assertEqual([], purls) diff --git a/component_catalog/views.py b/component_catalog/views.py index a650e000..2453a58e 100644 --- a/component_catalog/views.py +++ b/component_catalog/views.py @@ -8,7 +8,6 @@ import json from collections import Counter -from operator import itemgetter from urllib.parse import quote_plus from django.apps import apps @@ -261,7 +260,8 @@ def tab_vulnerabilities(self): label = ( f"Vulnerabilities" - f' {len(vulnerabilities_qs)}' + f' ' + f"{len(vulnerabilities_qs)}" ) vulnerabilities = [] @@ -280,55 +280,27 @@ def tab_vulnerabilities(self): } def get_fixed_packages_html(self, vulnerability, dataspace): - if not vulnerability.fixed_packages: + if not vulnerability.fixed_by_packages: return - fixed_packages_sorted = natsorted(vulnerability.fixed_packages, key=itemgetter("purl")) + fixed_packages_sorted = natsorted(vulnerability.fixed_by_packages) add_package_url = reverse("component_catalog:package_add") - vulnerability_icon = ( - '' - '' - "" - ) - no_vulnerabilities_icon = ( - '' - ' ' - ' ' - "" - ) fixed_packages_values = [] - for fixed_package in fixed_packages_sorted: - purl = fixed_package.get("purl") - is_vulnerable = fixed_package.get("is_vulnerable") + for purl in fixed_packages_sorted: package_instances = Package.objects.scope(dataspace).for_package_url(purl) for package in package_instances: - absolute_url = package.get_absolute_url() - display_value = package.get_html_link(href=absolute_url) - if is_vulnerable: - display_value += package.get_html_link( - href=f"{absolute_url}#vulnerabilities", - value=mark_safe(vulnerability_icon), - ) - else: - display_value += no_vulnerabilities_icon + display_value = package.get_html_link(href=package.get_absolute_url()) fixed_packages_values.append(display_value) if not package_instances: - display_value = purl.replace("pkg:", "") - if is_vulnerable: - display_value += vulnerability_icon - else: - display_value += no_vulnerabilities_icon # Warning: do not add spaces between HTML elements as this content # is displayed in a
-                display_value += (
+                display_value = (
+                    f"{purl.replace('pkg:', '')}"
                     f''
+                    f'   class="ms-1" target="_blank">'
                     f''
                     f''
diff --git a/dejacode/static/css/dejacode_bootstrap.css b/dejacode/static/css/dejacode_bootstrap.css
index 780ab914..1b354fb5 100644
--- a/dejacode/static/css/dejacode_bootstrap.css
+++ b/dejacode/static/css/dejacode_bootstrap.css
@@ -417,8 +417,8 @@ table.vulnerabilities-table .column-summary {
   width: 240px;
 }
 #tab_vulnerabilities .column-affected_packages {
-  min-width: 300px;
-  width: 300px;
+  min-width: 310px;
+  width: 310px;
 }
 #tab_vulnerabilities .column-triage_action {
   min-width: 165px;
@@ -603,12 +603,6 @@ table.purldb-table .column-license_expression {
 .vulnerability {
   color: #dc3545;
 }
-.badge-vulnerability {
-  color: #fff;
-  background-color: #dc3545;
-  vertical-align: middle;
-}
-
 #vulnerability-analysis-form fieldset legend {
   font-size: 1rem;
 }
diff --git a/dje/copier.py b/dje/copier.py
index e3cfe4ab..d55e6ab5 100644
--- a/dje/copier.py
+++ b/dje/copier.py
@@ -67,6 +67,8 @@
     "default_assignee",
     "affected_by_vulnerabilities",
     "risk_score",
+    "next_non_vulnerable_version",
+    "latest_non_vulnerable_version",
 ]
 
 
diff --git a/dje/tests/testfiles/test_dataset_cc_only.json b/dje/tests/testfiles/test_dataset_cc_only.json
index cab86eff..8d85e8e3 100644
--- a/dje/tests/testfiles/test_dataset_cc_only.json
+++ b/dje/tests/testfiles/test_dataset_cc_only.json
@@ -315,7 +315,9 @@
     "api_data_url": "",
     "datasource_id": "",
     "file_references": [],
-    "parties": []
+    "parties": [],
+    "next_non_vulnerable_version": "",
+    "latest_non_vulnerable_version": ""
   }
 },
 {
diff --git a/dje/tests/testfiles/test_dataset_pp_only.json b/dje/tests/testfiles/test_dataset_pp_only.json
index 4b1bba90..c772b185 100644
--- a/dje/tests/testfiles/test_dataset_pp_only.json
+++ b/dje/tests/testfiles/test_dataset_pp_only.json
@@ -49,7 +49,9 @@
     "api_data_url": "",
     "datasource_id": "",
     "file_references": [],
-    "parties": []
+    "parties": [],
+    "next_non_vulnerable_version": "",
+    "latest_non_vulnerable_version": ""
   }
 },
 {
diff --git a/product_portfolio/filters.py b/product_portfolio/filters.py
index 9dff4bf6..28fdff92 100644
--- a/product_portfolio/filters.py
+++ b/product_portfolio/filters.py
@@ -464,7 +464,9 @@ def __init__(self, *args, **kwargs):
         self.filters["vulnerability_analyses__state"].extra["null_label"] = "(No values)"
         self.filters["vulnerability_analyses__justification"].extra["null_label"] = "(No values)"
         is_reachable = self.filters["is_reachable"]
-        is_reachable.extra["widget"].link_content = ''
+        is_reachable.extra[
+            "widget"
+        ].link_content = ''
 
 
 class ComponentCompletenessListFilter(admin.SimpleListFilter):
diff --git a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html
index 9c886d78..9f8896e9 100644
--- a/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html
+++ b/product_portfolio/templates/product_portfolio/tabs/tab_packages_vulnerabilities.html
@@ -40,6 +40,13 @@
                {% trans "Exposure factor:" %} {{ product_package.purpose.exposure_factor }}
             
           {% endif %}
+          {% if product_package.package.latest_non_vulnerable_version %}
+            
+ + {% trans "Non-vulnerable version available:" %} + {{ product_package.package.latest_non_vulnerable_version }} +
+ {% endif %} {% for vulnerability in product_package.display_vulnerabilities %} {% if not forloop.first %}
{% endif %} diff --git a/product_portfolio/views.py b/product_portfolio/views.py index 9f92650f..86f86318 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -3574,7 +3574,7 @@ class ProductSecurityComplianceExportView( "exploitability": "Exploitability", "weighted_severity": "Weighted severity", "affected_package_count": "Affected packages", - "fixed_packages_count": "Fixed packages", + "fixed_by_packages_count": "Fixed packages", "resource_url": "Reference URL", "advisory_uid": "Advisory UID", } diff --git a/vulnerabilities/api.py b/vulnerabilities/api.py index e85335e7..466d4eac 100644 --- a/vulnerabilities/api.py +++ b/vulnerabilities/api.py @@ -46,7 +46,7 @@ class Meta: "weighted_severity", "risk_score", "risk_level", - "fixed_packages", + "fixed_by_packages", "ssvc_trees", "affected_packages", "affected_products", diff --git a/vulnerabilities/fetch.py b/vulnerabilities/fetch.py index 7bb55cbb..8f09b99d 100644 --- a/vulnerabilities/fetch.py +++ b/vulnerabilities/fetch.py @@ -221,7 +221,8 @@ def process_vc_entry( ): """ Process a single VulnerableCode purl entry: find matching packages, create or update - linked vulnerabilities, and apply the API-provided risk score. + linked vulnerabilities, and apply the API-provided risk score and non-vulnerable + version values. Returns the affected packages as a list, or an empty list if the entry has no vulnerabilities. The ``results`` dict is updated in-place. @@ -267,9 +268,19 @@ def process_vc_entry( # Link packages to vulnerabilities: 1 SELECT + 1 bulk INSERT instead of N*M get_or_create. batch_add_affected(affected_packages, vulnerabilities) - # Update risk_score without triggering Package.save() (which carries handle_assigned_licenses). - if package_risk_score := vc_entry.get("risk_score"): - packages_qs.update(risk_score=package_risk_score) + # Update those fields without triggering Package.save() (which carries + # handle_assigned_licenses). + package_field_names = ( + "risk_score", + "next_non_vulnerable_version", + "latest_non_vulnerable_version", + ) + package_update_fields = {} + for field_name in package_field_names: + if field_value := vc_entry.get(field_name): + package_update_fields[field_name] = field_value + if package_update_fields: + packages_qs.update(**package_update_fields) return affected_packages diff --git a/vulnerabilities/filters.py b/vulnerabilities/filters.py index 6cc5dd6b..b95979e6 100644 --- a/vulnerabilities/filters.py +++ b/vulnerabilities/filters.py @@ -86,7 +86,7 @@ class VulnerabilityFilterSet(DataspacedFilterSet): "affected_products_count", "affected_packages", "affected_packages_count", - "fixed_packages_count", + "fixed_by_packages_count", "created_date", "last_modified_date", ], diff --git a/vulnerabilities/migrations/0012_remove_vulnerability_fixed_packages_count_and_more.py b/vulnerabilities/migrations/0012_remove_vulnerability_fixed_packages_count_and_more.py new file mode 100644 index 00000000..97e9ae55 --- /dev/null +++ b/vulnerabilities/migrations/0012_remove_vulnerability_fixed_packages_count_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 6.0.6 on 2026-08-27 07:41 + +import dje.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vulnerabilities', '0011_vulnerabilityanalysis_applied_by_preset'), + ] + + operations = [ + migrations.RemoveField( + model_name='vulnerability', + name='fixed_packages_count', + ), + migrations.AddField( + model_name='vulnerability', + name='fixed_by_packages', + field=dje.fields.JSONListField(blank=True, default=list, help_text='A list of packages that fix this vulnerability.'), + ), + migrations.RemoveField( + model_name='vulnerability', + name='fixed_packages', + ), + migrations.AddField( + model_name='vulnerability', + name='fixed_by_packages_count', + field=models.GeneratedField(db_persist=True, expression=models.Func(models.F('fixed_by_packages'), function='jsonb_array_length'), output_field=models.IntegerField()), + ), + ] diff --git a/vulnerabilities/models.py b/vulnerabilities/models.py index eb286bb2..238f1e55 100644 --- a/vulnerabilities/models.py +++ b/vulnerabilities/models.py @@ -120,12 +120,12 @@ class Vulnerability(HistoryDateFieldsMixin, DataspacedModel): "(e.g., 'CVE-2017-1000136')." ), ) - fixed_packages = JSONListField( + fixed_by_packages = JSONListField( blank=True, - help_text=_("A list of packages that are not affected by this vulnerability."), + help_text=_("A list of packages that fix this vulnerability."), ) - fixed_packages_count = models.GeneratedField( - expression=models.Func(models.F("fixed_packages"), function="jsonb_array_length"), + fixed_by_packages_count = models.GeneratedField( + expression=models.Func(models.F("fixed_by_packages"), function="jsonb_array_length"), output_field=models.IntegerField(), db_persist=True, ) diff --git a/vulnerabilities/templates/vulnerabilities/includes/exploitability.html b/vulnerabilities/templates/vulnerabilities/includes/exploitability.html index 7cf19325..188ebae6 100644 --- a/vulnerabilities/templates/vulnerabilities/includes/exploitability.html +++ b/vulnerabilities/templates/vulnerabilities/includes/exploitability.html @@ -1,9 +1,8 @@ {% if instance.exploitability %} {{ instance.get_exploitability_display }} diff --git a/vulnerabilities/templates/vulnerabilities/includes/risk_score_badge.html b/vulnerabilities/templates/vulnerabilities/includes/risk_score_badge.html index c865ceab..9c924754 100644 --- a/vulnerabilities/templates/vulnerabilities/includes/risk_score_badge.html +++ b/vulnerabilities/templates/vulnerabilities/includes/risk_score_badge.html @@ -1,9 +1,9 @@ {% if risk_score %} {% if label %} diff --git a/vulnerabilities/templates/vulnerabilities/tables/vulnerability_list_table.html b/vulnerabilities/templates/vulnerabilities/tables/vulnerability_list_table.html index f6a6dc02..9e7dae9d 100644 --- a/vulnerabilities/templates/vulnerabilities/tables/vulnerability_list_table.html +++ b/vulnerabilities/templates/vulnerabilities/tables/vulnerability_list_table.html @@ -63,7 +63,7 @@ {% endif %} {% empty %} diff --git a/vulnerabilities/tests/data/vulnerabilities/idna_3.6_response.json b/vulnerabilities/tests/data/vulnerabilities/idna_3.6_response.json index e9e0a942..d2990af3 100644 --- a/vulnerabilities/tests/data/vulnerabilities/idna_3.6_response.json +++ b/vulnerabilities/tests/data/vulnerabilities/idna_3.6_response.json @@ -1,101 +1,148 @@ { - "count": 1, - "next": null, - "previous": null, - "results": [ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "purl": "pkg:pypi/idna@3.6", + "affected_by_vulnerabilities": [ { - "purl": "pkg:pypi/idna@3.6", - "affected_by_vulnerabilities": [ - { - "advisory_id": "PYSEC-2024-60", - "advisory_uid": "pypa/idna/PYSEC-2024-60", - "aliases": [ - "CVE-2024-3651", - "GHSA-jjg7-2v4v-x38h" - ], - "summary": "A vulnerability was identified in the kjd/idna library, specifically within the `idna.encode()` function, affecting version 3.6. The issue arises from the function's handling of crafted input strings, which can lead to quadratic complexity and consequently, a denial of service condition. This vulnerability is triggered by a crafted input that causes the `idna.encode()` function to process the input with considerable computational load, significantly increasing the processing time in a quadratic manner relative to the input size.", - "weighted_severity": 6.8, - "exploitability": 0.5, - "risk_score": 3.4, - "fixed_by_packages": [ - "pkg:pypi/idna@3.7" - ], - "ssvc_trees": [ - { - "vector": "SSVCv2/E:N/A:N/T:P/P:M/B:A/M:M/D:T/2024-07-07T19:07:43Z/", - "decision": "Track", - "options": [ - { - "Exploitation": "none" - }, - { - "Automatable": "no" - }, - { - "Technical Impact": "partial" - }, - { - "Mission Prevalence": "minimal" - }, - { - "Public Well-being Impact": "material" - }, - { - "Mission & Well-being": "medium" - } - ], - "source_url": "https://github.com/cisagov/vulnrichment/blob/develop/2024/3xxx/CVE-2024-3651.json" - } - ], - "resource_url": "http://public.vulnerablecode.io/advisories/pypa/idna/PYSEC-2024-60" + "advisory_id": "PYSEC-2024-60", + "advisory_uid": "pypa/idna/PYSEC-2024-60", + "aliases": [ + "CVE-2024-3651", + "GHSA-jjg7-2v4v-x38h" + ], + "summary": "A vulnerability was identified in the kjd/idna library, specifically within the `idna.encode()` function, affecting version 3.6. The issue arises from the function's handling of crafted input strings, which can lead to quadratic complexity and consequently, a denial of service condition. This vulnerability is triggered by a crafted input that causes the `idna.encode()` function to process the input with considerable computational load, significantly increasing the processing time in a quadratic manner relative to the input size.", + "weighted_severity": 6.8, + "exploitability": 0.5, + "risk_score": 3.4, + "fixed_by_packages": [ + "pkg:pypi/idna@3.7" + ], + "introduced_in_patches": [], + "fixed_in_patches": [], + "ssvc_trees": [ + { + "vector": "SSVCv2/E:N/A:N/T:P/P:M/B:A/M:M/D:T/2024-07-07T19:07:43Z/", + "decision": "Track", + "options": [ + { + "Exploitation": "none" + }, + { + "Automatable": "no" + }, + { + "Technical Impact": "partial" + }, + { + "Mission Prevalence": "minimal" + }, + { + "Public Well-being Impact": "material" + }, + { + "Mission & Well-being": "medium" + } + ], + "source_url": "https://github.com/cisagov/vulnrichment/blob/develop/2024/3xxx/CVE-2024-3651.json" + } + ], + "resource_url": "http://public.vulnerablecode.io/advisories/pypa/idna/PYSEC-2024-60" + }, + { + "advisory_id": "CVE-2026-45409", + "advisory_uid": "gitlab/pypi/idna/CVE-2026-45409", + "aliases": [ + "GHSA-65pc-fj4g-8rjx" + ], + "summary": "Internationalized Domain Names in Applications (IDNA): Specially crafted inputs to idna.encode() can bypass CVE-2024-3651 fix\nThis is the same issue as CVE-2024-3651, however the original remediation in 2024 was not a complete fix. Payloads such as `\"\\u0660\" * N` or `\"\\u30fb\" * N + \"\\u6f22\"` utilize the `valid_contexto` function prior to length rejection, and for high values of `N` will take a long time to process.", + "weighted_severity": null, + "exploitability": 0.5, + "risk_score": null, + "fixed_by_packages": [], + "introduced_in_patches": [], + "fixed_in_patches": [], + "ssvc_trees": [ + { + "vector": "SSVCv2/E:N/A:Y/T:P/P:M/B:A/M:M/D:T/2026-06-08T14:30:54Z/", + "decision": "Track", + "options": [ + { + "Exploitation": "none" + }, + { + "Automatable": "yes" + }, + { + "Technical Impact": "partial" + }, + { + "Mission Prevalence": "minimal" + }, + { + "Public Well-being Impact": "material" + }, + { + "Mission & Well-being": "medium" + } + ], + "source_url": "https://github.com/cisagov/vulnrichment/blob/develop/2026/45xxx/CVE-2026-45409.json" + } + ], + "resource_url": "http://public.vulnerablecode.io/advisories/gitlab/pypi/idna/CVE-2026-45409" + }, + { + "advisory_id": "PYSEC-2026-215", + "advisory_uid": "pypa/idna/PYSEC-2026-215", + "aliases": [ + "CVE-2026-45409", + "GHSA-65pc-fj4g-8rjx" + ], + "summary": "Internationalized Domain Names in Applications (IDNA) for Python provides support for Internationalized Domain Names in Applications (IDNA) and Unicode IDNA Compatibility Processing. In versions prior to 3.15, payloads such as `\"\\u0660\" * N` or `\"\\u30fb\" * N + \"\\u6f22\"` utilize the `valid_contexto` function prior to length rejection, and for high values of `N` will take a long time to process. This is the same issue as CVE-2024-3651, however the original remediation in 2024 was not a complete fix. A specially crafted argument to the `idna.encode()` function could consume significant resources. This may lead to a denial-of-service. Starting in version 3.14, the function rejects long inputs as soon as practicable prior to any further processing to minimize resource consumption. In version 3.15, this approach was extended to lesser used alternate functions (i.e. per-label conversions and codec support). A workaround is available. Domain names cannot exceed 253 characters in length. If this length limit is enforced prior to passing the domain to the `idna.encode()` function, it should no longer consume significant resources. This is triggered by arbitrarily large inputs that would not occur in normal usage, but may be passed to the library assuming there is no preliminary input validation by the higher-level application.", + "weighted_severity": 6.2, + "exploitability": 0.5, + "risk_score": 3.1, + "fixed_by_packages": [ + "pkg:pypi/idna@3.15" + ], + "introduced_in_patches": [], + "fixed_in_patches": [], + "ssvc_trees": [ + { + "vector": "SSVCv2/E:N/A:Y/T:P/P:M/B:A/M:M/D:T/2026-06-08T14:30:54Z/", + "decision": "Track", + "options": [ + { + "Exploitation": "none" + }, + { + "Automatable": "yes" + }, + { + "Technical Impact": "partial" + }, + { + "Mission Prevalence": "minimal" + }, + { + "Public Well-being Impact": "material" }, { - "advisory_id": "GHSA-65pc-fj4g-8rjx", - "advisory_uid": "github_osv/GHSA-65pc-fj4g-8rjx", - "aliases": [ - "CVE-2026-45409" - ], - "summary": "Internationalized Domain Names in Applications (IDNA): Specially crafted inputs to idna.encode() can bypass CVE-2024-3651 fix\nThis is the same issue as CVE-2024-3651, however the original remediation in 2024 was not a complete fix. Payloads such as `\"\\u0660\" * N` or `\"\\u30fb\" * N + \"\\u6f22\"` utilize the `valid_contexto` function prior to length rejection, and for high values of `N` will take a long time to process.\n\n### Impact\nA specially crafted argument to the `idna.encode()` function could consume significant resources. This may lead to a denial-of-service.\n\n### Patches\nStarting in version 3.14, the function rejects long inputs as soon as practicable prior to any further processing to minimize resource consumption. In version 3.15, this approach was extended to lesser used alternate functions (i.e. per-label conversions and codec support).\n\n### Workarounds\nDomain names cannot exceed 253 characters in length, if this length limit is enforced prior to passing the domain to the `idna.encode()` function it should no longer consume significant resources. This is triggered by arbitrarily large inputs that would not occur in normal usage, but may be passed to the library assuming there is no preliminary input validation by the higher-level application.", - "weighted_severity": 6.2, - "exploitability": 0.5, - "risk_score": 3.1, - "fixed_by_packages": [ - "pkg:pypi/idna@3.15" - ], - "ssvc_trees": [ - { - "vector": "SSVCv2/E:N/A:Y/T:P/P:M/B:A/M:M/D:T/2026-06-08T14:30:54Z/", - "decision": "Track", - "options": [ - { - "Exploitation": "none" - }, - { - "Automatable": "yes" - }, - { - "Technical Impact": "partial" - }, - { - "Mission Prevalence": "minimal" - }, - { - "Public Well-being Impact": "material" - }, - { - "Mission & Well-being": "medium" - } - ], - "source_url": "https://github.com/cisagov/vulnrichment/blob/develop/2026/45xxx/CVE-2026-45409.json" - } - ], - "resource_url": "http://public.vulnerablecode.io/advisories/github_osv/GHSA-65pc-fj4g-8rjx" + "Mission & Well-being": "medium" } - ], - "fixing_vulnerabilities": [], - "next_non_vulnerable_version": "3.15", - "latest_non_vulnerable_version": "3.15", - "risk_score": 3.4 + ], + "source_url": "https://github.com/cisagov/vulnrichment/blob/develop/2026/45xxx/CVE-2026-45409.json" + } + ], + "resource_url": "http://public.vulnerablecode.io/advisories/pypa/idna/PYSEC-2026-215" } - ] -} + ], + "fixing_vulnerabilities": [], + "next_non_vulnerable_version": "3.15", + "latest_non_vulnerable_version": "3.15", + "risk_score": 3.4 + } + ] +} \ No newline at end of file diff --git a/vulnerabilities/tests/test_api.py b/vulnerabilities/tests/test_api.py index 5f0f6574..0bfda9ca 100644 --- a/vulnerabilities/tests/test_api.py +++ b/vulnerabilities/tests/test_api.py @@ -96,6 +96,8 @@ def test_api_vulnerabilities_list_endpoint_filters(self): self.assertContains(response, self.vulnerability3.advisory_id) def test_api_vulnerabilities_detail_endpoint(self): + self.vulnerability1.fixed_by_packages = ["pkg:pypi/idna@3.7"] + self.vulnerability1.save() detail_url = reverse("api_v2:vulnerability-detail", args=[self.vulnerability1.uuid]) self.client.login(username="super_user", password="secret") @@ -107,6 +109,7 @@ def test_api_vulnerabilities_detail_endpoint(self): self.assertEqual(self.vulnerability1.advisory_uid, response.data["advisory_uid"]) self.assertEqual(str(self.vulnerability1.uuid), response.data["uuid"]) self.assertEqual("0.0", response.data["risk_score"]) + self.assertEqual(["pkg:pypi/idna@3.7"], response.data["fixed_by_packages"]) self.assertEqual(1, len(response.data["affected_packages"])) self.assertEqual(1, len(response.data["affected_products"])) diff --git a/vulnerabilities/tests/test_fetch.py b/vulnerabilities/tests/test_fetch.py index 636e9b4e..b24e6ca1 100644 --- a/vulnerabilities/tests/test_fetch.py +++ b/vulnerabilities/tests/test_fetch.py @@ -85,14 +85,14 @@ def test_vulnerabilities_fetch_for_packages(self, mock_bulk_search_by_purl): response_json = json.loads(response_file.read_text()) mock_bulk_search_by_purl.return_value = response_json - with self.assertNumQueries(12): + with self.assertNumQueries(13): results = fetch_for_packages( queryset, self.dataspace, batch_size=1, update=True, log_func=buffer.write ) - self.assertEqual(results, {"created": 2, "updated": 0}) + self.assertEqual(results, {"created": 3, "updated": 0}) self.assertEqual("Progress: 1/1", buffer.getvalue()) - self.assertEqual(2, package1.affected_by_vulnerabilities.count()) + self.assertEqual(3, package1.affected_by_vulnerabilities.count()) vulnerability = package1.affected_by_vulnerabilities.filter( advisory_uid="pypa/idna/PYSEC-2024-60" ).get() @@ -100,10 +100,13 @@ def test_vulnerabilities_fetch_for_packages(self, mock_bulk_search_by_purl): self.assertEqual(Decimal("0.5"), vulnerability.exploitability) self.assertEqual(Decimal("6.8"), vulnerability.weighted_severity) self.assertEqual(Decimal("3.4"), vulnerability.risk_score) + self.assertEqual(["pkg:pypi/idna@3.7"], vulnerability.fixed_by_packages) package1.refresh_from_db() pp1.refresh_from_db() self.assertEqual(Decimal("3.4"), package1.risk_score) self.assertEqual(Decimal("3.4"), pp1.weighted_risk_score) + self.assertEqual("3.15", package1.next_non_vulnerable_version) + self.assertEqual("3.15", package1.latest_non_vulnerable_version) purpose1 = make_product_item_purpose(self.dataspace, exposure_factor=0.5) pp1.raw_update(purpose=purpose1) @@ -151,9 +154,9 @@ def test_vulnerabilities_fetch_for_packages_cross_batch_no_spurious_update( mock_bulk_search_by_purl.side_effect = [response_36, response_37] results = fetch_for_packages(queryset, self.dataspace, batch_size=1, update=True) - # 2 vulnerabilities created from response_36; the shared one is NOT re-updated + # 3 vulnerabilities created from response_36; the shared one is NOT re-updated # when encountered in response_37's batch, because created_advisory_uids guards it. - self.assertEqual(results, {"created": 2, "updated": 0}) + self.assertEqual(results, {"created": 3, "updated": 0}) @mock.patch("vulnerabilities.fetch.fire_webhooks") def test_vulnerabilities_fetch_notify_vulnerability_data_update(self, mock_fire_hook): diff --git a/vulnerabilities/tests/test_models.py b/vulnerabilities/tests/test_models.py index 9f5f58e8..e0371b1c 100644 --- a/vulnerabilities/tests/test_models.py +++ b/vulnerabilities/tests/test_models.py @@ -29,6 +29,11 @@ from vulnerabilities.tests import make_vulnerability_analysis +# Command used to regenerate the idna_3.6_response.json test fixture: +# curl -s -X POST "https://public.vulnerablecode.io/api/v3/packages" \ +# -H "Content-Type: application/json" \ +# -H "User-Agent: VCIO_API_AGENT" \ +# -d '{"purls": ["pkg:pypi/idna@3.6"], "details": true}' | jq . class VulnerabilitiesModelsTestCase(TestCase): data = Path(__file__).parent / "data" @@ -44,8 +49,9 @@ def test_vulnerability_mixin_get_entry_for_package(self, mock_bulk_search): mock_bulk_search.return_value = json.loads(response_file.read_text()) affected_by_vulnerabilities = package1.get_entry_for_package(vulnerablecode) - self.assertEqual(2, len(affected_by_vulnerabilities)) + self.assertEqual(3, len(affected_by_vulnerabilities)) self.assertEqual("pypa/idna/PYSEC-2024-60", affected_by_vulnerabilities[0]["advisory_uid"]) + self.assertEqual(["pkg:pypi/idna@3.7"], affected_by_vulnerabilities[0]["fixed_by_packages"]) @mock.patch("vulnerabilities.models.AffectedByVulnerabilityMixin.get_entry_for_package") @mock.patch("dejacode_toolkit.vulnerablecode.VulnerableCode.is_configured") @@ -74,12 +80,19 @@ def test_vulnerability_mixin_fetch_vulnerabilities(self, mock_is_configured, moc package1 = make_package(self.dataspace, package_url="pkg:pypi/idna@3.6") package1.fetch_vulnerabilities() - self.assertEqual(2, Vulnerability.objects.scope(self.dataspace).count()) - self.assertEqual(2, package1.affected_by_vulnerabilities.count()) + self.assertEqual(3, Vulnerability.objects.scope(self.dataspace).count()) + self.assertEqual(3, package1.affected_by_vulnerabilities.count()) vulnerability = package1.affected_by_vulnerabilities.filter( advisory_uid="pypa/idna/PYSEC-2024-60" ).get() self.assertEqual("PYSEC-2024-60", vulnerability.advisory_id) + self.assertEqual(["pkg:pypi/idna@3.7"], vulnerability.fixed_by_packages) + + # This code path (single-package fetch) does not go through + # vulnerabilities.fetch.process_vc_entry, so the purl-level fields are not set. + package1.refresh_from_db() + self.assertEqual("", package1.next_non_vulnerable_version) + self.assertEqual("", package1.latest_non_vulnerable_version) def test_vulnerability_mixin_create_vulnerabilities(self): response_file = self.data / "vulnerabilities" / "idna_3.6_response.json" @@ -91,7 +104,7 @@ def test_vulnerability_mixin_create_vulnerabilities(self): product1 = make_product(self.dataspace, inventory=[package1]) package1.create_vulnerabilities(vulnerabilities_data) - self.assertEqual(3, Vulnerability.objects.scope(self.dataspace).count()) + self.assertEqual(4, Vulnerability.objects.scope(self.dataspace).count()) self.assertEqual("5.0", str(package1.risk_score)) self.assertEqual("5.0", str(product1.productpackages.get().weighted_risk_score)) @@ -194,17 +207,17 @@ def test_vulnerability_model_add_affected(self): self.assertQuerySetEqual(vulnerability2.affected_packages.all(), [package1]) self.assertQuerySetEqual(vulnerability2.affected_components.all(), [component1]) - def test_vulnerability_model_fixed_packages_count_generated_field(self): + def test_vulnerability_model_fixed_by_packages_count_generated_field(self): vulnerability1 = make_vulnerability(dataspace=self.dataspace) - self.assertEqual(0, vulnerability1.fixed_packages_count) + self.assertEqual(0, vulnerability1.fixed_by_packages_count) - vulnerability1.fixed_packages = [ - {"purl": "pkg:pypi/gitpython@3.1.41", "is_vulnerable": True}, - {"purl": "pkg:pypi/gitpython@3.2", "is_vulnerable": False}, + vulnerability1.fixed_by_packages = [ + "pkg:pypi/gitpython@3.1.41", + "pkg:pypi/gitpython@3.2", ] vulnerability1.save() vulnerability1.refresh_from_db() - self.assertEqual(2, vulnerability1.fixed_packages_count) + self.assertEqual(2, vulnerability1.fixed_by_packages_count) def test_vulnerability_model_create_from_data(self): package1 = make_package(self.dataspace) diff --git a/vulnerabilities/views.py b/vulnerabilities/views.py index 02bb41a1..6a5e3ef9 100644 --- a/vulnerabilities/views.py +++ b/vulnerabilities/views.py @@ -32,7 +32,7 @@ class VulnerabilityListView( Header("risk_score", _("Risk"), filter="risk_score"), Header("affected_products_count", _("Affected products"), help_text="Affected products"), Header("affected_packages_count", _("Affected packages"), help_text="Affected packages"), - Header("fixed_packages_count", _("Fixed by"), help_text="Fixed by packages"), + Header("fixed_by_packages_count", _("Fixed by"), help_text="Fixed by packages"), ) def get_queryset(self): @@ -46,7 +46,7 @@ def get_queryset(self): "resource_url", "aliases", "summary", - "fixed_packages_count", + "fixed_by_packages_count", "exploitability", "weighted_severity", "risk_score",
- {% trans 'Fixed packages' %} + {% trans 'Fixed by packages' %}
- {{ vulnerability.fixed_packages_count }} + {{ vulnerability.fixed_by_packages_count }}