From 9a2fc4116beed788afe114a701f3afa028276fe2 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 27 Aug 2026 09:43:38 +0200 Subject: [PATCH 01/10] rename fixed_by_packages and add non_vulnerable_version Signed-off-by: tdruez --- ..._latest_non_vulnerable_version_and_more.py | 33 +++++++++++++++++++ ..._latest_non_vulnerable_version_and_more.py | 23 +++++++++++++ ...erability_fixed_packages_count_and_more.py | 32 ++++++++++++++++++ vulnerabilities/models.py | 18 +++++++--- 4 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 component_catalog/migrations/0015_component_latest_non_vulnerable_version_and_more.py create mode 100644 product_portfolio/migrations/0020_product_latest_non_vulnerable_version_and_more.py create mode 100644 vulnerabilities/migrations/0012_remove_vulnerability_fixed_packages_count_and_more.py 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..0e71586d --- /dev/null +++ b/component_catalog/migrations/0015_component_latest_non_vulnerable_version_and_more.py @@ -0,0 +1,33 @@ +# 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='component', + 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='component', + 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), + ), + 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/product_portfolio/migrations/0020_product_latest_non_vulnerable_version_and_more.py b/product_portfolio/migrations/0020_product_latest_non_vulnerable_version_and_more.py new file mode 100644 index 00000000..0797a545 --- /dev/null +++ b/product_portfolio/migrations/0020_product_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 = [ + ('product_portfolio', '0019_productaffectedbyvulnerability_detected_date'), + ] + + operations = [ + migrations.AddField( + model_name='product', + 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='product', + 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/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..7396bf78 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, ) @@ -439,6 +439,16 @@ class AffectedByVulnerabilityMixin(models.Model): "indicate greater vulnerability risk for the 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."), + ) class Meta: abstract = True From ba97b31bd40a1537f747187a7381a0394489c858 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 27 Aug 2026 09:44:34 +0200 Subject: [PATCH 02/10] add new fields in API Signed-off-by: tdruez --- component_catalog/api.py | 4 ++++ product_portfolio/api.py | 2 ++ vulnerabilities/api.py | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/component_catalog/api.py b/component_catalog/api.py index e37e6ac4..63f55fe9 100644 --- a/component_catalog/api.py +++ b/component_catalog/api.py @@ -332,6 +332,8 @@ class Meta: "license_choices", "declared_license_expression", "other_license_expression", + "next_non_vulnerable_version", + "latest_non_vulnerable_version", "created_date", "last_modified_date", ) @@ -697,6 +699,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/product_portfolio/api.py b/product_portfolio/api.py index d6d8d75e..6f43b6b5 100644 --- a/product_portfolio/api.py +++ b/product_portfolio/api.py @@ -148,6 +148,8 @@ class Meta: "admin_notes", "notice_text", "vulnerabilities_risk_threshold", + "next_non_vulnerable_version", + "latest_non_vulnerable_version", "created_date", "last_modified_date", ) 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", From dd0cfa383ff7c51cd3ef07bc1d43d12554554aca Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 27 Aug 2026 09:47:44 +0200 Subject: [PATCH 03/10] only on Package model Signed-off-by: tdruez --- component_catalog/api.py | 2 -- ..._latest_non_vulnerable_version_and_more.py | 10 -------- component_catalog/models.py | 11 +++++++++ product_portfolio/api.py | 2 -- ..._latest_non_vulnerable_version_and_more.py | 23 ------------------- vulnerabilities/models.py | 10 -------- 6 files changed, 11 insertions(+), 47 deletions(-) delete mode 100644 product_portfolio/migrations/0020_product_latest_non_vulnerable_version_and_more.py diff --git a/component_catalog/api.py b/component_catalog/api.py index 63f55fe9..104430d9 100644 --- a/component_catalog/api.py +++ b/component_catalog/api.py @@ -332,8 +332,6 @@ class Meta: "license_choices", "declared_license_expression", "other_license_expression", - "next_non_vulnerable_version", - "latest_non_vulnerable_version", "created_date", "last_modified_date", ) 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 index 0e71586d..40616568 100644 --- 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 @@ -10,16 +10,6 @@ class Migration(migrations.Migration): ] operations = [ - migrations.AddField( - model_name='component', - 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='component', - 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), - ), migrations.AddField( model_name='package', name='latest_non_vulnerable_version', diff --git a/component_catalog/models.py b/component_catalog/models.py index 55902b65..a94493d7 100644 --- a/component_catalog/models.py +++ b/component_catalog/models.py @@ -1977,6 +1977,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/product_portfolio/api.py b/product_portfolio/api.py index 6f43b6b5..d6d8d75e 100644 --- a/product_portfolio/api.py +++ b/product_portfolio/api.py @@ -148,8 +148,6 @@ class Meta: "admin_notes", "notice_text", "vulnerabilities_risk_threshold", - "next_non_vulnerable_version", - "latest_non_vulnerable_version", "created_date", "last_modified_date", ) diff --git a/product_portfolio/migrations/0020_product_latest_non_vulnerable_version_and_more.py b/product_portfolio/migrations/0020_product_latest_non_vulnerable_version_and_more.py deleted file mode 100644 index 0797a545..00000000 --- a/product_portfolio/migrations/0020_product_latest_non_vulnerable_version_and_more.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 6.0.6 on 2026-08-27 07:41 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('product_portfolio', '0019_productaffectedbyvulnerability_detected_date'), - ] - - operations = [ - migrations.AddField( - model_name='product', - 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='product', - 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/vulnerabilities/models.py b/vulnerabilities/models.py index 7396bf78..238f1e55 100644 --- a/vulnerabilities/models.py +++ b/vulnerabilities/models.py @@ -439,16 +439,6 @@ class AffectedByVulnerabilityMixin(models.Model): "indicate greater vulnerability risk for the 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."), - ) class Meta: abstract = True From e670ab382f53cdff93c70d9334ce87174120f1f8 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 27 Aug 2026 09:49:44 +0200 Subject: [PATCH 04/10] update test data Signed-off-by: tdruez --- .../vulnerabilities/idna_3.6_response.json | 237 +++++++++++------- vulnerabilities/tests/test_models.py | 5 + 2 files changed, 147 insertions(+), 95 deletions(-) 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_models.py b/vulnerabilities/tests/test_models.py index 9f5f58e8..e56070c0 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" From 9ae7bb6e6f47595903461c2390024dd9aa19f9e8 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 27 Aug 2026 09:53:00 +0200 Subject: [PATCH 05/10] fetch new fields Signed-off-by: tdruez --- vulnerabilities/fetch.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) 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 From bb42865d716789983a3a4f4eecb057af5d6e9a90 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 27 Aug 2026 09:56:34 +0200 Subject: [PATCH 06/10] update UI Signed-off-by: tdruez --- component_catalog/views.py | 41 +++---------------- product_portfolio/views.py | 2 +- vulnerabilities/filters.py | 2 +- .../tables/vulnerability_list_table.html | 2 +- vulnerabilities/views.py | 4 +- 5 files changed, 11 insertions(+), 40 deletions(-) diff --git a/component_catalog/views.py b/component_catalog/views.py index a650e000..473e9b6a 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 @@ -280,53 +279,25 @@ 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'
       
-        {{ vulnerability.fixed_packages_count }}
+        {{ vulnerability.fixed_by_packages_count }}
       
     
   {% empty %}
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",

From d1ebe07183a22b22a9695eb1f495f2a800fec261 Mon Sep 17 00:00:00 2001
From: tdruez 
Date: Thu, 27 Aug 2026 10:13:44 +0200
Subject: [PATCH 07/10] display new fields in UI

Signed-off-by: tdruez 
---
 .../tabs/tab_vulnerabilities.html             | 30 ++++++++++++++-----
 component_catalog/views.py                    |  5 ++--
 dejacode/static/css/dejacode_bootstrap.css    |  6 ----
 dje/copier.py                                 |  2 ++
 .../includes/exploitability.html              |  7 ++---
 .../includes/risk_score_badge.html            |  8 ++---
 6 files changed, 34 insertions(+), 24 deletions(-)

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/views.py b/component_catalog/views.py index 473e9b6a..2453a58e 100644 --- a/component_catalog/views.py +++ b/component_catalog/views.py @@ -260,7 +260,8 @@ def tab_vulnerabilities(self): label = ( f"Vulnerabilities" - f' {len(vulnerabilities_qs)}' + f' ' + f"{len(vulnerabilities_qs)}" ) vulnerabilities = [] @@ -299,7 +300,7 @@ def get_fixed_packages_html(self, vulnerability, dataspace): 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..246dbbb5 100644 --- a/dejacode/static/css/dejacode_bootstrap.css +++ b/dejacode/static/css/dejacode_bootstrap.css @@ -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/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 %} From 8a0dbba46e165ed3f10fe7e88bcc161513785094 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 27 Aug 2026 10:23:25 +0200 Subject: [PATCH 08/10] display Non-vulnerable version in UI Signed-off-by: tdruez --- product_portfolio/filters.py | 4 +++- .../tabs/tab_packages_vulnerabilities.html | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) 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 %} From fb6640b4263e0df9a103798d358dcc7f0cea191f Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 27 Aug 2026 10:42:21 +0200 Subject: [PATCH 09/10] fix unit tests Signed-off-by: tdruez --- dejacode/static/css/dejacode_bootstrap.css | 4 ++-- vulnerabilities/tests/test_fetch.py | 13 ++++++---- vulnerabilities/tests/test_models.py | 28 ++++++++++++++-------- 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/dejacode/static/css/dejacode_bootstrap.css b/dejacode/static/css/dejacode_bootstrap.css index 246dbbb5..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; 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 e56070c0..e0371b1c 100644 --- a/vulnerabilities/tests/test_models.py +++ b/vulnerabilities/tests/test_models.py @@ -49,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") @@ -79,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" @@ -96,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)) @@ -199,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) From 002948a11554ba82fe9f86b0467a9d60d9ab2ef6 Mon Sep 17 00:00:00 2001 From: tdruez Date: Thu, 27 Aug 2026 10:55:16 +0200 Subject: [PATCH 10/10] fix unit tests Signed-off-by: tdruez --- component_catalog/models.py | 1 + component_catalog/tests/test_api.py | 8 +++++++- component_catalog/tests/test_views.py | 17 +++++++++++++++++ dje/tests/testfiles/test_dataset_cc_only.json | 4 +++- dje/tests/testfiles/test_dataset_pp_only.json | 4 +++- vulnerabilities/tests/test_api.py | 3 +++ 6 files changed, 34 insertions(+), 3 deletions(-) diff --git a/component_catalog/models.py b/component_catalog/models.py index a94493d7..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", ) 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/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/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"]))
- {% trans 'Fixed packages' %} + {% trans 'Fixed by packages' %}