Skip to content

Commit

Permalink
Add ProductVulnerabilityAnalysis model implementation #98 (#187)
Browse files Browse the repository at this point in the history
Signed-off-by: tdruez <[email protected]>
  • Loading branch information
tdruez authored Dec 2, 2024
1 parent 17a5006 commit bc428e2
Show file tree
Hide file tree
Showing 23 changed files with 874 additions and 75 deletions.
12 changes: 12 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ Release notes
vulnerability data is available.
https://github.com/aboutcode-org/dejacode/issues/98

- Introduce a new VulnerabilityAnalysis model based on CycloneDX spec:
https://cyclonedx.org/docs/1.6/json/#vulnerabilities_items_analysis
A VulnerabilityAnalysis is always assigned to a Vulnerability object and a
ProductPackage relation.
The values for a VulnerabilityAnalysis are display in the Product "Vulnerabilities"
tab.
A "Edit" button can be used to open a form in a model to provided analysis data.
Those new VEX related columns can be sorted and filtered.
The VulnerabilityAnalysis data is exported in the VEX (only) and SBOM+VEX (combined)
outputs.
https://github.com/aboutcode-org/dejacode/issues/98

### Version 5.2.1

- Fix the models documentation navigation.
Expand Down
8 changes: 5 additions & 3 deletions component_catalog/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ class ComponentFilterSet(DataspacedFilterSet):
"primary_language",
"usage_policy",
]
dropdown_fields = [
"type",
"usage_policy",
]
q = MatchOrderedSearchFilter(
label=_("Search"),
match_order_fields=["name"],
Expand Down Expand Up @@ -122,9 +126,7 @@ class Meta:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.filters["usage_policy"].extra["to_field_name"] = "label"
self.filters["usage_policy"].extra["widget"] = DropDownRightWidget()
self.filters["type"].extra["to_field_name"] = "label"
self.filters["type"].extra["widget"] = DropDownRightWidget()

@cached_property
def sort_value(self):
Expand Down Expand Up @@ -180,6 +182,7 @@ def filter(self, qs, value):


class PackageFilterSet(DataspacedFilterSet):
dropdown_fields = ["usage_policy"]
q = PackageSearchFilter(
label=_("Search"),
match_order_fields=[
Expand Down Expand Up @@ -262,7 +265,6 @@ class Meta:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.filters["usage_policy"].extra["to_field_name"] = "label"
self.filters["usage_policy"].extra["widget"] = DropDownRightWidget()

@cached_property
def sort_value(self):
Expand Down
20 changes: 17 additions & 3 deletions dejacode/static/css/dejacode_bootstrap.css
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ table.text-break thead {
.bg-warning-orange {
background-color: var(--bs-orange);
}

/* -- Dark there fixes -- */
[data-bs-theme=dark] .btn-outline-dark {
--bs-btn-color: var(--bs-tertiary-color);
Expand Down Expand Up @@ -390,17 +391,26 @@ table.vulnerabilities-table .column-summary {
min-width: 300px;
}
#tab_vulnerabilities .column-exploitability {
width: 150px;
width: 155px;
}
#tab_vulnerabilities .column-weighted_severity {
width: 115px;
width: 120px;
}
#tab_vulnerabilities .column-risk_score {
width: 95px;
width: 90px;
}
#tab_vulnerabilities .column-summary {
width: 300px;
}
#tab_vulnerabilities .column-vulnerability_analyses__state {
min-width: 105px;
}
#tab_vulnerabilities .column-vulnerability_analyses__justification {
min-width: 130px;
}
#tab_vulnerabilities .column-vulnerability_analyses__responses {
min-width: 120px;
}

/* -- Dependency tab -- */
#tab_dependencies .column-for_package {
Expand Down Expand Up @@ -540,6 +550,10 @@ table.purldb-table .column-license_expression {
vertical-align: middle;
}

#vulnerability-analysis-form fieldset legend {
font-size: 1rem;
}

/* -- Object form (add/edit) -- */
.datepicker {
width: 130px;
Expand Down
6 changes: 6 additions & 0 deletions dje/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
from dje.utils import extract_name_version
from dje.utils import get_uuids_list_sorted
from dje.utils import remove_field_from_query_dict
from dje.widgets import DropDownRightWidget

IS_FILTER_LOOKUP_VAR = "_filter_lookup"

Expand Down Expand Up @@ -80,6 +81,7 @@ def get_filters_breadcrumbs(self):

class DataspacedFilterSet(FilterSetUtilsMixin, django_filters.FilterSet):
related_only = []
dropdown_fields = []

def __init__(self, *args, **kwargs):
try:
Expand All @@ -90,6 +92,7 @@ def __init__(self, *args, **kwargs):
self.dynamic_qs = kwargs.pop("dynamic_qs", True)
self.parent_qs_cache = {}
self.anchor = kwargs.pop("anchor", None)
self.dropdown_fields = kwargs.pop("dropdown_fields", []) or self.dropdown_fields

super().__init__(*args, **kwargs)

Expand All @@ -106,6 +109,9 @@ def __init__(self, *args, **kwargs):
model_name = self._meta.model._meta.model_name
usage_policy.queryset = usage_policy.queryset.filter(content_type__model=model_name)

for field_name in self.dropdown_fields:
self.filters[field_name].extra["widget"] = DropDownRightWidget(anchor=self.anchor)

def apply_related_only(self, field_name, filter_):
"""
Limit the filter choices to the values used on the parent queryset.
Expand Down
18 changes: 14 additions & 4 deletions dje/outputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,20 @@ def get_cyclonedx_bom(instance, user, include_components=True, include_vex=False

if include_vex:
vulnerability_qs = instance.get_vulnerability_qs(prefetch_related_packages=True)
vulnerabilities = [
vulnerability.as_cyclonedx(affected_instances=vulnerability.affected_packages.all())
for vulnerability in vulnerability_qs
]
vulnerabilities = []
for vulnerability in vulnerability_qs:
analysis = None
vulnerability_analyses = vulnerability.vulnerability_analyses.all()
if len(vulnerability_analyses) == 1:
analysis = vulnerability_analyses[0]

vulnerabilities.append(
vulnerability.as_cyclonedx(
affected_instances=vulnerability.affected_packages.all(),
analysis=analysis,
)
)

bom.vulnerabilities = vulnerabilities

return bom
Expand Down
3 changes: 3 additions & 0 deletions dje/templates/includes/object_list_table_header.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,8 @@
{% endif %}
</th>
{% endfor %}
{% if include_actions %}
<th class="column-action" scope="col"></th>
{% endif %}
</tr>
</thead>
23 changes: 22 additions & 1 deletion dje/tests/test_outputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
# See https://aboutcode.org for more information about AboutCode FOSS projects.
#

import json

from django.test import TestCase

from cyclonedx.model import bom as cyclonedx_bom
Expand All @@ -18,6 +20,7 @@
from dje.tests import create_user
from product_portfolio.models import Product
from product_portfolio.tests import make_product_package
from vulnerabilities.models import VulnerabilityAnalysis
from vulnerabilities.tests import make_vulnerability


Expand Down Expand Up @@ -97,7 +100,7 @@ def test_outputs_get_cyclonedx_bom(self):

def test_outputs_get_cyclonedx_bom_include_vex(self):
package_in_product = make_package(self.dataspace, package_url="pkg:type/name")
make_product_package(self.product1, package_in_product)
product_package1 = make_product_package(self.product1, package_in_product)
package_not_in_product = make_package(self.dataspace)
vulnerability1 = make_vulnerability(
self.dataspace, affecting=[package_in_product, package_not_in_product]
Expand All @@ -112,6 +115,24 @@ def test_outputs_get_cyclonedx_bom_include_vex(self):
self.assertIsInstance(bom, cyclonedx_bom.Bom)
self.assertEqual(1, len(bom.vulnerabilities))
self.assertEqual(vulnerability1.vulnerability_id, bom.vulnerabilities[0].id)
self.assertIsNone(bom.vulnerabilities[0].analysis)

VulnerabilityAnalysis.objects.create(
product_package=product_package1,
vulnerability=vulnerability1,
state=VulnerabilityAnalysis.State.RESOLVED,
justification=VulnerabilityAnalysis.Justification.CODE_NOT_PRESENT,
detail="detail",
dataspace=self.dataspace,
)
bom = outputs.get_cyclonedx_bom(
instance=self.product1,
user=self.super_user,
include_vex=True,
)
analysis = bom.vulnerabilities[0].analysis
expected = {"detail": "detail", "justification": "code_not_present", "state": "resolved"}
self.assertEqual(expected, json.loads(analysis.as_json()))

def test_outputs_get_cyclonedx_bom_json(self):
bom = outputs.get_cyclonedx_bom(instance=self.product1, user=self.super_user)
Expand Down
7 changes: 4 additions & 3 deletions license_library/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ class LicenseFilterSet(DataspacedFilterSet):
"license_profile",
"usage_policy",
]
dropdown_fields = [
"category__license_type",
"usage_policy",
]
q = MatchOrderedSearchFilter(
label=_("Search"),
match_order_fields=["short_name", "key", "name"],
Expand Down Expand Up @@ -101,6 +105,3 @@ def __init__(self, *args, **kwargs):
self.filters["usage_policy"].extra["to_field_name"] = "label"
self.filters["usage_policy"].label = _("Policy")
self.filters["category__license_type"].label = _("Type")

for filter_name in ["category__license_type", "usage_policy"]:
self.filters[filter_name].extra["widget"] = DropDownRightWidget()
19 changes: 16 additions & 3 deletions product_portfolio/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
from dje.validators import validate_version
from vulnerabilities.fetch import fetch_for_packages
from vulnerabilities.models import Vulnerability
from vulnerabilities.models import VulnerabilityAnalysis

RELATION_LICENSE_EXPRESSION_HELP_TEXT = _(
"The License Expression assigned to a DejaCode Product Package or Product "
Expand Down Expand Up @@ -335,6 +336,10 @@ def all_packages(self):
models.Q(id__in=self.packages.all()) | models.Q(component__in=self.components.all())
).distinct()

@cached_property
def vulnerability_count(self):
return self.get_vulnerability_qs().count()

def get_merged_descendant_ids(self):
"""
Return a list of Component ids collected on the Product descendants:
Expand Down Expand Up @@ -514,13 +519,21 @@ def fetch_vulnerabilities(self):

def get_vulnerability_qs(self, prefetch_related_packages=False):
"""Return a QuerySet of all Vulnerability instances related to this product"""
qs = Vulnerability.objects.filter(affected_packages__in=self.packages.all())
vulnerability_qs = Vulnerability.objects.filter(
affected_packages__in=self.packages.all()
).distinct()

if prefetch_related_packages:
package_qs = Package.objects.filter(product=self).only_rendering_fields()
qs = qs.prefetch_related(models.Prefetch("affected_packages", package_qs))
analysis_qs = VulnerabilityAnalysis.objects.filter(product=self).select_related(
"package"
)
vulnerability_qs = vulnerability_qs.prefetch_related(
models.Prefetch("affected_packages", package_qs),
models.Prefetch("vulnerability_analyses", analysis_qs),
)

return qs
return vulnerability_qs


class ProductRelationStatus(BaseStatusMixin, DataspacedModel):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{% load crispy_forms_tags %}
<div id="vulnerability-analysis-modal" class="modal modal-lg" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title">
<h5>
Vulnerability analysis:
<strong id="analysis-vulnerability-id"></strong>
</h5>
<div>
Package: <strong id="analysis-package-identifier"></strong>
</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form autocomplete="off" method="post" id="vulnerability-analysis-form">
<div class="modal-body bg-body-tertiary" id="vulnerability-analysis-modal-body">
</div>
<div class="modal-footer">
<input type="button" name="close" value="Close" class="btn btn-secondary" data-bs-dismiss="modal">
<input type="submit" id="submit-vulnerability-analysis-form" value="Submit" class="btn btn-primary btn-success">
</div>
</form>
</div>
</div>
</div>
60 changes: 60 additions & 0 deletions product_portfolio/templates/product_portfolio/product_details.html
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@
{% if tabsets.Imports %}
{% include 'product_portfolio/includes/scancode_project_status_modal.html' %}
{% endif %}
{% if request.user.dataspace.enable_vulnerablecodedb_access and product.vulnerability_count %}
{% include 'product_portfolio/modals/vulnerability_analysis_modal.html' %}
{% endif %}
{% endblock %}

{% block extrastyle %}
Expand Down Expand Up @@ -232,6 +235,63 @@
</script>
{% endif %}

{% if request.user.dataspace.enable_vulnerablecodedb_access and product.vulnerability_count %}
<script>
$(document).ready(function () {
let vulnerability_modal = $('#vulnerability-analysis-modal');
vulnerability_modal.on('show.bs.modal', function (event) {
let modal_body = $('#vulnerability-analysis-modal-body');
modal_body.html(''); // Reset the modal content

let button = $(event.relatedTarget); // Button that triggered the modal
// Extract info from data-* attributes
let edit_url = button.data('edit-url');
let vulnerability_id = button.data('vulnerability-id');
let package_identifier = button.data('package-identifier');

$('#submit-vulnerability-analysis-form').data('edit-url', edit_url);
$('#vulnerability-analysis-modal #analysis-vulnerability-id').text(vulnerability_id);
$('#vulnerability-analysis-modal #analysis-package-identifier').text(package_identifier);

$.ajax({
url: edit_url,
success: function(data) {
modal_body.html(data);
},
error: function() {
modal_body.html('Error.');
}
});
});

$('#submit-vulnerability-analysis-form').on('click', function(event){
event.preventDefault();
let modal_body = $('#vulnerability-analysis-modal-body');
let edit_url = $('#submit-vulnerability-analysis-form').data('edit-url');

$.ajax({
url: edit_url,
type: 'POST',
headers: {'X-CSRFToken': csrftoken},
data: $('#vulnerability-analysis-form').serialize(),
success: function(data) {
if (data['success']) {
location.reload();
return false;
}
modal_body.html(data);
edit_modal.animate({scrollTop: 0});
},
error: function(){
modal_body.html('Error.');
}
});
});

});
</script>
{% endif %}

{% if purldb_enabled %}
<script>
document.addEventListener('DOMContentLoaded', function () {
Expand Down
Loading

0 comments on commit bc428e2

Please sign in to comment.