diff --git a/vulnerabilities/migrations/0133_alter_advisorytodov2_issue_detail.py b/vulnerabilities/migrations/0133_alter_advisorytodov2_issue_detail.py
new file mode 100644
index 000000000..f36f42a9b
--- /dev/null
+++ b/vulnerabilities/migrations/0133_alter_advisorytodov2_issue_detail.py
@@ -0,0 +1,20 @@
+# Generated by Django 5.2.11 on 2026-05-27 08:41
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("vulnerabilities", "0132_migrate_advisoryv2_datasource_ids"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="advisorytodov2",
+ name="issue_detail",
+ field=models.JSONField(
+ blank=True, default=dict, help_text="Additional details about the issue."
+ ),
+ ),
+ ]
diff --git a/vulnerabilities/models.py b/vulnerabilities/models.py
index 69253f54b..d05529638 100644
--- a/vulnerabilities/models.py
+++ b/vulnerabilities/models.py
@@ -2572,8 +2572,9 @@ class AdvisoryToDoV2(models.Model):
help_text="Select the issue that needs to be addressed from the available options.",
)
- issue_detail = models.TextField(
+ issue_detail = models.JSONField(
blank=True,
+ default=dict,
help_text="Additional details about the issue.",
)
@@ -3010,7 +3011,7 @@ def todo_excluded(self):
"""Exclude advisory ineligible for ToDo computation."""
from vulnerabilities.importers import TODO_EXCLUDED_PIPELINES
- return self.exclude(datasource_id__in=TODO_EXCLUDED_PIPELINES)
+ return self.exclude(pipeline_id__in=TODO_EXCLUDED_PIPELINES)
class AdvisorySet(models.Model):
@@ -3168,6 +3169,8 @@ class AdvisoryV2(models.Model):
choices=AdvisoryStatusType.choices, default=AdvisoryStatusType.PUBLISHED
)
+ # Note: Fields and relations below are not part of original upstream advisory.
+
exploitability = models.DecimalField(
null=True,
blank=True,
diff --git a/vulnerabilities/pipelines/v2_improvers/compute_advisory_todo.py b/vulnerabilities/pipelines/v2_improvers/compute_advisory_todo.py
index e146cbe30..ab8e9dfb0 100644
--- a/vulnerabilities/pipelines/v2_improvers/compute_advisory_todo.py
+++ b/vulnerabilities/pipelines/v2_improvers/compute_advisory_todo.py
@@ -17,6 +17,7 @@
from django.db.models import Prefetch
from django.utils import timezone
from packageurl import PackageURL
+from univers.version_range import RANGE_CLASS_BY_SCHEMES
from vulnerabilities.importer import AdvisoryDataV2
from vulnerabilities.models import AdvisoryAlias
@@ -281,7 +282,7 @@ def check_missing_summary(
todo_to_create,
advisory_relation_to_create,
):
- alias = advisory.datasource_id.rsplit("/", 1)[-1]
+ alias = advisory.advisory_id.rsplit("/", 1)[-1]
oldest_advisory_date = advisory.date_published or advisory.date_collected
if not advisory.summary:
todo = AdvisoryToDoV2(
@@ -333,7 +334,7 @@ def check_missing_affected_and_fixed_by_packages(
elif not has_fixed_package:
issue_type = "MISSING_FIXED_BY_PACKAGE"
- alias = advisory.datasource_id.rsplit("/", 1)[-1]
+ alias = advisory.advisory_id.rsplit("/", 1)[-1]
oldest_advisory_date = advisory.date_published or advisory.date_collected
if issue_type:
todo = AdvisoryToDoV2(
@@ -360,12 +361,12 @@ def compute_version_range_disagreement(adv_map):
fixed_intersection = set.intersection(*fixed_sets)
return {
- "affected_union": affected_union,
- "affected_intersection": affected_intersection,
- "affected_disagreement": affected_union - affected_intersection,
- "fixed_union": fixed_union,
- "fixed_intersection": fixed_intersection,
- "fixed_disagreement": fixed_union - fixed_intersection,
+ "affected_union": list(affected_union),
+ "affected_intersection": list(affected_intersection),
+ "affected_disagreement": list(affected_union - affected_intersection),
+ "fixed_union": list(fixed_union),
+ "fixed_intersection": list(fixed_intersection),
+ "fixed_disagreement": list(fixed_union - fixed_intersection),
}
@@ -417,6 +418,7 @@ def check_conflicting_affected_and_fixed_by_packages_for_alias(
"""
conflicting_package_details = {}
+ curation_items = []
has_conflicting_affected_packages = False
has_conflicting_fixed_package = False
conflicting_advisories = set()
@@ -433,6 +435,9 @@ def check_conflicting_affected_and_fixed_by_packages_for_alias(
conflicting_package_details[purl] = {
"avids": list(adv_map.keys()),
}
+ curation_items.append(
+ get_grouped_curation_advisories_for_dashboard_ui(purl, adv_map, result, advisories)
+ )
conflicting_advisories.update([advisories[avid] for avid in adv_map])
conflicting_package_details[purl].update(result)
@@ -462,6 +467,7 @@ def check_conflicting_affected_and_fixed_by_packages_for_alias(
"conflict_checksum": conflict_checksum,
"conflict_details": conflicting_package_details,
"partial_curation_advisory": partial_merged_advisory,
+ "curation_items": curation_items,
}
todo_id = advisories_checksum(conflicting_advisories)
@@ -484,7 +490,7 @@ def check_conflicting_affected_and_fixed_by_packages_for_alias(
todo = AdvisoryToDoV2(
related_advisories_id=todo_id,
issue_type=issue_type,
- issue_detail=json.dumps(issue_detail, default=list),
+ issue_detail=issue_detail,
alias=alias,
advisories_count=conflicting_advisories_count,
oldest_advisory_date=date_published or date_collected,
@@ -495,6 +501,94 @@ def check_conflicting_affected_and_fixed_by_packages_for_alias(
return conflicting_package_count, conflicting_advisories_count
+def get_disagreement_message(fixed_disagreement, affected_disagreement):
+ messages = []
+
+ if affected_disagreement:
+ affected = ", ".join(affected_disagreement)
+ noun = "version" if len(affected_disagreement) == 1 else "versions"
+ verb = "is" if len(affected_disagreement) == 1 else "are"
+
+ messages.append(f"Advisories do not agree whether {noun} {affected} {verb} affected.")
+
+ if fixed_disagreement:
+ fixed = ", ".join(fixed_disagreement)
+ noun = "version" if len(fixed_disagreement) == 1 else "versions"
+ verb = "contains" if len(fixed_disagreement) == 1 else "contain"
+
+ messages.append(f"Advisories do not agree whether {noun} {fixed} {verb} the fix.")
+
+ return " ".join(messages)
+
+
+def get_grouped_curation_advisories_for_dashboard_ui(purl, adv_map, conflict_detail, advisories):
+ """
+ Return curation details for the PURL, grouping advisories with similar conflicts based on precedence.
+ """
+ curation_item = {
+ "purl": purl,
+ "partial_curation": {
+ "affected": list(conflict_detail["affected_intersection"]),
+ "fixing": list(conflict_detail["fixed_intersection"]),
+ },
+ "advisories": [],
+ }
+
+ all_versions = conflict_detail["affected_union"] + conflict_detail["fixed_union"]
+ package_url = PackageURL.from_string(purl)
+ range_class = RANGE_CLASS_BY_SCHEMES[package_url.type]
+ version_class = range_class.version_class
+ sorted_versions = sorted([version_class(v) for v in all_versions])
+ curation_item["all_versions"] = [str(v) for v in sorted_versions]
+ curation_item["conflict_reason"] = get_disagreement_message(
+ fixed_disagreement=conflict_detail["fixed_disagreement"],
+ affected_disagreement=conflict_detail["affected_disagreement"],
+ )
+ advisory_by_conflict_range = defaultdict(list)
+ conflict_ranges = {}
+ for avid, packages in adv_map.items():
+ conflict_checksum = sha256_digest(
+ canonical_value(
+ {
+ "affected": packages["affected"],
+ "fixed": packages["fixed"],
+ }
+ )
+ )
+ if conflict_checksum not in conflict_ranges:
+ conflict_ranges[conflict_checksum] = {
+ "affected": list(packages["affected"]),
+ "fixing": list(packages["fixed"]),
+ }
+
+ advisory_item = {}
+ advisory_item["advisory_uid"] = avid
+ advisory_item["vers_ranges"] = []
+ advisory = advisories[avid]
+ advisory_item["precedence"] = advisory.precedence
+ advisory_item["advisory_id"] = advisory.advisory_id
+ advisory_item["datasource_id"] = advisory.datasource_id
+ for impact in advisory.impacted_packages.all():
+ if impact.base_purl != purl:
+ continue
+ advisory_item["vers_ranges"].append(
+ {
+ "affected_vers": impact.affecting_vers,
+ "fixing_vers": impact.fixed_vers,
+ }
+ )
+
+ advisory_by_conflict_range[conflict_checksum].append(advisory_item)
+
+ for checksum, adv_items in advisory_by_conflict_range.items():
+ primary, *secondaries = sorted(adv_items, key=lambda x: x["precedence"], reverse=True)
+ conflict_ranges[checksum]["primary"] = primary
+ conflict_ranges[checksum]["secondaries"] = secondaries
+
+ curation_item["advisories"] = list(conflict_ranges.values())
+ return curation_item
+
+
def get_advisory_with_best_impact_for_purls(purl_adv_map, conflicting_avids):
"""
Return PURL - AVID mapping for packages.
@@ -595,9 +689,10 @@ def merged_advisory(advisories, best_purl_avid_impact_map, conflicting_package_d
)
for summary, avids in seen_summaries.values():
- merged_summary.append(f"{tuple(sorted(avids))}: {summary}")
+ avids_str = ", ".join(sorted(avids))
+ merged_summary.append(f"[{avids_str}]: {summary}")
- merged_adv["summary"] = "\n".join(merged_summary)
+ merged_adv["summary"] = "\n\n".join(merged_summary)
merged_adv["aliases"] = list(merged_adv["aliases"])
merged_adv["weaknesses"] = list(merged_adv["weaknesses"])
@@ -624,7 +719,7 @@ def bulk_create_with_m2m(todos, advisories, logger):
try:
AdvisoryToDoV2.objects.bulk_create(objs=todos, ignore_conflicts=True)
except Exception as e:
- logger(f"Error creating AdvisoryToDo: {e}")
+ logger(f"Error creating AdvisoryToDoV2: {e}")
new_todos = AdvisoryToDoV2.objects.filter(created_at__gte=start_time)
diff --git a/vulnerabilities/templates/advisory_todos.html b/vulnerabilities/templates/advisory_todos.html
index 82dbafd76..0d9c2899c 100644
--- a/vulnerabilities/templates/advisory_todos.html
+++ b/vulnerabilities/templates/advisory_todos.html
@@ -122,6 +122,10 @@
Advisory To-Dos
{% for todo in todo_list %}
+ {% with supported_curation="CONFLICTING_FIXED_BY_PACKAGES CONFLICTING_AFFECTED_PACKAGES CONFLICTING_AFFECTED_AND_FIXED_BY_PACKAGES" %}
+ {% if todo.issue_type in supported_curation.split %}
+
+ {% endif %}
{{ todo.alias }}
@@ -139,6 +143,10 @@ Advisory To-Dos
{{ todo.get_issue_type_display }}
+ {% if todo.issue_type in supported_curation.split %}
+
+ {% endif %}
+ {% endwith %}
|
{% empty %}
diff --git a/vulnerabilities/templates/package_curation.html b/vulnerabilities/templates/package_curation.html
new file mode 100644
index 000000000..02a442635
--- /dev/null
+++ b/vulnerabilities/templates/package_curation.html
@@ -0,0 +1,101 @@
+{% extends "base.html" %}
+{% load static %}
+{% load utils %}
+
+{% block extrahead %}
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
Curation Dashboard
+ {{ vulnerability_id }}
+
+
+
+
+
+
+ Progress
+
+ 0 / 0
+
+
+
+
+
+
+
+
+
+
Advisory Summaries
+
+ {% for avid, text in advisory_summaries.items %}
+
+
{{ avid }}
+
{{ text|normalize_links|urlize }}
+
+ {% empty %}
+
+ No summaries available.
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+
+{% block scripts %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/vulnerabilities/templatetags/utils.py b/vulnerabilities/templatetags/utils.py
index 1f8d48a9e..543191283 100644
--- a/vulnerabilities/templatetags/utils.py
+++ b/vulnerabilities/templatetags/utils.py
@@ -8,6 +8,8 @@
#
+import re
+
from aboutcode.pipeline import humanize_time
from django import template
@@ -52,3 +54,13 @@ def querystring(request, **kwargs):
query[key] = value
return query.urlencode()
+
+
+@register.filter
+def normalize_links(value):
+ """Normalize Markdown URLs."""
+ if not value:
+ return ""
+
+ markdown_links = re.compile(r"\[([^\]]+)\]\((https?://[^\s)]+)\s*\)")
+ return markdown_links.sub(r"\1 \2", value)
diff --git a/vulnerabilities/tests/pipelines/v2_improvers/test_compute_advisory_todo_v2.py b/vulnerabilities/tests/pipelines/v2_improvers/test_compute_advisory_todo_v2.py
index a1bbe0465..c07dcf9d9 100644
--- a/vulnerabilities/tests/pipelines/v2_improvers/test_compute_advisory_todo_v2.py
+++ b/vulnerabilities/tests/pipelines/v2_improvers/test_compute_advisory_todo_v2.py
@@ -254,8 +254,8 @@ def test_advisory_todo_conflicting_fixed_affected(self):
self.assertEqual(1, AdvisoryToDoV2.objects.count())
self.assertEqual("CONFLICTING_AFFECTED_AND_FIXED_BY_PACKAGES", todo.issue_type)
self.assertIn(
- '"conflict_checksum": "87d9e2627a8461fc5c068335d822af4aa0a40a8f265a92895c51d275d97ab0d6",',
- todo.issue_detail,
+ "87d9e2627a8461fc5c068335d822af4aa0a40a8f265a92895c51d275d97ab0d6",
+ todo.issue_detail["conflict_checksum"],
)
self.assertEqual(2, todo.advisories.count())
self.assertEqual(todo, adv.advisory_todos.first())
@@ -287,7 +287,7 @@ def test_todo_conflict_details_partial_curation(self):
expected_partial_curation_advisory = {
"advisory_id": "PLACEHOLDER_PARTIAL_CURATION_AVID",
"aliases": ["CVE-000-000"],
- "summary": "('test5/test_id_5', 'test6/test_id_6'): Test summary",
+ "summary": "[test5/test_id_5, test6/test_id_6]: Test summary",
"affected_packages": [
{
"package": {
@@ -377,12 +377,10 @@ def test_todo_conflict_details_partial_curation(self):
pipeline.execute()
todo = AdvisoryToDoV2.objects.first()
- issue_details = json.loads(todo.issue_detail)
+ issue_details = todo.issue_detail
result_partial_curation = issue_details["partial_curation_advisory"]
self.assertEqual(1, AdvisoryToDoV2.objects.count())
self.assertEqual("CONFLICTING_FIXED_BY_PACKAGES", todo.issue_type)
- print(result_partial_curation)
- # breakpoint()
self.assertDictEqual(expected_partial_curation_advisory, result_partial_curation)
def test_todo_conflict_details_partial_curation_unpaired_purl_and_conflicting_affected_and_fixed(
@@ -391,7 +389,7 @@ def test_todo_conflict_details_partial_curation_unpaired_purl_and_conflicting_af
expected_partial_curation_advisory = {
"advisory_id": "PLACEHOLDER_PARTIAL_CURATION_AVID",
"aliases": ["CVE-000-000"],
- "summary": "('test1/test_id', 'test5/test_id_5'): Test summary",
+ "summary": "[test1/test_id, test5/test_id_5]: Test summary",
"affected_packages": [
{
"package": {
@@ -439,19 +437,17 @@ def test_todo_conflict_details_partial_curation_unpaired_purl_and_conflicting_af
pipeline.execute()
todo = AdvisoryToDoV2.objects.first()
- issue_details = json.loads(todo.issue_detail)
+ issue_details = todo.issue_detail
result_partial_curation = issue_details["partial_curation_advisory"]
self.assertEqual(1, AdvisoryToDoV2.objects.count())
self.assertEqual("CONFLICTING_AFFECTED_AND_FIXED_BY_PACKAGES", todo.issue_type)
- print(result_partial_curation)
- # breakpoint()
self.assertDictEqual(expected_partial_curation_advisory, result_partial_curation)
def test_todo_conflict_details_partial_curation_unpaired_purl_and_conflicting_fixed(self):
expected_partial_curation_advisory = {
"advisory_id": "PLACEHOLDER_PARTIAL_CURATION_AVID",
"aliases": ["CVE-000-000"],
- "summary": "('test1/test_id', 'test7/test_id_5'): Test summary",
+ "summary": "[test1/test_id, test7/test_id_5]: Test summary",
"affected_packages": [
{
"package": {
@@ -513,19 +509,17 @@ def test_todo_conflict_details_partial_curation_unpaired_purl_and_conflicting_fi
pipeline.execute()
todo = AdvisoryToDoV2.objects.first()
- issue_details = json.loads(todo.issue_detail)
+ issue_details = todo.issue_detail
result_partial_curation = issue_details["partial_curation_advisory"]
self.assertEqual(1, AdvisoryToDoV2.objects.count())
self.assertEqual("CONFLICTING_FIXED_BY_PACKAGES", todo.issue_type)
- print(result_partial_curation)
- # breakpoint()
self.assertDictEqual(expected_partial_curation_advisory, result_partial_curation)
def test_todo_conflict_details_partial_curation_unpaired_purl_and_conflicting_affected(self):
expected_partial_curation_advisory = {
"advisory_id": "PLACEHOLDER_PARTIAL_CURATION_AVID",
"aliases": ["CVE-000-000"],
- "summary": "('test1/test_id', 'test8/test_id_5'): Test summary",
+ "summary": "[test1/test_id, test8/test_id_5]: Test summary",
"affected_packages": [
{
"package": {
@@ -587,10 +581,8 @@ def test_todo_conflict_details_partial_curation_unpaired_purl_and_conflicting_af
pipeline.execute()
todo = AdvisoryToDoV2.objects.first()
- issue_details = json.loads(todo.issue_detail)
+ issue_details = todo.issue_detail
result_partial_curation = issue_details["partial_curation_advisory"]
self.assertEqual(1, AdvisoryToDoV2.objects.count())
self.assertEqual("CONFLICTING_AFFECTED_PACKAGES", todo.issue_type)
- print(result_partial_curation)
- # breakpoint()
self.assertDictEqual(expected_partial_curation_advisory, result_partial_curation)
diff --git a/vulnerabilities/views.py b/vulnerabilities/views.py
index 6ef3f39a2..04ac8a787 100644
--- a/vulnerabilities/views.py
+++ b/vulnerabilities/views.py
@@ -7,6 +7,7 @@
# See https://aboutcode.org for more information about nexB OSS projects.
#
+import json
import logging
from collections import defaultdict
from typing import List
@@ -1086,3 +1087,29 @@ def get_context_data(self, **kwargs):
context["issue_choices"] = ISSUE_TYPE_CHOICES
return context
+
+
+class AdvisoryPackageCurationView(DetailView):
+ model = AdvisoryToDoV2
+ template_name = "package_curation.html"
+ slug_url_kwarg = "todo_id"
+ slug_field = "todo_id"
+
+ def get_queryset(self):
+ package_curation_issue_types = [
+ "CONFLICTING_FIXED_BY_PACKAGES",
+ "CONFLICTING_AFFECTED_PACKAGES",
+ "CONFLICTING_AFFECTED_AND_FIXED_BY_PACKAGES",
+ ]
+ return super().get_queryset().filter(issue_type__in=package_curation_issue_types)
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ todo = self.object
+
+ context["advisory_summaries"] = {
+ adv.avid: adv.summary for adv in todo.advisories.all() if adv.summary.strip()
+ }
+ context["vulnerability_id"] = todo.alias
+ context["curation_items"] = json.dumps(todo.issue_detail["curation_items"])
+ return context
diff --git a/vulnerablecode/static/css/package_curation.css b/vulnerablecode/static/css/package_curation.css
new file mode 100644
index 000000000..19d7deed9
--- /dev/null
+++ b/vulnerablecode/static/css/package_curation.css
@@ -0,0 +1,174 @@
+ .state-affected {
+ background-color: #f14668 !important;
+ color: #fff !important;
+ }
+
+ .state-fixed {
+ background-color: #48c78e !important;
+ color: #fff !important;
+ }
+
+ .table {
+ table-layout: fixed !important;
+ width: 100% !important;
+ }
+
+ .table th {
+ border-width: 0 1px 1px 1px !important;
+ border-color: #dbdbdb !important;
+ background-color: #fff !important;
+ }
+
+ #table-header th {
+ height: 140px;
+ vertical-align: top !important;
+ padding: 0.75rem 0.5rem !important;
+ }
+
+ #table-header th>div {
+ display: inline-flex !important;
+ flex-direction: column !important;
+ justify-content: space-between !important;
+ height: 100% !important;
+ width: 100%;
+ }
+
+ #table-header th:first-child {
+ width: 100px !important;
+ }
+
+ #table-header th:nth-child(2) {
+ width: 100px !important;
+ }
+
+ #table-header th:nth-child(n+3) {
+ width: 100px !important;
+ }
+
+
+ .curation-cell,
+ .advisory-cell,
+ #curation-body td,
+ .folded-row-marker,
+ .range-data-cell,
+ .help-instructions {
+ font-size: 0.8rem !important;
+ }
+
+ .curation-cell {
+ cursor: pointer;
+ text-align: center !important;
+ font-weight: 700;
+ user-select: none;
+ transition: background-color 0.1s ease;
+ border: 1px solid #dbdbdb !important;
+ }
+
+ .curation-cell:hover {
+ filter: brightness(0.95);
+ box-shadow: inset 0 0 0 1px #3273dc;
+ }
+
+ .advisory-cell,
+ .advisory-cell * {
+ cursor: not-allowed;
+ }
+
+ .advisory-cell {
+ text-align: center !important;
+ vertical-align: middle !important;
+ border: 1px solid #dbdbdb !important;
+ }
+
+ .folded-row-marker,
+ .range-row-marker {
+ cursor: pointer;
+ text-align: center;
+ color: #4a4a4a;
+ font-weight: 600;
+ background-color: #f5f5f5;
+ }
+
+ .folded-row-marker {
+ height: 30px;
+ }
+
+ .folded-row-marker:hover,
+ .range-row-marker:hover {
+ background-color: #ededed;
+ color: #0a0a0a;
+ }
+
+ .folded-row-marker .icon {
+ transition: transform 0.2s ease;
+ }
+
+ .folded-row-marker.is-expanded .icon {
+ transform: rotate(180deg);
+ }
+
+ .range-row-marker {
+ font-size: 0.8rem;
+ padding: 6px 0;
+ border: 1px solid #dbdbdb !important;
+ }
+
+ .range-data-cell {
+ background-color: #fafafa;
+ padding: 6px !important;
+ vertical-align: top !important;
+ border: 1px solid #dbdbdb !important;
+
+ white-space: normal !important;
+ word-break: break-all !important;
+ overflow-wrap: break-word !important;
+ }
+
+ #summaries-container p strong {
+ display: block;
+ word-break: break-all;
+ overflow-wrap: break-word;
+ line-height: 1.3;
+ margin-bottom: 4px;
+ }
+
+ .summary-text {
+ font-size: 0.85rem;
+ line-height: 1.4;
+ white-space: pre-wrap;
+ word-break: break-word;
+ }
+
+ div.summary-text a:link,
+ div.summary-text a {
+ color: #3273dc !important;
+ }
+
+ div.summary-text a:visited {
+ color: #8a4de8 !important;
+ }
+
+ div.summary-text a:hover {
+ color: #0a0a0a !important;
+ }
+
+ .advisory-wrapper {
+ width: 100%;
+ word-break: break-all;
+ white-space: normal;
+}
+
+.advisory-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ text-align: left;
+ text-decoration: underline;
+ white-space: normal;
+ flex-wrap: wrap;
+}
+
+.advisory-link .icon {
+ font-size: 0.75rem;
+ flex-shrink: 0;
+}
\ No newline at end of file
diff --git a/vulnerablecode/static/js/package_curation.js b/vulnerablecode/static/js/package_curation.js
new file mode 100644
index 000000000..b2b34b775
--- /dev/null
+++ b/vulnerablecode/static/js/package_curation.js
@@ -0,0 +1,441 @@
+const app = {
+ currentIndex: 0,
+ userStates: {},
+ expandedFolds: new Set(),
+ showRanges: false,
+ foldAgreementBlocks: true,
+ init() {
+ this.renderPackageCuration();
+
+ document.querySelector('.summary-text')?.addEventListener('click', (e) => {
+ if (e.target.closest('a')) {
+ e.target.target = '_blank';
+ }
+ });
+ },
+
+ renderPackageCuration() {
+ const item = curationItems[this.currentIndex];
+ const versions = item.all_versions || item.all_version;
+
+ const total = curationItems.length;
+ const progPercentage = ((this.currentIndex + 1) / total) * 100;
+ document.getElementById('main-progress').value = progPercentage;
+ document.getElementById('progress-text').innerText = `${this.currentIndex + 1} / ${total}`;
+ document.getElementById('current-purl').innerText = item.purl;
+ document.getElementById('conflict-reason').innerText = item.conflict_reason;
+ if (!this.userStates[this.currentIndex]) {
+ this.userStates[this.currentIndex] = {};
+ versions.forEach(v => {
+ if (item.partial_curation.affected.includes(v)) this.userStates[this.currentIndex][v] = 'affected';
+ else if (item.partial_curation.fixing.includes(v)) this.userStates[this.currentIndex][v] = 'fixed';
+ else this.userStates[this.currentIndex][v] = '?';
+ });
+ }
+ this.renderHeader(item);
+ this.renderBody(item, versions);
+ this.updateNavButtons();
+ },
+
+ renderHeader(item) {
+ const header = document.getElementById('table-header');
+ header.innerHTML = `
+ Version |
+
+
+ | `;
+ item.advisories.forEach((adv, idx) => {
+ const th = document.createElement('th');
+ th.className = "has-text-centered";
+ const targetUrl = baseAdvisoryUrl.replace('0', adv.advisory_uid);
+ th.innerHTML = `
+
+ `;
+ header.appendChild(th);
+ });
+ },
+
+ renderBody(item, versions) {
+ const body = document.getElementById('curation-body');
+ body.innerHTML = '';
+ const totalColumns = item.advisories.length + 2;
+ const rangeToggleRow = document.createElement('tr');
+ rangeToggleRow.innerHTML = `
+
+
+ ${this.showRanges ? 'Hide' : 'Show'} Version Ranges
+ | `;
+ body.appendChild(rangeToggleRow);
+ if (this.showRanges) {
+ const rangeDataRow = document.createElement('tr');
+ let rowHtml = ` | | `;
+ item.advisories.forEach(adv => {
+ const rangeHtml = adv.vers_ranges.map(r => {
+ let htmlLines = [];
+ if (r.affected_vers && r.affected_vers.trim() !== "") {
+ htmlLines.push(`Affected: ${r.affected_vers}
`);
+ }
+ if (r.fixing_vers && r.fixing_vers.trim() !== "") {
+ htmlLines.push(`Fixing: ${r.fixing_vers}
`);
+ }
+ return htmlLines.length > 0 ? htmlLines.join('') : 'No range specified
';
+ }).join('
');
+ rowHtml += `${rangeHtml} | `;
+ });
+ rangeDataRow.innerHTML = rowHtml;
+ body.appendChild(rangeDataRow);
+ }
+ const foldable = this.getFoldableRanges(item, versions);
+ for (let i = 0; i < versions.length; i++) {
+ const range = foldable.find(r => i >= r.start && i <= r.end);
+ if (range) {
+ const foldKey = `${this.currentIndex}-${range.start}`;
+ let isExpanded = this.expandedFolds.has(foldKey);
+ if (!this.foldAgreementBlocks) {
+ isExpanded = !this.expandedFolds.has(`${this.currentIndex}-${range.start}-collapsed`);
+ }
+ if (i === range.start) {
+ const marker = document.createElement('tr');
+ marker.innerHTML = `
+
+ ${isExpanded ? 'Hide' : 'Show'} Consensus Range (${range.end - range.start + 1} versions)
+ | `;
+ body.appendChild(marker);
+ }
+ if (!isExpanded) {
+ if (i === range.end) continue;
+ i = range.end;
+ continue;
+ }
+ }
+ body.appendChild(this.createRow(versions[i], item));
+ }
+ },
+
+ resetCurrentCuration() {
+ const item = curationItems[this.currentIndex];
+ const versions = item.all_versions || item.all_version;
+ versions.forEach(v => {
+ if (item.partial_curation.affected.includes(v)) {
+ this.userStates[this.currentIndex][v] = 'affected';
+ } else if (item.partial_curation.fixing.includes(v)) {
+ this.userStates[this.currentIndex][v] = 'fixed';
+ } else {
+ this.userStates[this.currentIndex][v] = '?';
+ }
+ });
+ this.renderBody(item, versions);
+ },
+
+ renderHeader(item) {
+ const header = document.getElementById('table-header');
+ header.innerHTML = `
+ Version |
+
+
+ | `;
+
+ item.advisories.forEach((advGroup, groupIdx) => {
+ const secondaries = advGroup.secondaries || [];
+ const hasSecondaries = secondaries.length > 0;
+
+ const colKey = `${this.currentIndex}-col-${groupIdx}`;
+ const isExpanded = this.expandedFolds.has(colKey);
+
+ const primaryTh = document.createElement('th');
+ primaryTh.className = "has-text-centered";
+
+ const primaryUrl = baseAdvisoryUrl.replace('0', advGroup.primary.advisory_uid);
+ let primaryUidHtml = `
+
+ `;
+
+ let toggleHtml = "";
+ if (hasSecondaries) {
+ toggleHtml = `
+
+ `;
+ }
+
+ primaryTh.innerHTML = `
+
+
+ ${toggleHtml}
+ ${primaryUidHtml}
+
+
+
+ `;
+ header.appendChild(primaryTh);
+
+ if (hasSecondaries && isExpanded) {
+ secondaries.forEach((sec, secIdx) => {
+ const secTh = document.createElement('th');
+ secTh.className = "has-text-centered";
+ secTh.style.backgroundColor = "#fafafa";
+
+ const secUrl = baseAdvisoryUrl.replace('0', sec.advisory_uid);
+ secTh.innerHTML = `
+
+ `;
+ header.appendChild(secTh);
+ });
+ }
+ });
+ },
+
+ renderBody(item, versions) {
+ const body = document.getElementById('curation-body');
+ body.innerHTML = '';
+
+ let totalColumns = 2;
+ item.advisories.forEach((advGroup, groupIdx) => {
+ totalColumns += 1;
+ const colKey = `${this.currentIndex}-col-${groupIdx}`;
+ if (this.expandedFolds.has(colKey)) {
+ totalColumns += (advGroup.secondaries || []).length;
+ }
+ });
+
+ const rangeToggleRow = document.createElement('tr');
+ rangeToggleRow.innerHTML = `
+
+
+ ${this.showRanges ? 'Hide' : 'Show'} Version Ranges
+ | `;
+ body.appendChild(rangeToggleRow);
+
+ if (this.showRanges) {
+ const rangeDataRow = document.createElement('tr');
+ let rowHtml = ` | | `;
+
+ item.advisories.forEach((advGroup, groupIdx) => {
+ const colKey = `${this.currentIndex}-col-${groupIdx}`;
+ const isExpanded = this.expandedFolds.has(colKey);
+
+ const renderRangeHtml = (ranges) => ranges.map(r => {
+ let htmlLines = [];
+ if (r.affected_vers && r.affected_vers.trim() !== "") {
+ htmlLines.push(`Affected: ${r.affected_vers}
`);
+ }
+ if (r.fixing_vers && r.fixing_vers.trim() !== "") {
+ htmlLines.push(`Fixing: ${r.fixing_vers}
`);
+ }
+ return htmlLines.length > 0 ? htmlLines.join('') : 'No range specified
';
+ }).join('
');
+
+ rowHtml += `${renderRangeHtml(advGroup.primary.vers_ranges)} | `;
+
+ if (isExpanded && advGroup.secondaries) {
+ advGroup.secondaries.forEach(sec => {
+ rowHtml += `${renderRangeHtml(sec.vers_ranges)} | `;
+ });
+ }
+ });
+
+ rangeDataRow.innerHTML = rowHtml;
+ body.appendChild(rangeDataRow);
+ }
+
+ const foldable = this.getFoldableRanges(item, versions);
+ for (let i = 0; i < versions.length; i++) {
+ const range = foldable.find(r => i >= r.start && i <= r.end);
+ if (range) {
+ const foldKey = `${this.currentIndex}-${range.start}`;
+ let isExpanded = this.expandedFolds.has(foldKey);
+ if (!this.foldAgreementBlocks) {
+ isExpanded = !this.expandedFolds.has(`${this.currentIndex}-${range.start}-collapsed`);
+ }
+ if (i === range.start) {
+ const marker = document.createElement('tr');
+ marker.innerHTML = `
+
+ ${isExpanded ? 'Hide' : 'Show'} Consensus Range (${range.end - range.start + 1} versions)
+ | `;
+ body.appendChild(marker);
+ }
+ if (!isExpanded) {
+ if (i === range.end) continue;
+ i = range.end;
+ continue;
+ }
+ }
+ body.appendChild(this.createRow(versions[i], item));
+ }
+ },
+
+ createRow(v, item) {
+ const tr = document.createElement('tr');
+ const state = this.userStates[this.currentIndex][v];
+ tr.innerHTML = `${v} | `;
+ const userTd = document.createElement('td');
+ userTd.className = `curation-cell state-${state} `;
+ userTd.innerText = state.toUpperCase();
+ userTd.onclick = () => this.cycleState(v);
+ tr.appendChild(userTd);
+
+ item.advisories.forEach((advGroup, groupIdx) => {
+ const colKey = `${this.currentIndex}-col-${groupIdx}`;
+ const isExpanded = this.expandedFolds.has(colKey);
+
+ const primaryState = advGroup.affected.includes(v) ? 'affected' : (advGroup.fixing.includes(v) ? 'fixed' : 'unaffected');
+ const td = document.createElement('td');
+ td.className = `advisory-cell state-${primaryState}`;
+ td.innerText = primaryState.toUpperCase();
+ tr.appendChild(td);
+
+ if (isExpanded && advGroup.secondaries) {
+ advGroup.secondaries.forEach(sec => {
+ const secAffected = sec.affected || advGroup.affected;
+ const secFixing = sec.fixing || advGroup.fixing;
+
+ const secState = secAffected.includes(v) ? 'affected' : (secFixing.includes(v) ? 'fixed' : 'unaffected');
+ const secTd = document.createElement('td');
+ secTd.className = `advisory-cell state-${secState}`;
+ secTd.style.borderLeft = "1px dashed #dbdbdb";
+ secTd.innerText = secState.toUpperCase();
+ tr.appendChild(secTd);
+ });
+ }
+ });
+ return tr;
+ },
+
+ getFoldableRanges(item, versions) {
+ const ranges = [];
+ let start = -1;
+ for (let i = 0; i < versions.length; i++) {
+ const v = versions[i];
+ const states = item.advisories.map(a => a.affected.includes(v) ? 'affected' : (a.fixing.includes(v) ? 'fixed' : 'unaffected'));
+ const allMatch = states.every(s => s === states[0]);
+ if (allMatch) {
+ if (start === -1) start = i;
+ } else {
+ if (start !== -1 && (i - start) >= 3) ranges.push({
+ start,
+ end: i - 1
+ });
+ start = -1;
+ }
+ }
+ if (start !== -1 && (versions.length - start) >= 3) ranges.push({
+ start,
+ end: versions.length - 1
+ });
+ return ranges;
+ },
+
+ toggleFold(startIdx) {
+ const foldKey = `${this.currentIndex}-${startIdx}`;
+ const collapseKey = `${this.currentIndex}-${startIdx}-collapsed`;
+
+ if (this.foldAgreementBlocks) {
+ if (this.expandedFolds.has(foldKey)) {
+ this.expandedFolds.delete(foldKey);
+ } else {
+ this.expandedFolds.add(foldKey);
+ }
+ } else {
+ if (this.expandedFolds.has(collapseKey)) {
+ this.expandedFolds.delete(collapseKey);
+ } else {
+ this.expandedFolds.add(collapseKey);
+ }
+ }
+ const item = curationItems[this.currentIndex];
+ const versions = item.all_versions || item.all_version;
+ this.renderBody(item, versions);
+ },
+
+ toggleColumnFold(groupIdx) {
+ const colKey = `${this.currentIndex}-col-${groupIdx}`;
+ if (this.expandedFolds.has(colKey)) {
+ this.expandedFolds.delete(colKey);
+ } else {
+ this.expandedFolds.add(colKey);
+ }
+ const item = curationItems[this.currentIndex];
+ const versions = item.all_versions || item.all_version;
+
+ this.renderHeader(item);
+ this.renderBody(item, versions);
+ },
+
+ toggleRanges() {
+ this.showRanges = !this.showRanges;
+ const item = curationItems[this.currentIndex];
+ const versions = item.all_versions || item.all_version;
+ this.renderBody(item, versions);
+ },
+
+ cycleState(v) {
+ const seq = ['unaffected', 'affected', 'fixed'];
+ const current = this.userStates[this.currentIndex][v];
+ this.userStates[this.currentIndex][v] = seq[(seq.indexOf(current) + 1) % 3];
+ const item = curationItems[this.currentIndex];
+ const versions = item.all_versions || item.all_version;
+ this.renderBody(item, versions);
+ },
+
+ pickAdvisory(advIdx, type, secondaryIdx) {
+ const item = curationItems[this.currentIndex];
+ const advGroup = item.advisories[advIdx];
+ const versions = item.all_versions || item.all_version;
+
+ versions.forEach(v => {
+ if (advGroup.affected.includes(v)) this.userStates[this.currentIndex][v] = 'affected';
+ else if (advGroup.fixing.includes(v)) this.userStates[this.currentIndex][v] = 'fixed';
+ else this.userStates[this.currentIndex][v] = 'unaffected';
+ });
+ this.renderBody(item, versions);
+ },
+
+ navigate(dir) {
+ this.currentIndex += dir;
+ this.renderPackageCuration();
+ },
+
+ updateNavButtons() {
+ document.getElementById('prev-btn').disabled = this.currentIndex === 0;
+ const isLast = this.currentIndex === curationItems.length - 1;
+ document.getElementById('next-btn').classList.toggle('is-hidden', isLast);
+ document.getElementById('finish-btn').classList.toggle('is-hidden', !isLast);
+ },
+
+};
+document.addEventListener('DOMContentLoaded', () => app.init());
\ No newline at end of file
diff --git a/vulnerablecode/static/js/package_curation.min.js b/vulnerablecode/static/js/package_curation.min.js
new file mode 100644
index 000000000..eeb5af880
--- /dev/null
+++ b/vulnerablecode/static/js/package_curation.min.js
@@ -0,0 +1,48 @@
+const app={currentIndex:0,userStates:{},expandedFolds:new Set,showRanges:!1,foldAgreementBlocks:!0,init(){this.renderPackageCuration(),document.querySelector(".summary-text")?.addEventListener("click",e=>{e.target.closest("a")&&(e.target.target="_blank")})},renderPackageCuration(){const t=curationItems[this.currentIndex];var e=t.all_versions||t.all_version,s=curationItems.length,n=(this.currentIndex+1)/s*100;document.getElementById("main-progress").value=n,document.getElementById("progress-text").innerText=this.currentIndex+1+" / "+s,document.getElementById("current-purl").innerText=t.purl,document.getElementById("conflict-reason").innerText=t.conflict_reason,this.userStates[this.currentIndex]||(this.userStates[this.currentIndex]={},e.forEach(e=>{t.partial_curation.affected.includes(e)?this.userStates[this.currentIndex][e]="affected":t.partial_curation.fixing.includes(e)?this.userStates[this.currentIndex][e]="fixed":this.userStates[this.currentIndex][e]="?"})),this.renderHeader(t),this.renderBody(t,e),this.updateNavButtons()},renderHeader:function(e){const d=document.getElementById("table-header");d.innerHTML=`
+ Version |
+
+
+ | `,e.advisories.forEach((e,a)=>{var t=e.secondaries||[],s=0
+
+ ${e.primary.advisory_uid}
+
+
+
+ `;let r="";s&&(r=`
+
+ `),i.innerHTML=`
+
+
+ ${r}
+ ${e}
+
+
+
+ `,d.appendChild(i),s&&n&&t.forEach((e,t)=>{var s=document.createElement("th"),n=(s.className="has-text-centered",s.style.backgroundColor="#fafafa",baseAdvisoryUrl.replace("0",e.advisory_uid));s.innerHTML=`
+
+ `,d.appendChild(s)})})},renderBody:function(e,s){var a=document.getElementById("curation-body");a.innerHTML="";let n=2;e.advisories.forEach((e,t)=>{n+=1,t=this.currentIndex+"-col-"+t,this.expandedFolds.has(t)&&(n+=(e.secondaries||[]).length)});var t=document.createElement("tr");if(t.innerHTML=`
+
+
+ ${this.showRanges?"Hide":"Show"} Version Ranges
+ | `,a.appendChild(t),this.showRanges){t=document.createElement("tr");let n=" | | ";e.advisories.forEach((e,t)=>{t=this.currentIndex+"-col-"+t,t=this.expandedFolds.has(t);const s=e=>e.map(e=>{var t=[];return e.affected_vers&&""!==e.affected_vers.trim()&&t.push(`Affected: ${e.affected_vers}
`),e.fixing_vers&&""!==e.fixing_vers.trim()&&t.push(`Fixing: ${e.fixing_vers}
`),0No range specified"}).join('
');n+=`${s(e.primary.vers_ranges)} | `,t&&e.secondaries&&e.secondaries.forEach(e=>{n+=`${s(e.vers_ranges)} | `})}),t.innerHTML=n,a.appendChild(t)}var i=this.getFoldableRanges(e,s);for(let t=0;tt>=e.start&&t<=e.end);if(r){var d=this.currentIndex+"-"+r.start;let e=this.expandedFolds.has(d);if(this.foldAgreementBlocks||(e=!this.expandedFolds.has(`${this.currentIndex}-${r.start}-collapsed`)),t===r.start&&((d=document.createElement("tr")).innerHTML=`
+
+ ${e?"Hide":"Show"} Consensus Range (${r.end-r.start+1} versions)
+ | `,a.appendChild(d)),!e){if(t===r.end)continue;t=r.end;continue}}a.appendChild(this.createRow(s[t],e))}},resetCurrentCuration(){const t=curationItems[this.currentIndex];var e=t.all_versions||t.all_version;e.forEach(e=>{t.partial_curation.affected.includes(e)?this.userStates[this.currentIndex][e]="affected":t.partial_curation.fixing.includes(e)?this.userStates[this.currentIndex][e]="fixed":this.userStates[this.currentIndex][e]="?"}),this.renderBody(t,e)},createRow(a,e){const i=document.createElement("tr");var t=this.userStates[this.currentIndex][a],s=(i.innerHTML=`${a} | `,document.createElement("td"));return s.className=`curation-cell state-${t} `,s.innerText=t.toUpperCase(),s.onclick=()=>this.cycleState(a),i.appendChild(s),e.advisories.forEach((s,e)=>{var e=this.currentIndex+"-col-"+e,e=this.expandedFolds.has(e),t=s.affected.includes(a)?"affected":s.fixing.includes(a)?"fixed":"unaffected",n=document.createElement("td");n.className="advisory-cell state-"+t,n.innerText=t.toUpperCase(),i.appendChild(n),e&&s.secondaries&&s.secondaries.forEach(e=>{var t=e.affected||s.affected,e=e.fixing||s.fixing,t=t.includes(a)?"affected":e.includes(a)?"fixed":"unaffected";(e=document.createElement("td")).className="advisory-cell state-"+t,e.style.borderLeft="1px dashed #dbdbdb",e.innerText=t.toUpperCase(),i.appendChild(e)})}),i},getFoldableRanges(t,s){var n=[];let a=-1;for(let e=0;ee.affected.includes(i)?"affected":e.fixing.includes(i)?"fixed":"unaffected");r.every(e=>e===r[0])?-1===a&&(a=e):(-1!==a&&3<=e-a&&n.push({start:a,end:e-1}),a=-1)}return-1!==a&&3<=s.length-a&&n.push({start:a,end:s.length-1}),n},toggleFold(e){var t=this.currentIndex+"-"+e,e=this.currentIndex+`-${e}-collapsed`;this.foldAgreementBlocks?this.expandedFolds.has(t)?this.expandedFolds.delete(t):this.expandedFolds.add(t):this.expandedFolds.has(e)?this.expandedFolds.delete(e):this.expandedFolds.add(e);e=(t=curationItems[this.currentIndex]).all_versions||t.all_version;this.renderBody(t,e)},toggleColumnFold(e){e=this.currentIndex+"-col-"+e;this.expandedFolds.has(e)?this.expandedFolds.delete(e):this.expandedFolds.add(e);var t=(e=curationItems[this.currentIndex]).all_versions||e.all_version;this.renderHeader(e),this.renderBody(e,t)},toggleRanges(){this.showRanges=!this.showRanges;var e=curationItems[this.currentIndex],t=e.all_versions||e.all_version;this.renderBody(e,t)},cycleState(e){var t=["unaffected","affected","fixed"],s=this.userStates[this.currentIndex][e];this.userStates[this.currentIndex][e]=t[(t.indexOf(s)+1)%3];t=(e=curationItems[this.currentIndex]).all_versions||e.all_version;this.renderBody(e,t)},pickAdvisory(e,t,s){var n=curationItems[this.currentIndex];const a=n.advisories[e];(e=n.all_versions||n.all_version).forEach(e=>{a.affected.includes(e)?this.userStates[this.currentIndex][e]="affected":a.fixing.includes(e)?this.userStates[this.currentIndex][e]="fixed":this.userStates[this.currentIndex][e]="unaffected"}),this.renderBody(n,e)},navigate(e){this.currentIndex+=e,this.renderPackageCuration()},updateNavButtons(){document.getElementById("prev-btn").disabled=0===this.currentIndex;var e=this.currentIndex===curationItems.length-1;document.getElementById("next-btn").classList.toggle("is-hidden",e),document.getElementById("finish-btn").classList.toggle("is-hidden",!e)}};document.addEventListener("DOMContentLoaded",()=>app.init());
\ No newline at end of file
diff --git a/vulnerablecode/urls.py b/vulnerablecode/urls.py
index c9bee1247..292938b8f 100644
--- a/vulnerablecode/urls.py
+++ b/vulnerablecode/urls.py
@@ -31,6 +31,7 @@
from vulnerabilities.api_v3 import PackageV3ViewSet
from vulnerabilities.views import AdminLoginView
from vulnerabilities.views import AdvisoryDetails
+from vulnerabilities.views import AdvisoryPackageCurationView
from vulnerabilities.views import AdvisoryPackagesDetails
from vulnerabilities.views import AdvisoryToDoListView
from vulnerabilities.views import AffectedByAdvisoriesListView
@@ -105,6 +106,11 @@ def __init__(self, *args, **kwargs):
AdvisoryToDoListView.as_view(),
name="todo-list",
),
+ path(
+ "advisories/todos//package/curate/",
+ AdvisoryPackageCurationView.as_view(),
+ name="todo-detail",
+ ),
path(
"pipelines//runs/",
PipelineRunListView.as_view(),