Skip to content
Original file line number Diff line number Diff line change
@@ -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."
),
),
]
7 changes: 5 additions & 2 deletions vulnerabilities/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
)

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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,
Expand Down
119 changes: 107 additions & 12 deletions vulnerabilities/pipelines/v2_improvers/compute_advisory_todo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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),
}


Expand Down Expand Up @@ -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()
Expand All @@ -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)

Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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 "\n".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.
Expand Down Expand Up @@ -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"])

Expand All @@ -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)

Expand Down
16 changes: 12 additions & 4 deletions vulnerabilities/templates/advisory_todos.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
<div class="column">
</div>

<div class="column is-four-fifths">
<div class="column is-11">
<div class="content is-normal">
<h1>Advisory To-Dos</h1>
<hr />
Expand Down Expand Up @@ -100,9 +100,9 @@ <h1>Advisory To-Dos</h1>

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

<div class="column" style="flex: 0 0 40%;">
<div class="select is-half">
<select name="issue_type" onchange="this.form.submit()">
<div class="column">
<div class="select is-fullwidth">
<select name="issue_type" onchange="this.form.submit()" >
{% for val, label in form.fields.issue_type.choices %}
<option value="{{ val }}"
{% if form.issue_type.value == val %}selected{% endif %}>
Expand All @@ -122,6 +122,10 @@ <h1>Advisory To-Dos</h1>
{% for todo in todo_list %}
<tr>
<td colspan="4">
{% with supported_curation="CONFLICTING_FIXED_BY_PACKAGES CONFLICTING_AFFECTED_PACKAGES CONFLICTING_AFFECTED_AND_FIXED_BY_PACKAGES" %}
{% if todo.issue_type in supported_curation.split %}
<a href="{% url 'todo-detail' todo_id=todo.todo_id %}" class="has-text-info">
{% endif %}
<div class="columns px-1 is-vcentered">
<div class="column has-text-left" style="flex: 0 0 20%;">
{{ todo.alias }}
Expand All @@ -139,6 +143,10 @@ <h1>Advisory To-Dos</h1>
{{ todo.get_issue_type_display }}
</div>
</div>
{% if todo.issue_type in supported_curation.split %}
</a>
{% endif %}
{% endwith %}
</td>
</tr>
{% empty %}
Expand Down
101 changes: 101 additions & 0 deletions vulnerabilities/templates/package_curation.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
{% extends "base.html" %}
{% load static %}
{% load utils %}

{% block extrahead %}
<link rel="stylesheet" href="{% static 'css/package_curation.css' %}">
{% endblock %}

{% block content %}
<section class="section">
<div class="container is-fluid">
<div class="columns is-vcentered mb-5">
<div class="column">
<h1 class="title is-3">Advisory Curation</h1>
<h2 class="subtitle is-5 has-text-grey">{{ vulnerability_id }}</h2>
</div>

<div class="column is-narrow">
<div class="box p-4 has-background-white shadow-soft" style="min-width: 260px; border-radius: 5px;">
<div class="is-flex is-justify-content-space-between is-align-items-center mb-2">
<span class="is-size-6 has-text-grey-dark uppercase has-text-weight-bold mr-2">
Progress
</span>
<span class="tag is-info is-light has-text-weight-bold" id="progress-text">0 / 0</span>
</div>
<progress class="progress is-info is-small mb-0" id="progress" value="0" max="100"></progress>
</div>
</div>

</div>

<div class="columns">
<div class="column is-3">
<h3 class="title is-5">Advisory Summaries</h3>
<div id="summaries-container" style="max-height: 800px; overflow-y: auto;">
{% for avid, text in advisory_summaries.items %}
<div class="notification is-info is-light p-3 mb-3">
<p><strong>{{ avid }}</strong></p>
<div class="text summary-text is-2">{{ text|normalize_links|urlize }}</div>
</div>
{% empty %}
<div class="notification is-light p-3 mb-3 has-text-grey">
No summaries available.
</div>
{% endfor %}
</div>
</div>

<div class="column is-9">
<div class="box">
<div class="mb-4">
<h4 class="title is-4 mb-1" id="current-purl"></h4>
<p class="text is-5" id="conflict-reason"></p>
</div>

<div class="table-container" style="max-height: 800px; overflow-y: auto; border: 1px solid #dbdbdb; border-radius: 6px;">
<table class="table is-bordered is-narrow is-fullwidth is-hoverable">
<thead style="position: sticky; top: 0; background: white; z-index: 10;">
<tr id="table-header">

</tr>
</thead>
<tbody id="curation-body">

</tbody>
</table>
</div>

<div class="level mt-5">
<div class="level-left">
<button class="button is-light" id="prev-btn" onclick="app.navigate(-1)">
<span class="icon"><i class="fa fa-chevron-left"></i></span>
<span>Previous Item</span>
</button>
</div>
<div class="level-right">
<button class="button is-info" id="next-btn" onclick="app.navigate(1)">
<span>Next Item</span>
<span class="icon"><i class="fa fa-chevron-right"></i></span>
</button>
<button class="button is-info is-hidden" id="finish-btn" disabled>
<span class="icon"><i class="fa fa-check"></i></span>
<span>Submit</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{% endblock %}


{% block scripts %}
<script>
const baseAdvisoryUrl = "{% url 'advisory_details' 0 %}";
const curationItems = {{ curation_items|safe }};
</script>
<script src="{% static 'js/package_curation.min.js' %}"></script>
{% endblock %}
Loading