Skip to content
Open
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
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 " ".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
8 changes: 8 additions & 0 deletions vulnerabilities/templates/advisory_todos.html
Original file line number Diff line number Diff line change
Expand Up @@ -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">Curation Dashboard</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-7 has-text-grey-dark uppercase has-text-weight-bold mr-2 mt-1">
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="main-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="summary-text">{{ 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="help" 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>Validate</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 %}
12 changes: 12 additions & 0 deletions vulnerabilities/templatetags/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
#


import re

from aboutcode.pipeline import humanize_time
from django import template

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