Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions component_catalog/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
),
]
12 changes: 12 additions & 0 deletions component_catalog/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
Expand Down Expand Up @@ -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)()

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
{% load i18n %}
<dl class="row mb-3">
<dt class="col-sm-1 text-end pe-0">
<span class="help_text" data-bs-placement="right" data-bs-toggle="tooltip" data-bs-title="Risk score between 0.0 and 10.0, where higher values indicate greater vulnerability risk for the package.">
<div class="d-flex flex-wrap align-items-center gap-4 mb-3">
<div class="d-flex align-items-center gap-2">
<span class="help_text" data-bs-toggle="tooltip" data-bs-title="Risk score between 0.0 and 10.0, where higher values indicate greater vulnerability risk for the package.">
Risk score
</span>
</dt>
<dd class="col-sm-11 fs-110pct">
{% include 'vulnerabilities/includes/risk_score_badge.html' with risk_score=package.risk_score only %}
</dd>
</dl>
</div>
{% if package.next_non_vulnerable_version %}
<div class="d-flex align-items-center gap-2">
<span class="help_text" data-bs-toggle="tooltip" data-bs-title="The next version, following this one, that is not vulnerable.">
Next non-vulnerable version
</span>
<span class="fw-semibold">{{ package.next_non_vulnerable_version }}</span>
</div>
{% endif %}
{% if package.latest_non_vulnerable_version %}
<div class="d-flex align-items-center gap-2">
<span class="help_text" data-bs-toggle="tooltip" data-bs-title="The latest available version that is not vulnerable.">
Latest non-vulnerable version
</span>
<span class="fw-semibold">{{ package.latest_non_vulnerable_version }}</span>
</div>
{% endif %}
</div>
<table class="table table-bordered table-hover table-md text-break">
<thead>
<tr>
Expand Down Expand Up @@ -39,7 +53,7 @@
</th>
<th style="min-width: 320px;">
<span class="help_text" data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-title="The identifiers of Package Versions that have been reported to fix a specific vulnerability and collected in VulnerableCodeDB.">
{% trans 'Fixed packages' %}
{% trans 'Fixed by packages' %}
</span>
</th>
</tr>
Expand Down
8 changes: 7 additions & 1 deletion component_catalog/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"],
Expand Down
17 changes: 17 additions & 0 deletions component_catalog/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
46 changes: 9 additions & 37 deletions component_catalog/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -261,7 +260,8 @@ def tab_vulnerabilities(self):

label = (
f"Vulnerabilities"
f' <span class="badge badge-vulnerability">{len(vulnerabilities_qs)}</span>'
f' <span class="badge bg-danger-subtle text-danger-emphasis">'
f"{len(vulnerabilities_qs)}</span>"
)

vulnerabilities = []
Expand All @@ -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 = (
'<span data-bs-toggle="tooltip" title="Vulnerabilities"'
' data-boundary="viewport">'
'<i class="fas fa-bug vulnerability mx-1"></i>'
"</span>"
)
no_vulnerabilities_icon = (
'<span class="fa-stack fa-small text-muted-light ms-1"'
' data-bs-toggle="tooltip" title="No vulnerabilities found"'
' data-boundary="viewport">'
' <i class="fas fa-bug fa-stack-1x"></i>'
' <i class="fas fa-ban fa-stack-2x"></i>'
"</span>"
)

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 <pre>
display_value += (
display_value = (
f"{purl.replace('pkg:', '')}"
f'<a href="{add_package_url}?package_url={purl}"'
f' target="_blank">'
f' class="ms-1" target="_blank">'
f'<span data-bs-toggle="tooltip" title="Add Package"'
f' data-boundary="viewport">'
f'<i class="fas fa-plus-circle"></i>'
Expand Down
10 changes: 2 additions & 8 deletions dejacode/static/css/dejacode_bootstrap.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions dje/copier.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@
"default_assignee",
"affected_by_vulnerabilities",
"risk_score",
"next_non_vulnerable_version",
"latest_non_vulnerable_version",
]


Expand Down
4 changes: 3 additions & 1 deletion dje/tests/testfiles/test_dataset_cc_only.json
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,9 @@
"api_data_url": "",
"datasource_id": "",
"file_references": [],
"parties": []
"parties": [],
"next_non_vulnerable_version": "",
"latest_non_vulnerable_version": ""
}
},
{
Expand Down
4 changes: 3 additions & 1 deletion dje/tests/testfiles/test_dataset_pp_only.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@
"api_data_url": "",
"datasource_id": "",
"file_references": [],
"parties": []
"parties": [],
"next_non_vulnerable_version": "",
"latest_non_vulnerable_version": ""
}
},
{
Expand Down
4 changes: 3 additions & 1 deletion product_portfolio/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<i class="fa-solid fa-circle-radiation"></i>'
is_reachable.extra[
"widget"
].link_content = '<i class="fa-solid fa-circle-radiation me-1"></i>'


class ComponentCompletenessListFilter(admin.SimpleListFilter):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@
<i class="fas fa-arrow-alt-circle-right"></i> {% trans "Exposure factor:" %} {{ product_package.purpose.exposure_factor }}
</div>
{% endif %}
{% if product_package.package.latest_non_vulnerable_version %}
<div class="small text-body-secondary mt-2">
<i class="fa-solid fa-circle-up text-success" data-bs-toggle="tooltip" title="Latest non-vulnerable version"></i>
{% trans "Non-vulnerable version available:" %}
<span class="fw-semibold text-success-emphasis">{{ product_package.package.latest_non_vulnerable_version }}</span>
</div>
{% endif %}
</td>
{% for vulnerability in product_package.display_vulnerabilities %}
{% if not forloop.first %}<tr>{% endif %}
Expand Down
2 changes: 1 addition & 1 deletion product_portfolio/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down
2 changes: 1 addition & 1 deletion vulnerabilities/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ class Meta:
"weighted_severity",
"risk_score",
"risk_level",
"fixed_packages",
"fixed_by_packages",
"ssvc_trees",
"affected_packages",
"affected_products",
Expand Down
19 changes: 15 additions & 4 deletions vulnerabilities/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion vulnerabilities/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
Expand Down
Loading
Loading