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..b1a9b4469 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 "\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. @@ -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..c08e7917b 100644 --- a/vulnerabilities/templates/advisory_todos.html +++ b/vulnerabilities/templates/advisory_todos.html @@ -32,7 +32,7 @@
-
+

Advisory To-Dos


@@ -100,9 +100,9 @@

Advisory To-Dos

-
-
- {% for val, label in form.fields.issue_type.choices %}
+ `;let i="";s&&(i=` + + `),r.innerHTML=` +
+
+ ${i} + ${e} +
+ +
+ `,d.appendChild(r),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(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 r=this.getFoldableRanges(e,s);for(let t=0;tt>=e.start&&t<=e.end);if(i){var d=this.currentIndex+"-"+i.start;let e=this.expandedFolds.has(d);if(this.foldAgreementBlocks||(e=!this.expandedFolds.has(`${this.currentIndex}-${i.start}-collapsed`)),t===i.start&&((d=document.createElement("tr")).innerHTML=` + + ${e?"Hide":"Show"} Consensus Range (${i.end-i.start+1} versions) + `,a.appendChild(d)),!e){if(t===i.end)continue;t=i.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 r=document.createElement("tr");var t=this.userStates[this.currentIndex][a],s=(r.innerHTML=`${a}`,document.createElement("td"));return s.className=`curation-cell state-${t} `,s.innerText=t.toUpperCase(),s.onclick=()=>this.cycleState(a),r.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=`state-${t} has-text-centered`,n.innerText=t.toUpperCase(),r.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=`state-${t} has-text-centered`,e.style.borderLeft="1px dashed #dbdbdb",e.innerText=t.toUpperCase(),r.appendChild(e)})}),r},getFoldableRanges(t,s){var n=[];let a=-1;for(let e=0;ee.affected.includes(r)?"affected":e.fixing.includes(r)?"fixed":"unaffected");i.every(e=>e===i[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(),