Skip to content

Commit 223b9db

Browse files
committed
Refcator with AdvisoryHistoryDiff model and pipeline
Signed-off-by: Sampurna Pyne <sampurnapyne1710@gmail.com>
1 parent e133b7f commit 223b9db

11 files changed

Lines changed: 483 additions & 236 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.SET_NULL, related_name='history_diff', to='vulnerabilities.advisoryv2')),
29+
('advisory_before', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, 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+
]

‎vulnerabilities/models.py‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3367,6 +3367,138 @@ 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.SET_NULL,
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.SET_NULL, 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+
def to_diff_dict(self) -> dict:
3436+
"""
3437+
Serialize the relational diff back to the dict format consumed by
3438+
format_diff_for_ui and the existing template.
3439+
"""
3440+
diff = {}
3441+
3442+
if self.summary_added is not None or self.summary_removed is not None:
3443+
diff["summary"] = {"old": self.summary_removed, "new": self.summary_added}
3444+
3445+
if self.url_added is not None or self.url_removed is not None:
3446+
diff["url"] = {"old": self.url_removed, "new": self.url_added}
3447+
3448+
def _serialize_history_diff(key, added_qs, removed_qs, serializer):
3449+
added = list(added_qs)
3450+
removed = list(removed_qs)
3451+
if added or removed:
3452+
diff[key] = {
3453+
"added": [serializer(obj) for obj in added],
3454+
"removed": [serializer(obj) for obj in removed],
3455+
}
3456+
3457+
_serialize_history_diff(
3458+
"severities",
3459+
self.added_severities.all(),
3460+
self.removed_severities.all(),
3461+
lambda s: s.to_dict(),
3462+
)
3463+
3464+
_serialize_history_diff(
3465+
"affected_packages",
3466+
self.added_impacted_packages.all(),
3467+
self.removed_impacted_packages.all(),
3468+
lambda p: p.to_dict(),
3469+
)
3470+
3471+
_serialize_history_diff(
3472+
"references",
3473+
self.added_references.all(),
3474+
self.removed_references.all(),
3475+
lambda r: r.to_dict(),
3476+
)
3477+
3478+
_serialize_history_diff(
3479+
"aliases",
3480+
self.added_aliases.all(),
3481+
self.removed_aliases.all(),
3482+
lambda a: a.alias,
3483+
)
3484+
3485+
_serialize_history_diff(
3486+
"weaknesses",
3487+
self.added_weaknesses.all(),
3488+
self.removed_weaknesses.all(),
3489+
lambda w: w.cwe_id,
3490+
)
3491+
3492+
_serialize_history_diff(
3493+
"patches",
3494+
self.added_patches.all(),
3495+
self.removed_patches.all(),
3496+
lambda p: p.to_dict(),
3497+
)
3498+
3499+
return diff
3500+
3501+
33703502
class ImpactedPackage(models.Model):
33713503
"""
33723504
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+
run_once = True
26+
27+
@classmethod
28+
def steps(cls):
29+
return (cls.calculate_history_diffs,)
30+
31+
def calculate_history_diffs(self):
32+
# Skip excluded importers
33+
excluded_ids = [
34+
cls.pipeline_id
35+
for cls in IMPORTERS_REGISTRY.values()
36+
if getattr(cls, "exclude_from_history_diff", False)
37+
and getattr(cls, "pipeline_id", None)
38+
]
39+
qs = (
40+
AdvisoryV2.objects.exclude(pipeline_id__in=excluded_ids)
41+
if excluded_ids
42+
else AdvisoryV2.objects.all()
43+
)
44+
45+
avids = qs.filter(history_diff__isnull=True).values_list("avid", flat=True).distinct()
46+
self.log(f"Computing history diffs for {len(avids)} advisories")
47+
48+
for avid in LoopProgress(
49+
total_iterations=len(avids), logger=self.log, progress_step=10
50+
).iter(avids):
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

0 commit comments

Comments
 (0)