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
6 changes: 6 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ Release notes
outputs.
https://github.com/aboutcode-org/dejacode/issues/98

- Add the ability to propagate vulnerability analysis data to other affected products.
A new "Propagate analysis to:" section in now displayed the "Vulnerability analysis"
modal. The list of products containing the same package as the one currently being
analysed are listed and can be selected for "analysis propagation".
https://github.com/aboutcode-org/dejacode/issues/105

### Version 5.2.1

- Fix the models documentation navigation.
Expand Down
8 changes: 8 additions & 0 deletions dejacode/static/css/dejacode_bootstrap.css
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,14 @@ table.vulnerabilities-table .column-summary {
#tab_vulnerabilities .column-vulnerability_analyses__responses {
min-width: 120px;
}
/* -- Vulnerability analysis modal -- */
#vulnerability-analysis-modal #div_id_responses .form-check {
display: inline-block;
margin-right: 1rem;
}
#vulnerability-analysis-modal .form-label {
font-weight: 600;
}

/* -- Dependency tab -- */
#tab_dependencies .column-for_package {
Expand Down
5 changes: 4 additions & 1 deletion dejacode/static/js/dejacode_main.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ function setupPopovers() {

function setupSelectionCheckboxes() {
const selectAllCheckbox = document.getElementById("checkbox-select-all");
const rowCheckboxes = document.querySelectorAll("table#object-list-table tbody input[type='checkbox']");
if (!selectAllCheckbox) return;

const parentTable = selectAllCheckbox.closest("table");
const rowCheckboxes = parentTable.querySelectorAll("tbody input[type='checkbox']");
let lastChecked; // Store the last checked checkbox

if (!rowCheckboxes) return;
Expand Down
25 changes: 0 additions & 25 deletions dje/templates/bootstrap_base_js.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,4 @@
<script>
NEXB = {};
NEXB.client_data = JSON.parse(document.getElementById("client_data").textContent);

NEXB.displayOverlay = function(text) {
const overlay = document.createElement('div');
overlay.id = 'overlay';
overlay.textContent = text;

Object.assign(overlay.style, {
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
backgroundColor: 'rgba(0, 0, 0, .5)',
zIndex: 10000,
verticalAlign: 'middle',
paddingTop: '300px',
textAlign: 'center',
color: '#fff',
fontSize: '30px',
fontWeight: 'bold',
cursor: 'wait'
});

document.body.appendChild(overlay);
}
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,19 @@
<div class="modal-header">
<div class="modal-title">
<h5>
<i class="fas fa-bug vulnerability"></i>
Vulnerability analysis:
<strong id="analysis-vulnerability-id"></strong>
</h5>
<div>
Package: <strong id="analysis-package-identifier"></strong>
<i class="fa fa-archive"></i>
<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 class="modal-body bg-body-tertiary pb-0" 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">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,9 @@
url: edit_url,
success: function(data) {
modal_body.html(data);
setupTooltips();
setupPopovers();
setupSelectionCheckboxes();
},
error: function() {
modal_body.html('Error.');
Expand All @@ -280,7 +283,7 @@
return false;
}
modal_body.html(data);
edit_modal.animate({scrollTop: 0});
vulnerability_modal.animate({scrollTop: 0});
},
error: function(){
modal_body.html('Error.');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,11 @@
</td>
<td>
{% if package.vulnerability_analysis.responses %}
{{ package.vulnerability_analysis.responses|join:"<br>" }}
<ul class="ps-3 m-0">
{% for response in package.vulnerability_analysis.responses %}
<li>{{ response }}</li>
{% endfor %}
</ul>
{% endif %}
</td>
<td class="p-1">
Expand Down
7 changes: 6 additions & 1 deletion product_portfolio/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,12 @@ def test_product_portfolio_tab_vulnerability_view_analysis_rendering(self):
</span>
</td>
<td>Code Not Present</td>
<td>can_not_fix<br>rollback</td>
<td>
<ul class="ps-3 m-0">
<li>can_not_fix</li>
<li>rollback</li>
</ul>
</td>
"""
self.assertContains(response, expected, html=True)

Expand Down
54 changes: 42 additions & 12 deletions product_portfolio/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@
from django.core.paginator import Paginator
from django.db import transaction
from django.db.models import Count
from django.db.models import ObjectDoesNotExist
from django.db.models import OuterRef
from django.db.models import Prefetch
from django.db.models import Subquery
from django.db.models.functions import Lower
from django.forms import modelformset_factory
from django.http import Http404
Expand Down Expand Up @@ -2464,35 +2465,64 @@ def improve_packages_from_purldb_view(request, dataspace, name, version=""):
@login_required
def vulnerability_analysis_form_view(request, product_uuid, vulnerability_id, package_uuid):
user = request.user
dataspace = user.dataspace
form_class = VulnerabilityAnalysisForm
perms = "change_product"

qs = Product.objects.get_queryset(user, perms=perms)
product = get_object_or_404(qs, uuid=product_uuid)
vulnerability_qs = Vulnerability.objects.scope(user.dataspace)
product_qs = Product.objects.get_queryset(user, perms=perms)
product = get_object_or_404(product_qs, uuid=product_uuid)
vulnerability_qs = Vulnerability.objects.scope(dataspace)
vulnerability = get_object_or_404(vulnerability_qs, vulnerability_id=vulnerability_id)
product_package_qs = ProductPackage.objects.product_secured(user, perms=perms)
product_package = get_object_or_404(
product_package_qs, product=product, package__uuid=package_uuid
)
vulnerability_analysis_qs = VulnerabilityAnalysis.objects.scope(dataspace)

try:
vulnerability_analysis = VulnerabilityAnalysis.objects.scope(user.dataspace).get(
product_package=product_package,
vulnerability=vulnerability,
# Fetch the existing Analysis values for each affected products
product_analysis = vulnerability_analysis_qs.filter(
product=OuterRef("pk"),
package=OuterRef("packages__pk"),
vulnerability=OuterRef("packages__affected_by_vulnerabilities__pk"),
)
affected_products = (
product_qs.exclude(pk=product.pk)
.filter(
packages__uuid=package_uuid,
packages__affected_by_vulnerabilities=vulnerability,
)
.annotate(
analysis_state=Subquery(product_analysis.values("state")[:1]),
analysis_justification=Subquery(product_analysis.values("justification")[:1]),
analysis_responses=Subquery(product_analysis.values("responses")[:1]),
analysis_detail=Subquery(product_analysis.values("detail")[:1]),
)
except ObjectDoesNotExist:
vulnerability_analysis = None # Addition
)

vulnerability_analysis = vulnerability_analysis_qs.get_or_none(
product_package=product_package,
vulnerability=vulnerability,
)

if request.method == "POST":
form = form_class(user, instance=vulnerability_analysis, data=request.POST)
form = form_class(
user,
instance=vulnerability_analysis,
data=request.POST,
affected_products=affected_products,
)
if form.is_valid():
form.save()
messages.success(request, "Vulnerability analysis successfully updated.")
return JsonResponse({"success": "updated"}, status=200)
else:
initial = {"product_package": product_package, "vulnerability": vulnerability}
form = form_class(user, instance=vulnerability_analysis, initial=initial)
form = form_class(
user,
instance=vulnerability_analysis,
initial=initial,
affected_products=affected_products,
)

rendered_form = render_crispy_form(form, context=csrf(request))

Expand Down
49 changes: 47 additions & 2 deletions vulnerabilities/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@
from django.core.exceptions import ValidationError

from crispy_forms.helper import FormHelper
from crispy_forms.layout import Field
from crispy_forms.layout import Fieldset
from crispy_forms.layout import Layout

from dje.forms import DataspacedModelForm
from dje.forms import Group
from product_portfolio.models import ProductPackage
from vulnerabilities.models import VulnerabilityAnalysis

Expand All @@ -22,6 +26,15 @@ class VulnerabilityAnalysisForm(DataspacedModelForm):
widget=forms.CheckboxSelectMultiple,
required=False,
)
propagate_to_products = forms.MultipleChoiceField(
label="Propagate analysis to:",
widget=forms.CheckboxSelectMultiple,
required=False,
help_text=(
"The listed products share the same vulnerable package. "
"The analysis values will be applied to all selected products."
),
)

class Meta:
model = VulnerabilityAnalysis
Expand All @@ -32,16 +45,26 @@ class Meta:
"justification",
"responses",
"detail",
"propagate_to_products",
]
widgets = {
"product_package": forms.widgets.HiddenInput,
"vulnerability": forms.widgets.HiddenInput,
"detail": forms.Textarea(attrs={"rows": 3}),
"detail": forms.Textarea(attrs={"rows": 2}),
}

def __init__(self, user, *args, **kwargs):
affected_products = kwargs.pop("affected_products", [])
super().__init__(user, *args, **kwargs)

propagate_to_field = self.fields["propagate_to_products"]
# Note: the whole `product` object is set as the label to be fully used in the
# template rendering.
propagate_to_products_choices = [(product.uuid, product) for product in affected_products]
propagate_to_field.choices = propagate_to_products_choices
if not propagate_to_products_choices:
propagate_to_field.widget = forms.widgets.HiddenInput()

responses_model_field = self._meta.model._meta.get_field("responses")
self.fields["responses"].help_text = responses_model_field.help_text

Expand All @@ -53,12 +76,34 @@ def __init__(self, user, *args, **kwargs):
def helper(self):
helper = FormHelper()
helper.form_method = "post"
helper.form_id = "product-vulnerability-analysis-form"
helper.form_tag = False
helper.modal_title = "Vulnerability analysis"
helper.modal_id = "vulnerability-analysis-modal"
helper.layout = Layout(
Fieldset(
"",
"product_package",
"vulnerability",
Group("state", "justification"),
"responses",
"detail",
Field(
"propagate_to_products",
template="vulnerabilities/forms/widgets/propagate_to_table.html",
),
),
)
return helper

def save(self, *args, **kwargs):
instance = super().save(*args, **kwargs)

if products := self.cleaned_data.get("propagate_to_products"):
for product_uuid in products:
instance.propagate(product_uuid, self.user)

return instance

def clean(self):
main_fields = ["state", "justification", "responses", "detail"]
if not any(self.cleaned_data.get(field_name) for field_name in main_fields):
Expand Down
26 changes: 26 additions & 0 deletions vulnerabilities/migrations/0004_vulnerabilityanalysis_indexes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Generated by Django 5.0.9 on 2024-12-04 05:20

from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('component_catalog', '0010_component_risk_score_package_risk_score'),
('dje', '0004_dataspace_vulnerabilities_updated_at'),
('product_portfolio', '0008_productdependency_is_resolved_to_is_pinned'),
('vulnerabilities', '0003_vulnerabilityanalysis'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.AddIndex(
model_name='vulnerabilityanalysis',
index=models.Index(fields=['state'], name='vulnerabili_state_12e7bb_idx'),
),
migrations.AddIndex(
model_name='vulnerabilityanalysis',
index=models.Index(fields=['justification'], name='vulnerabili_justifi_219dc2_idx'),
),
]
Loading