diff --git a/vulnerabilities/improvers/__init__.py b/vulnerabilities/improvers/__init__.py index 51694bde9..0a1b5e7b5 100644 --- a/vulnerabilities/improvers/__init__.py +++ b/vulnerabilities/improvers/__init__.py @@ -21,6 +21,7 @@ from vulnerabilities.pipelines.v2_improvers import ( group_advisories_for_packages as group_advisories_for_packages_v2, ) +from vulnerabilities.pipelines.v2_improvers import history_diff_backfill from vulnerabilities.pipelines.v2_improvers import mark_unfurl_version_range from vulnerabilities.pipelines.v2_improvers import reference_collect_commits from vulnerabilities.pipelines.v2_improvers import relate_severities @@ -42,6 +43,7 @@ reference_collect_commits.CollectReferencesFixCommitsPipeline, enhance_with_github_poc.GithubPocsImproverPipeline, mark_unfurl_version_range.MarkUnfurlVersionRangePipeline, + history_diff_backfill.HistoryDiffImproverPipeline, group_advisories_for_packages_v2.GroupAdvisoriesForPackages, ] ) diff --git a/vulnerabilities/migrations/0138_advisoryhistorydiff.py b/vulnerabilities/migrations/0138_advisoryhistorydiff.py new file mode 100644 index 000000000..7e7efc21e --- /dev/null +++ b/vulnerabilities/migrations/0138_advisoryhistorydiff.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.11 on 2026-07-25 16:54 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vulnerabilities', '0137_alter_pipelineschedule_run_interval'), + ] + + operations = [ + migrations.CreateModel( + name='AdvisoryHistoryDiff', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('summary_added', models.TextField(blank=True, null=True)), + ('summary_removed', models.TextField(blank=True, null=True)), + ('url_added', models.URLField(blank=True, null=True)), + ('url_removed', models.URLField(blank=True, null=True)), + ('added_aliases', models.ManyToManyField(blank=True, related_name='added_in_history', to='vulnerabilities.advisoryalias')), + ('added_impacted_packages', models.ManyToManyField(blank=True, related_name='added_in_history', to='vulnerabilities.impactedpackage')), + ('added_patches', models.ManyToManyField(blank=True, related_name='added_in_history', to='vulnerabilities.patch')), + ('added_references', models.ManyToManyField(blank=True, related_name='added_in_history', to='vulnerabilities.advisoryreference')), + ('added_severities', models.ManyToManyField(blank=True, related_name='added_in_history', to='vulnerabilities.advisoryseverity')), + ('added_weaknesses', models.ManyToManyField(blank=True, related_name='added_in_history', to='vulnerabilities.advisoryweakness')), + ('advisory_after', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='history_diff', to='vulnerabilities.advisoryv2')), + ('advisory_before', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='history_diff_before', to='vulnerabilities.advisoryv2')), + ('removed_aliases', models.ManyToManyField(blank=True, related_name='removed_in_history', to='vulnerabilities.advisoryalias')), + ('removed_impacted_packages', models.ManyToManyField(blank=True, related_name='removed_in_history', to='vulnerabilities.impactedpackage')), + ('removed_patches', models.ManyToManyField(blank=True, related_name='removed_in_history', to='vulnerabilities.patch')), + ('removed_references', models.ManyToManyField(blank=True, related_name='removed_in_history', to='vulnerabilities.advisoryreference')), + ('removed_severities', models.ManyToManyField(blank=True, related_name='removed_in_history', to='vulnerabilities.advisoryseverity')), + ('removed_weaknesses', models.ManyToManyField(blank=True, related_name='removed_in_history', to='vulnerabilities.advisoryweakness')), + ], + ), + ] diff --git a/vulnerabilities/migrations/0142_merge_20260726_0000.py b/vulnerabilities/migrations/0142_merge_20260726_0000.py new file mode 100644 index 000000000..34e4525ca --- /dev/null +++ b/vulnerabilities/migrations/0142_merge_20260726_0000.py @@ -0,0 +1,14 @@ +# Generated by Django 5.2.11 on 2026-07-26 00:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('vulnerabilities', '0138_advisoryhistorydiff'), + ('vulnerabilities', '0141_advisorymitigations'), + ] + + operations = [ + ] diff --git a/vulnerabilities/models.py b/vulnerabilities/models.py index 826d35a54..ded3a749f 100644 --- a/vulnerabilities/models.py +++ b/vulnerabilities/models.py @@ -3367,6 +3367,73 @@ def get_aliases(self): alias = get_aliases +class AdvisoryHistoryDiff(models.Model): + """ + Stores the pre-computed diff between an AdvisoryV2 snapshot (advisory_after) + and its immediate predecessor (advisory_before). + Created for every snapshot. + """ + + # Points to the older snapshot in the comparison + advisory_before = models.OneToOneField( + AdvisoryV2, + on_delete=models.SET_NULL, + related_name="history_diff_before", + null=True, # The very first snapshot won't have a predecessor + blank=True, + ) + + # Points to the newer snapshot in the comparison + advisory_after = models.OneToOneField( + AdvisoryV2, on_delete=models.SET_NULL, related_name="history_diff", null=True, blank=True + ) + + summary_added = models.TextField(blank=True, null=True) + summary_removed = models.TextField(blank=True, null=True) + + url_added = models.URLField(blank=True, null=True) + url_removed = models.URLField(blank=True, null=True) + + added_severities = models.ManyToManyField( + "AdvisorySeverity", related_name="added_in_history", blank=True + ) + removed_severities = models.ManyToManyField( + "AdvisorySeverity", related_name="removed_in_history", blank=True + ) + + added_impacted_packages = models.ManyToManyField( + "ImpactedPackage", related_name="added_in_history", blank=True + ) + removed_impacted_packages = models.ManyToManyField( + "ImpactedPackage", related_name="removed_in_history", blank=True + ) + + added_references = models.ManyToManyField( + "AdvisoryReference", related_name="added_in_history", blank=True + ) + removed_references = models.ManyToManyField( + "AdvisoryReference", related_name="removed_in_history", blank=True + ) + + added_aliases = models.ManyToManyField( + "AdvisoryAlias", related_name="added_in_history", blank=True + ) + removed_aliases = models.ManyToManyField( + "AdvisoryAlias", related_name="removed_in_history", blank=True + ) + + added_weaknesses = models.ManyToManyField( + "AdvisoryWeakness", related_name="added_in_history", blank=True + ) + removed_weaknesses = models.ManyToManyField( + "AdvisoryWeakness", related_name="removed_in_history", blank=True + ) + + added_patches = models.ManyToManyField("Patch", related_name="added_in_history", blank=True) + removed_patches = models.ManyToManyField("Patch", related_name="removed_in_history", blank=True) + + + class ImpactedPackage(models.Model): """ Represents a single impact for an advisory, including affected range and fixed version and diff --git a/vulnerabilities/pipelines/__init__.py b/vulnerabilities/pipelines/__init__.py index 5940363b0..508bec9d1 100644 --- a/vulnerabilities/pipelines/__init__.py +++ b/vulnerabilities/pipelines/__init__.py @@ -284,6 +284,10 @@ class VulnerableCodeBaseImporterPipelineV2(VulnerableCodePipeline): # pull requests, commit messages, EPSS, exploits, etc. exclude_from_package_todo = False + # Set this to True if computing history diff is not fruitful for this source. + # An example of such advisory would be pipeline dedicated to EPSS + exclude_from_history_diff = False + # Control how often progress log is shown (range: 1–100, higher value = less frequent log) progress_step = 10 @@ -344,6 +348,7 @@ def collect_and_store_advisories(self): datasource_id=self.datasource_id, logger=self.log, precedence=self.precedence, + calculate_history_diff=not self.exclude_from_history_diff, ): collected_advisory_count += 1 except Exception as e: diff --git a/vulnerabilities/pipelines/v2_improvers/history_diff_backfill.py b/vulnerabilities/pipelines/v2_improvers/history_diff_backfill.py new file mode 100644 index 000000000..f53a2e324 --- /dev/null +++ b/vulnerabilities/pipelines/v2_improvers/history_diff_backfill.py @@ -0,0 +1,81 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# VulnerableCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/aboutcode-org/vulnerablecode for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# + +from aboutcode.pipeline import LoopProgress + +from vulnerabilities.importers import IMPORTERS_REGISTRY +from vulnerabilities.models import AdvisoryHistoryDiff +from vulnerabilities.models import AdvisoryV2 +from vulnerabilities.pipelines import VulnerableCodePipeline +from vulnerabilities.utils import compute_advisory_v2_diff + + +class HistoryDiffImproverPipeline(VulnerableCodePipeline): + """ + Pipeline to compute and store relational history snapshot diffs. + """ + + pipeline_id = "history_diff_improver_v2" + + @classmethod + def steps(cls): + return (cls.calculate_history_diffs,) + + def calculate_history_diffs(self): + # Skip excluded importers + excluded_ids = [ + cls.pipeline_id + for cls in IMPORTERS_REGISTRY.values() + if getattr(cls, "exclude_from_history_diff", False) + and getattr(cls, "pipeline_id", None) + ] + qs = ( + AdvisoryV2.objects.exclude(pipeline_id__in=excluded_ids) + if excluded_ids + else AdvisoryV2.objects.all() + ) + + avids_qs = qs.filter(history_diff__isnull=True).values_list("avid", flat=True).distinct() + avids_count = avids_qs.count() + self.log(f"Computing history diffs for {avids_count} advisories") + + for avid in LoopProgress( + total_iterations=avids_count, logger=self.log, progress_step=10 + ).iter(avids_qs.iterator()): + snapshots = list( + qs.filter(avid=avid) + .order_by("date_collected", "unique_content_id") + .prefetch_related( + "aliases", + "references", + "weaknesses", + "severities", + "patches", + "impacted_packages", + ) + ) + + previous_snapshot = None + + # TODO: + # Bulk diff creation + + for snapshot in snapshots: + if not hasattr(snapshot, "history_diff"): # Skip already processed advisories + if previous_snapshot is not None: + compute_advisory_v2_diff(previous_snapshot, snapshot) + else: + # For the very first snapshot, we create an empty diff to mark it as processed + AdvisoryHistoryDiff.objects.create( + advisory_after=snapshot, advisory_before=None + ) + + previous_snapshot = snapshot + + self.log("Successfully finished history diff backfill.") diff --git a/vulnerabilities/pipes/advisory.py b/vulnerabilities/pipes/advisory.py index fec72e66f..a91046605 100644 --- a/vulnerabilities/pipes/advisory.py +++ b/vulnerabilities/pipes/advisory.py @@ -33,6 +33,7 @@ from vulnerabilities.improver import MAX_CONFIDENCE from vulnerabilities.models import Advisory from vulnerabilities.models import AdvisoryAlias +from vulnerabilities.models import AdvisoryHistoryDiff from vulnerabilities.models import AdvisoryReference from vulnerabilities.models import AdvisorySeverity from vulnerabilities.models import AdvisoryV2 @@ -49,6 +50,7 @@ from vulnerabilities.models import Weakness from vulnerabilities.pipes.risk_score import compute_advisory_risk_score from vulnerabilities.pipes.univers_utils import get_exact_purls_v2 +from vulnerabilities.utils import compute_advisory_v2_diff def get_or_create_aliases(aliases: List) -> QuerySet: @@ -295,6 +297,7 @@ def insert_advisory_v2( logger: Callable, datasource_id: str, precedence: int = 0, + calculate_history_diff: bool = True, ): from vulnerabilities.models import ImpactedPackage from vulnerabilities.models import PackageV2 @@ -338,12 +341,16 @@ def insert_advisory_v2( if not created: return advisory_obj - AdvisoryV2.objects.filter( + latest_qs = AdvisoryV2.objects.filter( avid=f"{datasource_id}/{advisory.advisory_id}", is_latest=True, - ).update(is_latest=False) + ) + + previous_latest_advisory = latest_qs.first() + latest_qs.update(is_latest=False) + advisory_obj.is_latest = True - advisory_obj.save() + advisory_obj.save(update_fields=["is_latest"]) aliases = get_or_create_advisory_aliases(aliases=advisory.aliases) references = get_or_create_advisory_references(references=advisory.references) @@ -416,6 +423,31 @@ def insert_advisory_v2( ) impact.introduced_by_package_commit_patches.add(*introduced_commit_v2) impact.fixed_by_package_commit_patches.add(*fixed_commit_v2) + + if calculate_history_diff: + if previous_latest_advisory: + previous = AdvisoryV2.objects.prefetch_related( + "aliases", + "references", + "weaknesses", + "severities", + "patches", + "impacted_packages", + ).get(id=previous_latest_advisory.id) + + current = AdvisoryV2.objects.prefetch_related( + "aliases", + "references", + "weaknesses", + "severities", + "patches", + "impacted_packages", + ).get(id=advisory_obj.id) + + compute_advisory_v2_diff(previous, current) + else: + AdvisoryHistoryDiff.objects.create(advisory_after=advisory_obj, advisory_before=None) + return advisory_obj diff --git a/vulnerabilities/templates/advisory_detail.html b/vulnerabilities/templates/advisory_detail.html index fea5afde4..16cb4caf2 100644 --- a/vulnerabilities/templates/advisory_detail.html +++ b/vulnerabilities/templates/advisory_detail.html @@ -4,6 +4,7 @@ {% load static %} {% load show_cvss %} {% load url_filters %} +{% load diff_advisory_history %} {% block title %} VulnerableCode Advisory Details - {{ advisory.advisory_id }} @@ -23,6 +24,13 @@ + {% if is_snapshot %} +
+ Historical snapshot collected on {{ advisory.date_collected|date:"Y-m-d H:i" }} UTC. + View current version +
+ {% endif %} +
@@ -737,6 +872,36 @@ {% endblock %} \ No newline at end of file diff --git a/vulnerabilities/templates/advisory_package_details.html b/vulnerabilities/templates/advisory_package_details.html index 85c1dcaba..66853a77e 100644 --- a/vulnerabilities/templates/advisory_package_details.html +++ b/vulnerabilities/templates/advisory_package_details.html @@ -13,6 +13,13 @@ {% if advisoryv2 %}
+ {% if is_snapshot %} +
+ Historical snapshot collected on {{ advisoryv2.date_collected|date:"Y-m-d H:i" }} UTC. + View current version +
+ {% endif %} +
diff --git a/vulnerabilities/templatetags/diff_advisory_history.py b/vulnerabilities/templatetags/diff_advisory_history.py new file mode 100644 index 000000000..f2be18c1f --- /dev/null +++ b/vulnerabilities/templatetags/diff_advisory_history.py @@ -0,0 +1,101 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# VulnerableCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/aboutcode-org/vulnerablecode for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# + +from django import template + +from vulnerabilities.models import AdvisoryHistoryDiff + +register = template.Library() + + +@register.filter +def format_diff_for_ui(diff: AdvisoryHistoryDiff) -> dict: + formatted = {} + + if diff.summary_added or diff.summary_removed: + formatted["Summary"] = {"old": diff.summary_removed, "new": diff.summary_added} + + if diff.url_added or diff.url_removed: + formatted["Url"] = {"old": diff.url_removed, "new": diff.url_added} + + def _process_added_removed(label, added_qs, removed_qs, formatter): + added = [formatter(item) for item in added_qs] + removed = [formatter(item) for item in removed_qs] + if added or removed: + formatted[label] = {"added": added, "removed": removed} + + def format_severity(severity): + scoring_system_string = str(severity.scoring_system) + if "cvss" in scoring_system_string.lower(): + scoring_system_string = scoring_system_string.upper() + else: + scoring_system_string = scoring_system_string.replace("_", " ").title() + + attrs = [("System", scoring_system_string)] + if severity.value: + attrs.append(("Value", severity.value)) + if severity.scoring_elements: + attrs.append(("Elements", severity.scoring_elements)) + return {"header": "Severity", "attributes": attrs} + + _process_added_removed( + "Severities", diff.added_severities.all(), diff.removed_severities.all(), format_severity + ) + + def format_package(package): + attrs = [("PURL", package.base_purl)] + if package.affecting_vers: + attrs.append(("Affected version", package.affecting_vers)) + if package.fixed_vers: + attrs.append(("Fixed Version", package.fixed_vers)) + return {"header": "Affected package", "attributes": attrs} + + _process_added_removed( + "Affected Packages", + diff.added_impacted_packages.all(), + diff.removed_impacted_packages.all(), + format_package, + ) + + def format_reference(reference): + attrs = [] + if reference.url: + attrs.append(("URL", reference.url)) + if reference.reference_id: + attrs.append(("ID", reference.reference_id)) + return {"header": "Reference", "attributes": attrs} + + _process_added_removed( + "References", diff.added_references.all(), diff.removed_references.all(), format_reference + ) + + def format_patch(patch): + attrs = [] + if patch.url or patch.repository: + attrs.append(("URL", patch.url or patch.repository)) + if patch.commit: + attrs.append(("Commit", patch.commit)) + return {"header": "Patch", "attributes": attrs} + + _process_added_removed( + "Patches", diff.added_patches.all(), diff.removed_patches.all(), format_patch + ) + + _process_added_removed( + "Weaknesses", + diff.added_weaknesses.all(), + diff.removed_weaknesses.all(), + lambda weakness: weakness.cwe_id, + ) + + _process_added_removed( + "Aliases", diff.added_aliases.all(), diff.removed_aliases.all(), lambda alias: alias.alias + ) + + return formatted diff --git a/vulnerabilities/tests/test_advisory_history.py b/vulnerabilities/tests/test_advisory_history.py new file mode 100644 index 000000000..85d2ebcec --- /dev/null +++ b/vulnerabilities/tests/test_advisory_history.py @@ -0,0 +1,91 @@ +from datetime import timedelta + +import pytest +from django.utils import timezone + +from vulnerabilities.models import AdvisoryHistoryDiff +from vulnerabilities.models import AdvisorySeverity +from vulnerabilities.models import AdvisoryV2 +from vulnerabilities.models import ImpactedPackage +from vulnerabilities.pipelines.v2_improvers.history_diff_backfill import HistoryDiffImproverPipeline + + +@pytest.mark.django_db +def test_advisory_history_diffing(): + """ + Test the diffing logic for historical advisory snapshots. + """ + avid1 = "github_osv/GHSA-72hv-8253-57qq" + + severity_high = AdvisorySeverity.objects.create(scoring_system="generic_textual", value="HIGH") + severity_moderate = AdvisorySeverity.objects.create( + scoring_system="generic_textual", value="MODERATE" + ) + + old_advisory_snapshot = AdvisoryV2.objects.create( + advisory_id="GHSA-72hv-8253-57qq", + unique_content_id="snap1hash", + date_collected=timezone.now(), + pipeline_id="test", + datasource_id="github_osv", + url="http://test.com", + avid=avid1, + summary="Initial summary", + ) + old_advisory_snapshot.severities.add(severity_high) + + new_advisory_snapshot = AdvisoryV2.objects.create( + advisory_id="GHSA-72hv-8253-57qq", + unique_content_id="snap2hash", + date_collected=timezone.now() + timedelta(days=1), + pipeline_id="test", + datasource_id="github_osv", + url="http://test.com", + avid=avid1, + summary="Updated summary", + ) + new_advisory_snapshot.severities.add(severity_moderate) + + pkg1 = ImpactedPackage.objects.create( + advisory=old_advisory_snapshot, + base_purl="pkg:maven/tools.jackson.core/jackson-core", + affecting_vers="vers:maven/<=2.18.5", + fixed_vers="vers:maven/2.18.6", + ) + pkg2 = ImpactedPackage.objects.create( + advisory=old_advisory_snapshot, + base_purl="pkg:maven/tools.jackson.core/jackson-core", + affecting_vers="vers:maven/>=2.19.0|<2.21.1", + fixed_vers="vers:maven/2.21.1", + ) + pkg3 = ImpactedPackage.objects.create( + advisory=old_advisory_snapshot, + base_purl="pkg:maven/com.fasterxml.jackson.core/jackson-core", + affecting_vers="vers:maven/>=3.0.0|<3.1.0", + fixed_vers="vers:maven/3.1.0", + ) + + pipeline = HistoryDiffImproverPipeline() + pipeline.execute() + + # Run a second time to ensure idempotency + pipeline.execute() + + old_advisory_snapshot.refresh_from_db() + new_advisory_snapshot.refresh_from_db() + + assert hasattr(old_advisory_snapshot, "history_diff") + assert old_advisory_snapshot.history_diff.advisory_before is None + + diff = AdvisoryHistoryDiff.objects.get(advisory_after=new_advisory_snapshot) + assert diff.summary_removed == "Initial summary" + assert diff.summary_added == "Updated summary" + assert severity_moderate in diff.added_severities.all() + assert severity_high in diff.removed_severities.all() + + assert diff.added_impacted_packages.count() == 0 + assert diff.removed_impacted_packages.count() == 3 + assert pkg1 in diff.removed_impacted_packages.all() + assert pkg2 in diff.removed_impacted_packages.all() + assert pkg3 in diff.removed_impacted_packages.all() + assert AdvisoryHistoryDiff.objects.count() == 2 diff --git a/vulnerabilities/utils.py b/vulnerabilities/utils.py index a4fe0274f..7db54460e 100644 --- a/vulnerabilities/utils.py +++ b/vulnerabilities/utils.py @@ -1129,3 +1129,85 @@ def safe_altcha_redirect(next_url: str) -> redirect: return redirect(next_url) return redirect("/") + + +def compute_advisory_v2_diff(old_snapshot, new_snapshot): + """ + Compare two advisory snapshots and compute the diff. + Args: + old_snapshot: The old advisory snapshot. + new_snapshot: The new advisory snapshot. + """ + from vulnerabilities.models import AdvisoryHistoryDiff + + diff = AdvisoryHistoryDiff(advisory_after=new_snapshot, advisory_before=old_snapshot) + + if old_snapshot.summary != new_snapshot.summary: + diff.summary_removed = old_snapshot.summary + diff.summary_added = new_snapshot.summary + + if old_snapshot.url != new_snapshot.url: + diff.url_removed = old_snapshot.url + diff.url_added = new_snapshot.url + + diff.save() + + def _compute_diff_helper(old_queryset, new_queryset, added_manager, removed_manager): + old_ids = set(old_queryset.values_list("pk", flat=True)) + new_ids = set(new_queryset.values_list("pk", flat=True)) + if added_ids := new_ids - old_ids: + added_manager.set(added_ids) + if removed_ids := old_ids - new_ids: + removed_manager.set(removed_ids) + + _compute_diff_helper( + old_snapshot.severities, + new_snapshot.severities, + diff.added_severities, + diff.removed_severities, + ) + _compute_diff_helper( + old_snapshot.references, + new_snapshot.references, + diff.added_references, + diff.removed_references, + ) + _compute_diff_helper( + old_snapshot.aliases, new_snapshot.aliases, diff.added_aliases, diff.removed_aliases + ) + _compute_diff_helper( + old_snapshot.weaknesses, + new_snapshot.weaknesses, + diff.added_weaknesses, + diff.removed_weaknesses, + ) + _compute_diff_helper( + old_snapshot.patches, new_snapshot.patches, diff.added_patches, diff.removed_patches + ) + + def _get_impacted_package(impacted_package) -> tuple: + """ + Return the tuple representation of an impacted package. + For eg: ("pkg:pypi/django", "<1.0", "5.0.1") + """ + return ( + impacted_package.base_purl, + impacted_package.affecting_vers, + impacted_package.fixed_vers, + ) + + old_packages_map = { + _get_impacted_package(pkg): pkg.pk for pkg in old_snapshot.impacted_packages.all() + } + new_packages_map = { + _get_impacted_package(pkg): pkg.pk for pkg in new_snapshot.impacted_packages.all() + } + + old_tuples = set(old_packages_map.keys()) + new_tuples = set(new_packages_map.keys()) + + if added_tuples := new_tuples - old_tuples: + diff.added_impacted_packages.set([new_packages_map[tup] for tup in added_tuples]) + + if removed_tuples := old_tuples - new_tuples: + diff.removed_impacted_packages.set([old_packages_map[tup] for tup in removed_tuples]) diff --git a/vulnerabilities/views.py b/vulnerabilities/views.py index 73eb229b3..256347c5c 100644 --- a/vulnerabilities/views.py +++ b/vulnerabilities/views.py @@ -21,6 +21,7 @@ from django.core.cache import cache from django.core.exceptions import ValidationError from django.core.mail import send_mail +from django.core.paginator import Paginator from django.db.models import Exists from django.db.models import OuterRef from django.db.models import Prefetch @@ -580,6 +581,57 @@ def get_context_data(self, **kwargs): return context +def build_advisory_history(advisory, page_number): + """ + Build advisory history for a given advisory. + """ + advisory_history_qs = models.AdvisoryV2.objects.filter(avid=advisory.avid).order_by( + "-date_collected", "-unique_content_id" + ) + + paginator = Paginator(advisory_history_qs, 10) + pagination_obj = paginator.get_page(page_number) + advisories_on_page = list(pagination_obj.object_list) + + advisory_ids = [advisory.pk for advisory in advisories_on_page] + advisory_to_diff = { + diff.advisory_after_id: diff + for diff in models.AdvisoryHistoryDiff.objects.filter( + advisory_after_id__in=advisory_ids + ).prefetch_related( + "added_severities", + "removed_severities", + "added_impacted_packages", + "removed_impacted_packages", + "added_references", + "removed_references", + "added_patches", + "removed_patches", + "added_weaknesses", + "removed_weaknesses", + "added_aliases", + "removed_aliases", + ) + } + + advisory_history = [] + for current_index, current_advisory in enumerate(advisories_on_page): + is_initial = not pagination_obj.has_next() and current_index == len(advisories_on_page) - 1 + history_diff = advisory_to_diff.get(current_advisory.pk) + + advisory_history.append( + { + "date_collected": current_advisory.date_collected, + "unique_content_id": current_advisory.unique_content_id, + "is_latest": current_advisory.is_latest, + "is_initial": is_initial, + "diff": history_diff, + } + ) + + return advisory_history, pagination_obj + + class AdvisoryDetails(VulnerableCodeDetailView): model = models.AdvisoryV2 template_name = "advisory_detail.html" @@ -647,6 +699,12 @@ def get_queryset(self): "source_advisory__url", ), ), + Prefetch( + "impacted_packages", + queryset=models.ImpactedPackage.objects.only( + "base_purl", "affecting_vers", "fixed_vers" + ), + ), ) ) @@ -738,6 +796,10 @@ def add_ssvc(ssvc): for ssvc in advisory.related_ssvcs.all(): add_ssvc(ssvc) + advisory_history, pagination_obj = build_advisory_history( + advisory, self.request.GET.get("page", 1) + ) + context["ssvcs"] = ssvc_entries context.update( { @@ -750,11 +812,29 @@ def add_ssvc(ssvc): "weaknesses": weaknesses_present_in_db, "status": advisory.get_status_label, "epss_data": epss_data, + "advisory_history": advisory_history, + "page_obj": pagination_obj, + "is_snapshot": False, } ) return context +class AdvisorySnapshotView(AdvisoryDetails): + def get_object(self, queryset=None): + avid = self.kwargs["avid"] + uid = self.kwargs["unique_content_id"] + try: + return self.get_queryset().get(avid=avid, unique_content_id=uid) + except models.AdvisoryV2.DoesNotExist: + raise Http404 + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update({"advisory_history": [], "is_snapshot": True}) + return context + + class HomePage(VulnerableCodeView): template_name = "index.html" @@ -993,6 +1073,45 @@ def get_queryset(self): ) +class AdvisoryPackagesSnapshotView(AdvisoryPackagesDetails): + """ + View to display package details for an advisory snapshot. + """ + + def get_object(self, queryset=None): + avid = self.kwargs["avid"] + uid = self.kwargs["unique_content_id"] + try: + return self.get_queryset().get(avid=avid, unique_content_id=uid) + except models.AdvisoryV2.DoesNotExist: + raise Http404 + + def get_queryset(self): + from vulnerabilities.models import ImpactedPackage + from vulnerabilities.models import PackageV2 + + return AdvisoryV2.objects.all().prefetch_related( + Prefetch( + "impacted_packages", + queryset=ImpactedPackage.objects.all().prefetch_related( + Prefetch( + "affecting_packages", + queryset=PackageV2.objects.only("type", "namespace", "name", "version"), + ), + Prefetch( + "fixed_by_packages", + queryset=PackageV2.objects.only("type", "namespace", "name", "version"), + ), + ), + ) + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update({"is_snapshot": True}) + return context + + class PipelineScheduleListView(VulnerableCodeListView, FormMixin): model = PipelineSchedule context_object_name = "schedule_list" diff --git a/vulnerablecode/static/css/custom.css b/vulnerablecode/static/css/custom.css index 6d8918a8f..b914ccf55 100644 --- a/vulnerablecode/static/css/custom.css +++ b/vulnerablecode/static/css/custom.css @@ -655,3 +655,63 @@ ul.fixed_by_bullet li li li { box-shadow: none; } } + + +/* Diff formatting for advisory history */ + +.diff-badge { + padding: 2px 6px; + border-radius: 4px; + display: inline-block; +} + +.diff-badge-yellow { + color: #856404; + background-color: #fff3cd; +} + +.diff-badge-red { + color: #721c24; + background-color: #f8d7da; +} + +.diff-badge-green { + color: #155724; + background-color: #d4edda; +} + +.diff-wrap { + word-break: break-word; + white-space: pre-wrap; +} + +.diff-max-width { + max-width: 600px; +} + +#tab-content table div { + display: block !important; + margin-bottom: 0 !important; + padding-bottom: 0 !important; +} + +.history-diff-collapsible { + max-height: 120px; + overflow: hidden; + position: relative; +} + +.history-diff-collapsible:not(.is-expanded):not(.no-truncate)::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 40px; + background: linear-gradient(transparent, white); + pointer-events: none; +} + +.history-diff-collapsible.is-expanded { + max-height: none; +} \ No newline at end of file diff --git a/vulnerablecode/urls.py b/vulnerablecode/urls.py index b693bd725..a4b1eb306 100644 --- a/vulnerablecode/urls.py +++ b/vulnerablecode/urls.py @@ -27,7 +27,9 @@ from vulnerabilities.views import AdvisoryPackageCommitPatchDetails from vulnerabilities.views import AdvisoryPackageCurationView from vulnerabilities.views import AdvisoryPackagesDetails +from vulnerabilities.views import AdvisoryPackagesSnapshotView from vulnerabilities.views import AdvisorySeverityCurationView +from vulnerabilities.views import AdvisorySnapshotView from vulnerabilities.views import AdvisoryToDoListView from vulnerabilities.views import AdvisoryWeaknessCurationView from vulnerabilities.views import AffectedByAdvisoriesListView @@ -114,6 +116,11 @@ def __init__(self, *args, **kwargs): PipelineRunDetailView.as_view(), name="run-details", ), + path( + "v2", + HomePageV2.as_view(), + name="home", + ), path( "advisories/packages/", AdvisoryPackagesDetails.as_view(), @@ -124,6 +131,11 @@ def __init__(self, *args, **kwargs): AdvisoryPackageCommitPatchDetails.as_view(), name="advisory_package_commit_details", ), + path( + "advisories/snapshot//", + AdvisorySnapshotView.as_view(), + name="advisory_snapshot", + ), path( "advisories/", AdvisoryDetails.as_view(),