Skip to content

Commit 77e9052

Browse files
authored
Merge pull request #2316 from aboutcode-org/package-curation-workflow-ui
Add curation dashboard to resolve conflicting package version range
2 parents 7c8b646 + c2fedd3 commit 77e9052

12 files changed

Lines changed: 833 additions & 36 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Generated by Django 5.2.11 on 2026-05-27 08:41
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("vulnerabilities", "0132_migrate_advisoryv2_datasource_ids"),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name="advisorytodov2",
15+
name="issue_detail",
16+
field=models.JSONField(
17+
blank=True, default=dict, help_text="Additional details about the issue."
18+
),
19+
),
20+
]

vulnerabilities/models.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2572,8 +2572,9 @@ class AdvisoryToDoV2(models.Model):
25722572
help_text="Select the issue that needs to be addressed from the available options.",
25732573
)
25742574

2575-
issue_detail = models.TextField(
2575+
issue_detail = models.JSONField(
25762576
blank=True,
2577+
default=dict,
25772578
help_text="Additional details about the issue.",
25782579
)
25792580

@@ -3010,7 +3011,7 @@ def todo_excluded(self):
30103011
"""Exclude advisory ineligible for ToDo computation."""
30113012
from vulnerabilities.importers import TODO_EXCLUDED_PIPELINES
30123013

3013-
return self.exclude(datasource_id__in=TODO_EXCLUDED_PIPELINES)
3014+
return self.exclude(pipeline_id__in=TODO_EXCLUDED_PIPELINES)
30143015

30153016

30163017
class AdvisorySet(models.Model):
@@ -3168,6 +3169,8 @@ class AdvisoryV2(models.Model):
31683169
choices=AdvisoryStatusType.choices, default=AdvisoryStatusType.PUBLISHED
31693170
)
31703171

3172+
# Note: Fields and relations below are not part of original upstream advisory.
3173+
31713174
exploitability = models.DecimalField(
31723175
null=True,
31733176
blank=True,

vulnerabilities/pipelines/v2_improvers/compute_advisory_todo.py

Lines changed: 107 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from django.db.models import Prefetch
1818
from django.utils import timezone
1919
from packageurl import PackageURL
20+
from univers.version_range import RANGE_CLASS_BY_SCHEMES
2021

2122
from vulnerabilities.importer import AdvisoryDataV2
2223
from vulnerabilities.models import AdvisoryAlias
@@ -281,7 +282,7 @@ def check_missing_summary(
281282
todo_to_create,
282283
advisory_relation_to_create,
283284
):
284-
alias = advisory.datasource_id.rsplit("/", 1)[-1]
285+
alias = advisory.advisory_id.rsplit("/", 1)[-1]
285286
oldest_advisory_date = advisory.date_published or advisory.date_collected
286287
if not advisory.summary:
287288
todo = AdvisoryToDoV2(
@@ -333,7 +334,7 @@ def check_missing_affected_and_fixed_by_packages(
333334
elif not has_fixed_package:
334335
issue_type = "MISSING_FIXED_BY_PACKAGE"
335336

336-
alias = advisory.datasource_id.rsplit("/", 1)[-1]
337+
alias = advisory.advisory_id.rsplit("/", 1)[-1]
337338
oldest_advisory_date = advisory.date_published or advisory.date_collected
338339
if issue_type:
339340
todo = AdvisoryToDoV2(
@@ -360,12 +361,12 @@ def compute_version_range_disagreement(adv_map):
360361
fixed_intersection = set.intersection(*fixed_sets)
361362

362363
return {
363-
"affected_union": affected_union,
364-
"affected_intersection": affected_intersection,
365-
"affected_disagreement": affected_union - affected_intersection,
366-
"fixed_union": fixed_union,
367-
"fixed_intersection": fixed_intersection,
368-
"fixed_disagreement": fixed_union - fixed_intersection,
364+
"affected_union": list(affected_union),
365+
"affected_intersection": list(affected_intersection),
366+
"affected_disagreement": list(affected_union - affected_intersection),
367+
"fixed_union": list(fixed_union),
368+
"fixed_intersection": list(fixed_intersection),
369+
"fixed_disagreement": list(fixed_union - fixed_intersection),
369370
}
370371

371372

@@ -417,6 +418,7 @@ def check_conflicting_affected_and_fixed_by_packages_for_alias(
417418
"""
418419
conflicting_package_details = {}
419420

421+
curation_items = []
420422
has_conflicting_affected_packages = False
421423
has_conflicting_fixed_package = False
422424
conflicting_advisories = set()
@@ -433,6 +435,9 @@ def check_conflicting_affected_and_fixed_by_packages_for_alias(
433435
conflicting_package_details[purl] = {
434436
"avids": list(adv_map.keys()),
435437
}
438+
curation_items.append(
439+
get_grouped_curation_advisories_for_dashboard_ui(purl, adv_map, result, advisories)
440+
)
436441
conflicting_advisories.update([advisories[avid] for avid in adv_map])
437442
conflicting_package_details[purl].update(result)
438443

@@ -462,6 +467,7 @@ def check_conflicting_affected_and_fixed_by_packages_for_alias(
462467
"conflict_checksum": conflict_checksum,
463468
"conflict_details": conflicting_package_details,
464469
"partial_curation_advisory": partial_merged_advisory,
470+
"curation_items": curation_items,
465471
}
466472

467473
todo_id = advisories_checksum(conflicting_advisories)
@@ -484,7 +490,7 @@ def check_conflicting_affected_and_fixed_by_packages_for_alias(
484490
todo = AdvisoryToDoV2(
485491
related_advisories_id=todo_id,
486492
issue_type=issue_type,
487-
issue_detail=json.dumps(issue_detail, default=list),
493+
issue_detail=issue_detail,
488494
alias=alias,
489495
advisories_count=conflicting_advisories_count,
490496
oldest_advisory_date=date_published or date_collected,
@@ -495,6 +501,94 @@ def check_conflicting_affected_and_fixed_by_packages_for_alias(
495501
return conflicting_package_count, conflicting_advisories_count
496502

497503

504+
def get_disagreement_message(fixed_disagreement, affected_disagreement):
505+
messages = []
506+
507+
if affected_disagreement:
508+
affected = ", ".join(affected_disagreement)
509+
noun = "version" if len(affected_disagreement) == 1 else "versions"
510+
verb = "is" if len(affected_disagreement) == 1 else "are"
511+
512+
messages.append(f"Advisories do not agree whether {noun} {affected} {verb} affected.")
513+
514+
if fixed_disagreement:
515+
fixed = ", ".join(fixed_disagreement)
516+
noun = "version" if len(fixed_disagreement) == 1 else "versions"
517+
verb = "contains" if len(fixed_disagreement) == 1 else "contain"
518+
519+
messages.append(f"Advisories do not agree whether {noun} {fixed} {verb} the fix.")
520+
521+
return "\n".join(messages)
522+
523+
524+
def get_grouped_curation_advisories_for_dashboard_ui(purl, adv_map, conflict_detail, advisories):
525+
"""
526+
Return curation details for the PURL, grouping advisories with similar conflicts based on precedence.
527+
"""
528+
curation_item = {
529+
"purl": purl,
530+
"partial_curation": {
531+
"affected": list(conflict_detail["affected_intersection"]),
532+
"fixing": list(conflict_detail["fixed_intersection"]),
533+
},
534+
"advisories": [],
535+
}
536+
537+
all_versions = conflict_detail["affected_union"] + conflict_detail["fixed_union"]
538+
package_url = PackageURL.from_string(purl)
539+
range_class = RANGE_CLASS_BY_SCHEMES[package_url.type]
540+
version_class = range_class.version_class
541+
sorted_versions = sorted([version_class(v) for v in all_versions])
542+
curation_item["all_versions"] = [str(v) for v in sorted_versions]
543+
curation_item["conflict_reason"] = get_disagreement_message(
544+
fixed_disagreement=conflict_detail["fixed_disagreement"],
545+
affected_disagreement=conflict_detail["affected_disagreement"],
546+
)
547+
advisory_by_conflict_range = defaultdict(list)
548+
conflict_ranges = {}
549+
for avid, packages in adv_map.items():
550+
conflict_checksum = sha256_digest(
551+
canonical_value(
552+
{
553+
"affected": packages["affected"],
554+
"fixed": packages["fixed"],
555+
}
556+
)
557+
)
558+
if conflict_checksum not in conflict_ranges:
559+
conflict_ranges[conflict_checksum] = {
560+
"affected": list(packages["affected"]),
561+
"fixing": list(packages["fixed"]),
562+
}
563+
564+
advisory_item = {}
565+
advisory_item["advisory_uid"] = avid
566+
advisory_item["vers_ranges"] = []
567+
advisory = advisories[avid]
568+
advisory_item["precedence"] = advisory.precedence
569+
advisory_item["advisory_id"] = advisory.advisory_id
570+
advisory_item["datasource_id"] = advisory.datasource_id
571+
for impact in advisory.impacted_packages.all():
572+
if impact.base_purl != purl:
573+
continue
574+
advisory_item["vers_ranges"].append(
575+
{
576+
"affected_vers": impact.affecting_vers,
577+
"fixing_vers": impact.fixed_vers,
578+
}
579+
)
580+
581+
advisory_by_conflict_range[conflict_checksum].append(advisory_item)
582+
583+
for checksum, adv_items in advisory_by_conflict_range.items():
584+
primary, *secondaries = sorted(adv_items, key=lambda x: x["precedence"], reverse=True)
585+
conflict_ranges[checksum]["primary"] = primary
586+
conflict_ranges[checksum]["secondaries"] = secondaries
587+
588+
curation_item["advisories"] = list(conflict_ranges.values())
589+
return curation_item
590+
591+
498592
def get_advisory_with_best_impact_for_purls(purl_adv_map, conflicting_avids):
499593
"""
500594
Return PURL - AVID mapping for packages.
@@ -595,9 +689,10 @@ def merged_advisory(advisories, best_purl_avid_impact_map, conflicting_package_d
595689
)
596690

597691
for summary, avids in seen_summaries.values():
598-
merged_summary.append(f"{tuple(sorted(avids))}: {summary}")
692+
avids_str = ", ".join(sorted(avids))
693+
merged_summary.append(f"[{avids_str}]: {summary}")
599694

600-
merged_adv["summary"] = "\n".join(merged_summary)
695+
merged_adv["summary"] = "\n\n".join(merged_summary)
601696
merged_adv["aliases"] = list(merged_adv["aliases"])
602697
merged_adv["weaknesses"] = list(merged_adv["weaknesses"])
603698

@@ -624,7 +719,7 @@ def bulk_create_with_m2m(todos, advisories, logger):
624719
try:
625720
AdvisoryToDoV2.objects.bulk_create(objs=todos, ignore_conflicts=True)
626721
except Exception as e:
627-
logger(f"Error creating AdvisoryToDo: {e}")
722+
logger(f"Error creating AdvisoryToDoV2: {e}")
628723

629724
new_todos = AdvisoryToDoV2.objects.filter(created_at__gte=start_time)
630725

vulnerabilities/templates/advisory_todos.html

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
<div class="column">
3333
</div>
3434

35-
<div class="column is-four-fifths">
35+
<div class="column is-11">
3636
<div class="content is-normal">
3737
<h1>Advisory To-Dos</h1>
3838
<hr />
@@ -100,9 +100,9 @@ <h1>Advisory To-Dos</h1>
100100

101101
<div class="column has-text-left" style="flex: 0 0 10%;"></div>
102102

103-
<div class="column" style="flex: 0 0 40%;">
104-
<div class="select is-half">
105-
<select name="issue_type" onchange="this.form.submit()">
103+
<div class="column">
104+
<div class="select is-fullwidth">
105+
<select name="issue_type" onchange="this.form.submit()" >
106106
{% for val, label in form.fields.issue_type.choices %}
107107
<option value="{{ val }}"
108108
{% if form.issue_type.value == val %}selected{% endif %}>
@@ -122,6 +122,10 @@ <h1>Advisory To-Dos</h1>
122122
{% for todo in todo_list %}
123123
<tr>
124124
<td colspan="4">
125+
{% with supported_curation="CONFLICTING_FIXED_BY_PACKAGES CONFLICTING_AFFECTED_PACKAGES CONFLICTING_AFFECTED_AND_FIXED_BY_PACKAGES" %}
126+
{% if todo.issue_type in supported_curation.split %}
127+
<a href="{% url 'todo-detail' todo_id=todo.todo_id %}" class="has-text-info">
128+
{% endif %}
125129
<div class="columns px-1 is-vcentered">
126130
<div class="column has-text-left" style="flex: 0 0 20%;">
127131
{{ todo.alias }}
@@ -139,6 +143,10 @@ <h1>Advisory To-Dos</h1>
139143
{{ todo.get_issue_type_display }}
140144
</div>
141145
</div>
146+
{% if todo.issue_type in supported_curation.split %}
147+
</a>
148+
{% endif %}
149+
{% endwith %}
142150
</td>
143151
</tr>
144152
{% empty %}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
{% extends "base.html" %}
2+
{% load static %}
3+
{% load utils %}
4+
5+
{% block extrahead %}
6+
<link rel="stylesheet" href="{% static 'css/package_curation.css' %}">
7+
{% endblock %}
8+
9+
{% block content %}
10+
<section class="section">
11+
<div class="container is-fluid">
12+
<div class="columns is-vcentered mb-5">
13+
<div class="column">
14+
<h1 class="title is-3">Advisory Curation</h1>
15+
<h2 class="subtitle is-5 has-text-grey">{{ vulnerability_id }}</h2>
16+
</div>
17+
18+
<div class="column is-narrow">
19+
<div class="box p-4 has-background-white shadow-soft" style="min-width: 260px; border-radius: 5px;">
20+
<div class="is-flex is-justify-content-space-between is-align-items-center mb-2">
21+
<span class="is-size-6 has-text-grey-dark uppercase has-text-weight-bold mr-2">
22+
Progress
23+
</span>
24+
<span class="tag is-info is-light has-text-weight-bold" id="progress-text">0 / 0</span>
25+
</div>
26+
<progress class="progress is-info is-small mb-0" id="progress" value="0" max="100"></progress>
27+
</div>
28+
</div>
29+
30+
</div>
31+
32+
<div class="columns">
33+
<div class="column is-3">
34+
<h3 class="title is-5">Advisory Summaries</h3>
35+
<div id="summaries-container" style="max-height: 800px; overflow-y: auto;">
36+
{% for avid, text in advisory_summaries.items %}
37+
<div class="notification is-info is-light p-3 mb-3">
38+
<p><strong>{{ avid }}</strong></p>
39+
<div class="text summary-text is-2">{{ text|normalize_links|urlize }}</div>
40+
</div>
41+
{% empty %}
42+
<div class="notification is-light p-3 mb-3 has-text-grey">
43+
No summaries available.
44+
</div>
45+
{% endfor %}
46+
</div>
47+
</div>
48+
49+
<div class="column is-9">
50+
<div class="box">
51+
<div class="mb-4">
52+
<h4 class="title is-4 mb-1" id="current-purl"></h4>
53+
<p class="text is-5" id="conflict-reason"></p>
54+
</div>
55+
56+
<div class="table-container" style="max-height: 800px; overflow-y: auto; border: 1px solid #dbdbdb; border-radius: 6px;">
57+
<table class="table is-bordered is-narrow is-fullwidth is-hoverable">
58+
<thead style="position: sticky; top: 0; background: white; z-index: 10;">
59+
<tr id="table-header">
60+
61+
</tr>
62+
</thead>
63+
<tbody id="curation-body">
64+
65+
</tbody>
66+
</table>
67+
</div>
68+
69+
<div class="level mt-5">
70+
<div class="level-left">
71+
<button class="button is-light" id="prev-btn" onclick="app.navigate(-1)">
72+
<span class="icon"><i class="fa fa-chevron-left"></i></span>
73+
<span>Previous Item</span>
74+
</button>
75+
</div>
76+
<div class="level-right">
77+
<button class="button is-info" id="next-btn" onclick="app.navigate(1)">
78+
<span>Next Item</span>
79+
<span class="icon"><i class="fa fa-chevron-right"></i></span>
80+
</button>
81+
<button class="button is-info is-hidden" id="finish-btn" disabled>
82+
<span class="icon"><i class="fa fa-check"></i></span>
83+
<span>Submit</span>
84+
</button>
85+
</div>
86+
</div>
87+
</div>
88+
</div>
89+
</div>
90+
</div>
91+
</section>
92+
{% endblock %}
93+
94+
95+
{% block scripts %}
96+
<script>
97+
const baseAdvisoryUrl = "{% url 'advisory_details' 0 %}";
98+
const curationItems = {{ curation_items|safe }};
99+
</script>
100+
<script src="{% static 'js/package_curation.min.js' %}"></script>
101+
{% endblock %}

0 commit comments

Comments
 (0)