Skip to content

Commit 48e311b

Browse files
committed
Refactor with AdvisoryHistoryDiff model and pipeline
Signed-off-by: Sampurna Pyne <sampurnapyne1710@gmail.com>
1 parent 21c9957 commit 48e311b

12 files changed

Lines changed: 434 additions & 239 deletions

File tree

vulnerabilities/improvers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from vulnerabilities.pipelines.v2_improvers import (
2222
group_advisories_for_packages as group_advisories_for_packages_v2,
2323
)
24+
from vulnerabilities.pipelines.v2_improvers import history_diff_backfill
2425
from vulnerabilities.pipelines.v2_improvers import mark_unfurl_version_range
2526
from vulnerabilities.pipelines.v2_improvers import reference_collect_commits
2627
from vulnerabilities.pipelines.v2_improvers import relate_severities
@@ -42,6 +43,7 @@
4243
reference_collect_commits.CollectReferencesFixCommitsPipeline,
4344
enhance_with_github_poc.GithubPocsImproverPipeline,
4445
mark_unfurl_version_range.MarkUnfurlVersionRangePipeline,
46+
history_diff_backfill.HistoryDiffImproverPipeline,
4547
group_advisories_for_packages_v2.GroupAdvisoriesForPackages,
4648
]
4749
)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Generated by Django 5.2.11 on 2026-07-25 16:54
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('vulnerabilities', '0137_alter_pipelineschedule_run_interval'),
11+
]
12+
13+
operations = [
14+
migrations.CreateModel(
15+
name='AdvisoryHistoryDiff',
16+
fields=[
17+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18+
('summary_added', models.TextField(blank=True, null=True)),
19+
('summary_removed', models.TextField(blank=True, null=True)),
20+
('url_added', models.URLField(blank=True, null=True)),
21+
('url_removed', models.URLField(blank=True, null=True)),
22+
('added_aliases', models.ManyToManyField(blank=True, related_name='added_in_history', to='vulnerabilities.advisoryalias')),
23+
('added_impacted_packages', models.ManyToManyField(blank=True, related_name='added_in_history', to='vulnerabilities.impactedpackage')),
24+
('added_patches', models.ManyToManyField(blank=True, related_name='added_in_history', to='vulnerabilities.patch')),
25+
('added_references', models.ManyToManyField(blank=True, related_name='added_in_history', to='vulnerabilities.advisoryreference')),
26+
('added_severities', models.ManyToManyField(blank=True, related_name='added_in_history', to='vulnerabilities.advisoryseverity')),
27+
('added_weaknesses', models.ManyToManyField(blank=True, related_name='added_in_history', to='vulnerabilities.advisoryweakness')),
28+
('advisory_after', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='history_diff', to='vulnerabilities.advisoryv2')),
29+
('advisory_before', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='history_diff_before', to='vulnerabilities.advisoryv2')),
30+
('removed_aliases', models.ManyToManyField(blank=True, related_name='removed_in_history', to='vulnerabilities.advisoryalias')),
31+
('removed_impacted_packages', models.ManyToManyField(blank=True, related_name='removed_in_history', to='vulnerabilities.impactedpackage')),
32+
('removed_patches', models.ManyToManyField(blank=True, related_name='removed_in_history', to='vulnerabilities.patch')),
33+
('removed_references', models.ManyToManyField(blank=True, related_name='removed_in_history', to='vulnerabilities.advisoryreference')),
34+
('removed_severities', models.ManyToManyField(blank=True, related_name='removed_in_history', to='vulnerabilities.advisoryseverity')),
35+
('removed_weaknesses', models.ManyToManyField(blank=True, related_name='removed_in_history', to='vulnerabilities.advisoryweakness')),
36+
],
37+
),
38+
]
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Generated by Django 5.2.11 on 2026-07-26 00:00
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('vulnerabilities', '0138_advisoryhistorydiff'),
10+
('vulnerabilities', '0141_advisorymitigations'),
11+
]
12+
13+
operations = [
14+
]

vulnerabilities/models.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3367,6 +3367,72 @@ def get_aliases(self):
33673367
alias = get_aliases
33683368

33693369

3370+
class AdvisoryHistoryDiff(models.Model):
3371+
"""
3372+
Stores the pre-computed diff between an AdvisoryV2 snapshot (advisory_after)
3373+
and its immediate predecessor (advisory_before).
3374+
Created for every snapshot.
3375+
"""
3376+
3377+
# Points to the older snapshot in the comparison
3378+
advisory_before = models.OneToOneField(
3379+
AdvisoryV2,
3380+
on_delete=models.PROTECT,
3381+
related_name="history_diff_before",
3382+
null=True, # The very first snapshot won't have a predecessor
3383+
blank=True,
3384+
)
3385+
3386+
# Points to the newer snapshot in the comparison
3387+
advisory_after = models.OneToOneField(
3388+
AdvisoryV2, on_delete=models.PROTECT, related_name="history_diff", null=True, blank=True
3389+
)
3390+
3391+
summary_added = models.TextField(blank=True, null=True)
3392+
summary_removed = models.TextField(blank=True, null=True)
3393+
3394+
url_added = models.URLField(blank=True, null=True)
3395+
url_removed = models.URLField(blank=True, null=True)
3396+
3397+
added_severities = models.ManyToManyField(
3398+
"AdvisorySeverity", related_name="added_in_history", blank=True
3399+
)
3400+
removed_severities = models.ManyToManyField(
3401+
"AdvisorySeverity", related_name="removed_in_history", blank=True
3402+
)
3403+
3404+
added_impacted_packages = models.ManyToManyField(
3405+
"ImpactedPackage", related_name="added_in_history", blank=True
3406+
)
3407+
removed_impacted_packages = models.ManyToManyField(
3408+
"ImpactedPackage", related_name="removed_in_history", blank=True
3409+
)
3410+
3411+
added_references = models.ManyToManyField(
3412+
"AdvisoryReference", related_name="added_in_history", blank=True
3413+
)
3414+
removed_references = models.ManyToManyField(
3415+
"AdvisoryReference", related_name="removed_in_history", blank=True
3416+
)
3417+
3418+
added_aliases = models.ManyToManyField(
3419+
"AdvisoryAlias", related_name="added_in_history", blank=True
3420+
)
3421+
removed_aliases = models.ManyToManyField(
3422+
"AdvisoryAlias", related_name="removed_in_history", blank=True
3423+
)
3424+
3425+
added_weaknesses = models.ManyToManyField(
3426+
"AdvisoryWeakness", related_name="added_in_history", blank=True
3427+
)
3428+
removed_weaknesses = models.ManyToManyField(
3429+
"AdvisoryWeakness", related_name="removed_in_history", blank=True
3430+
)
3431+
3432+
added_patches = models.ManyToManyField("Patch", related_name="added_in_history", blank=True)
3433+
removed_patches = models.ManyToManyField("Patch", related_name="removed_in_history", blank=True)
3434+
3435+
33703436
class ImpactedPackage(models.Model):
33713437
"""
33723438
Represents a single impact for an advisory, including affected range and fixed version and

vulnerabilities/pipelines/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,10 @@ class VulnerableCodeBaseImporterPipelineV2(VulnerableCodePipeline):
284284
# pull requests, commit messages, EPSS, exploits, etc.
285285
exclude_from_package_todo = False
286286

287+
# Set this to True if computing history diff is not fruitful for this source.
288+
# An example of such advisory would be pipeline dedicated to EPSS
289+
exclude_from_history_diff = False
290+
287291
# Control how often progress log is shown (range: 1–100, higher value = less frequent log)
288292
progress_step = 10
289293

@@ -344,6 +348,7 @@ def collect_and_store_advisories(self):
344348
datasource_id=self.datasource_id,
345349
logger=self.log,
346350
precedence=self.precedence,
351+
calculate_history_diff=not self.exclude_from_history_diff,
347352
):
348353
collected_advisory_count += 1
349354
except Exception as e:
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# VulnerableCode is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: Apache-2.0
5+
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
6+
# See https://github.com/aboutcode-org/vulnerablecode for support or download.
7+
# See https://aboutcode.org for more information about nexB OSS projects.
8+
#
9+
10+
from aboutcode.pipeline import LoopProgress
11+
12+
from vulnerabilities.importers import IMPORTERS_REGISTRY
13+
from vulnerabilities.models import AdvisoryHistoryDiff
14+
from vulnerabilities.models import AdvisoryV2
15+
from vulnerabilities.pipelines import VulnerableCodePipeline
16+
from vulnerabilities.utils import compute_advisory_v2_diff
17+
18+
19+
class HistoryDiffImproverPipeline(VulnerableCodePipeline):
20+
"""
21+
Pipeline to compute and store relational history snapshot diffs.
22+
"""
23+
24+
pipeline_id = "history_diff_improver_v2"
25+
26+
@classmethod
27+
def steps(cls):
28+
return (cls.calculate_history_diffs,)
29+
30+
def calculate_history_diffs(self):
31+
# Skip excluded importers
32+
excluded_ids = [
33+
cls.pipeline_id
34+
for cls in IMPORTERS_REGISTRY.values()
35+
if getattr(cls, "exclude_from_history_diff", False)
36+
and getattr(cls, "pipeline_id", None)
37+
]
38+
qs = (
39+
AdvisoryV2.objects.exclude(pipeline_id__in=excluded_ids)
40+
if excluded_ids
41+
else AdvisoryV2.objects.all()
42+
)
43+
44+
avids_qs = qs.filter(history_diff__isnull=True).values_list("avid", flat=True).distinct()
45+
avids_count = avids_qs.count()
46+
self.log(f"Computing history diffs for {avids_count} advisories")
47+
48+
for avid in LoopProgress(
49+
total_iterations=avids_count, logger=self.log, progress_step=10
50+
).iter(avids_qs.iterator()):
51+
snapshots = list(
52+
qs.filter(avid=avid)
53+
.order_by("date_collected", "unique_content_id")
54+
.prefetch_related(
55+
"aliases",
56+
"references",
57+
"weaknesses",
58+
"severities",
59+
"patches",
60+
"impacted_packages",
61+
)
62+
)
63+
64+
previous_snapshot = None
65+
66+
# TODO:
67+
# Bulk diff creation
68+
69+
for snapshot in snapshots:
70+
if not hasattr(snapshot, "history_diff"): # Skip already processed advisories
71+
if previous_snapshot is not None:
72+
compute_advisory_v2_diff(previous_snapshot, snapshot)
73+
else:
74+
# For the very first snapshot, we create an empty diff to mark it as processed
75+
AdvisoryHistoryDiff.objects.create(
76+
advisory_after=snapshot, advisory_before=None
77+
)
78+
79+
previous_snapshot = snapshot
80+
81+
self.log("Successfully finished history diff backfill.")

vulnerabilities/pipes/advisory.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from vulnerabilities.improver import MAX_CONFIDENCE
3434
from vulnerabilities.models import Advisory
3535
from vulnerabilities.models import AdvisoryAlias
36+
from vulnerabilities.models import AdvisoryHistoryDiff
3637
from vulnerabilities.models import AdvisoryReference
3738
from vulnerabilities.models import AdvisorySeverity
3839
from vulnerabilities.models import AdvisoryV2
@@ -49,6 +50,7 @@
4950
from vulnerabilities.models import Weakness
5051
from vulnerabilities.pipes.risk_score import compute_advisory_risk_score
5152
from vulnerabilities.pipes.univers_utils import get_exact_purls_v2
53+
from vulnerabilities.utils import compute_advisory_v2_diff
5254

5355

5456
def get_or_create_aliases(aliases: List) -> QuerySet:
@@ -295,6 +297,7 @@ def insert_advisory_v2(
295297
logger: Callable,
296298
datasource_id: str,
297299
precedence: int = 0,
300+
calculate_history_diff: bool = True,
298301
):
299302
from vulnerabilities.models import ImpactedPackage
300303
from vulnerabilities.models import PackageV2
@@ -338,12 +341,16 @@ def insert_advisory_v2(
338341
if not created:
339342
return advisory_obj
340343

341-
AdvisoryV2.objects.filter(
344+
latest_qs = AdvisoryV2.objects.filter(
342345
avid=f"{datasource_id}/{advisory.advisory_id}",
343346
is_latest=True,
344-
).update(is_latest=False)
347+
)
348+
349+
previous_latest_advisory = latest_qs.first()
350+
latest_qs.update(is_latest=False)
351+
345352
advisory_obj.is_latest = True
346-
advisory_obj.save()
353+
advisory_obj.save(update_fields=["is_latest"])
347354

348355
aliases = get_or_create_advisory_aliases(aliases=advisory.aliases)
349356
references = get_or_create_advisory_references(references=advisory.references)
@@ -416,6 +423,31 @@ def insert_advisory_v2(
416423
)
417424
impact.introduced_by_package_commit_patches.add(*introduced_commit_v2)
418425
impact.fixed_by_package_commit_patches.add(*fixed_commit_v2)
426+
427+
if calculate_history_diff:
428+
if previous_latest_advisory:
429+
previous = AdvisoryV2.objects.prefetch_related(
430+
"aliases",
431+
"references",
432+
"weaknesses",
433+
"severities",
434+
"patches",
435+
"impacted_packages",
436+
).get(id=previous_latest_advisory.id)
437+
438+
current = AdvisoryV2.objects.prefetch_related(
439+
"aliases",
440+
"references",
441+
"weaknesses",
442+
"severities",
443+
"patches",
444+
"impacted_packages",
445+
).get(id=advisory_obj.id)
446+
447+
compute_advisory_v2_diff(previous, current)
448+
else:
449+
AdvisoryHistoryDiff.objects.create(advisory_after=advisory_obj, advisory_before=None)
450+
419451
return advisory_obj
420452

421453

vulnerabilities/templates/advisory_detail.html

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -776,6 +776,9 @@
776776
<span class="tag is-info is-light">Oldest version collected</span>
777777
{% elif entry.diff %}
778778
{% with formatted_diff=entry.diff|format_diff_for_ui %}
779+
{% if formatted_diff %}
780+
<a class="diff-toggle-btn is-block has-text-right has-text-link is-size-7 has-text-weight-semibold mb-1 is-clickable"
781+
onclick="toggleDiff(this)">Show more ▾</a>
779782
<div class="history-diff-collapsible">
780783
{% for field, change in formatted_diff.items %}
781784
<div class="mb-2">
@@ -794,15 +797,13 @@
794797
<div class="mt-1">
795798
{% for item in change.added %}
796799
<div class="mt-1">
797-
<span class="diff-badge diff-badge-green diff-wrap">&plus; Added {% if item.attributes %}{{ item.header }} with
798-
{% for key, value in item.attributes %} {{ key }} <b>{{ value }}</b>{% if not forloop.last %}
800+
<span class="diff-badge diff-badge-green diff-wrap">{% if item.attributes %}{% for key, value in item.attributes %}{{ key }} <b>{{ value }}</b>{% if not forloop.last %}
799801
{% endif %}{% endfor %}{% else %}{{ item }}{% endif %}</span>
800802
</div>
801803
{% endfor %}
802804
{% for item in change.removed %}
803805
<div class="mt-1">
804-
<span class="diff-badge diff-badge-red diff-wrap">&minus; Removed {% if item.attributes %}{{ item.header }} with
805-
{% for key, value in item.attributes %} {{ key }} <b>{{ value }}</b>{% if not forloop.last %}
806+
<span class="diff-badge diff-badge-red diff-wrap">{% if item.attributes %}{% for key, value in item.attributes %}{{ key }} <b>{{ value }}</b>{% if not forloop.last %}
806807
{% endif %}{% endfor %}{% else %}{{ item }}{% endif %}</span>
807808
</div>
808809
{% endfor %}
@@ -811,8 +812,9 @@
811812
</div>
812813
{% endfor %}
813814
</div>
814-
<a class="diff-toggle-btn is-block has-text-right has-text-link is-size-7 has-text-weight-semibold mt-1 is-clickable"
815-
onclick="toggleDiff(this)">Show more ▾</a>
815+
{% else %}
816+
<span class="has-text-grey is-italic is-size-7">No changes detected in this snapshot.</span>
817+
{% endif %}
816818
{% endwith %}
817819
{% endif %}
818820
</td>
@@ -873,7 +875,7 @@
873875

874876
function checkDiffHeights() {
875877
document.querySelectorAll('.history-diff-collapsible').forEach(function (diffContainer) {
876-
const toggleButton = diffContainer.nextElementSibling;
878+
const toggleButton = diffContainer.previousElementSibling;
877879
if (!toggleButton || !toggleButton.classList.contains('diff-toggle-btn')) return;
878880

879881
// Measure true height
@@ -893,7 +895,7 @@
893895
}
894896

895897
function toggleDiff(toggleButton) {
896-
const diffContainer = toggleButton.previousElementSibling;
898+
const diffContainer = toggleButton.nextElementSibling;
897899
diffContainer.classList.toggle('is-expanded');
898900
toggleButton.textContent = diffContainer.classList.contains('is-expanded')
899901
? 'Show less \u25b4'

0 commit comments

Comments
 (0)