Skip to content

Commit 769f53c

Browse files
committed
Add Advisory History
Signed-off-by: Sampurna Pyne <sampurnapyne1710@gmail.com>
1 parent 4702c06 commit 769f53c

7 files changed

Lines changed: 953 additions & 357 deletions

File tree

vulnerabilities/templates/advisory_detail.html

Lines changed: 527 additions & 357 deletions
Large diffs are not rendered by default.

vulnerabilities/templates/advisory_package_details.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@
1313

1414
{% if advisoryv2 %}
1515
<section class="section pt-4">
16+
{% if is_snapshot %}
17+
<div class="notification is-warning mb-3 has-text-centered" style="max-width: fit-content; margin: 0 auto;">
18+
<i class="fa fa-history mr-1"></i> Historical snapshot collected on <strong>{{ advisoryv2.date_collected|date:"Y-m-d H:i" }} UTC</strong>.
19+
<a href="{% url 'advisory_package_details' avid=advisoryv2.avid %}" class="ml-3"><strong>View current version</strong> <i class="fa fa-arrow-right is-size-7"></i></a>
20+
</div>
21+
{% endif %}
22+
1623
<div class="details-container">
1724
<article class="panel is-info panel-header-only">
1825
<div class="panel-heading py-2 is-size-6">
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
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 django import template
11+
from packageurl import PackageURL
12+
13+
register = template.Library()
14+
15+
16+
@register.filter
17+
def format_diff_for_ui(changes) -> dict:
18+
"""
19+
Example:
20+
{
21+
'affected_packages': {
22+
'added': [{'package': {'type': 'pypi', 'name': 'requests', 'version': '2.25.0'}, 'affected_version_range': '==2.25.0', 'fixed_version_range': '==2.25.1'}],
23+
'removed': [{'package': {'type': 'pypi', 'name': 'requests', 'version': '2.24.0'}, 'affected_version_range': '==2.24.0', 'fixed_version_range': '==2.24.1'}]
24+
}
25+
}
26+
should result in:
27+
{
28+
'Affected Packages': {
29+
'added': [{'header': 'Affected package', 'attributes': [('PURL', 'pkg:pypi/requests@2.25.0'), ('Affected version', '==2.25.0'), ('Fixed Version', '==2.25.1')] }],
30+
'removed': [{'header': 'Affected package', 'attributes': [('PURL', 'pkg:pypi/requests@2.24.0'), ('Affected version', '==2.24.0'), ('Fixed Version', '==2.24.1')] }]
31+
}
32+
}
33+
"""
34+
formatted = {}
35+
36+
for field, change in changes.items():
37+
label = field.replace("_", " ").title()
38+
39+
if "old" in change or "new" in change:
40+
formatted[label] = change
41+
continue
42+
43+
formatted[label] = {"added": [], "removed": []}
44+
45+
for change_type in ["added", "removed"]:
46+
for item in change.get(change_type, []):
47+
if field == "affected_packages":
48+
attributes = []
49+
50+
if package := item.get("package"):
51+
package_string = (
52+
str(PackageURL(**package))
53+
if isinstance(package, dict)
54+
else str(package)
55+
)
56+
attributes.append(("PURL", package_string))
57+
58+
if affected_version_range := item.get("affected_version_range"):
59+
attributes.append(("Affected version", affected_version_range))
60+
61+
if fixed_version_range := item.get("fixed_version_range"):
62+
attributes.append(("Fixed Version", fixed_version_range))
63+
64+
formatted[label][change_type].append(
65+
{"header": "Affected package", "attributes": attributes}
66+
)
67+
68+
elif field == "references":
69+
attributes = []
70+
71+
if reference_url := item.get("url"):
72+
attributes.append(("URL", reference_url))
73+
74+
if reference_id := item.get("reference_id"):
75+
attributes.append(("ID", reference_id))
76+
77+
formatted[label][change_type].append(
78+
{"header": "Reference", "attributes": attributes}
79+
)
80+
81+
elif field == "severities":
82+
attributes = []
83+
84+
if scoring_system := item.get("system") or item.get("scoring_system"):
85+
scoring_system_string = str(scoring_system)
86+
if "cvss" in scoring_system_string.lower():
87+
scoring_system_string = scoring_system_string.upper()
88+
else:
89+
scoring_system_string = scoring_system_string.replace("_", " ").title()
90+
attributes.append(("System", scoring_system_string))
91+
92+
if severity_value := item.get("value"):
93+
attributes.append(("Value", severity_value))
94+
95+
if scoring_elements := item.get("scoring_elements"):
96+
attributes.append(("Elements", scoring_elements))
97+
98+
formatted[label][change_type].append(
99+
{"header": "Severity", "attributes": attributes}
100+
)
101+
102+
elif field == "patches":
103+
attributes = []
104+
105+
if patch_url := (item.get("url") or item.get("repository")):
106+
attributes.append(("URL", patch_url))
107+
108+
if patch_commit := item.get("commit"):
109+
attributes.append(("Commit", patch_commit))
110+
111+
formatted[label][change_type].append(
112+
{"header": "Patch", "attributes": attributes}
113+
)
114+
115+
else:
116+
formatted[label][change_type].append(item)
117+
118+
return formatted

vulnerabilities/utils.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,36 @@ def compute_content_id(advisory_data):
691691
return content_id
692692

693693

694+
def get_normalized_advisory_v2(advisory_data):
695+
"""
696+
Return a normalized dictionary of advisory data for diffing history.
697+
698+
:param advisory_data: An AdvisoryV2 object
699+
:return: A normalized dictionary of advisory data
700+
"""
701+
from vulnerabilities.models import AdvisoryV2
702+
703+
if not isinstance(advisory_data, AdvisoryV2):
704+
raise ValueError("Unsupported advisory data type for history diffing")
705+
706+
advisory_data = advisory_data.to_advisory_data()
707+
708+
normalized_data = {
709+
"advisory_id": advisory_data.advisory_id,
710+
"aliases": advisory_data.aliases,
711+
"summary": advisory_data.summary,
712+
"affected_packages": [pkg.to_dict() for pkg in advisory_data.affected_packages if pkg],
713+
"references": [ref.to_dict() for ref in advisory_data.references if ref],
714+
"severities": [sev.to_dict() for sev in advisory_data.severities if sev],
715+
"weaknesses": advisory_data.weaknesses,
716+
"patches": [patch.to_dict() for patch in advisory_data.patches],
717+
"url": advisory_data.url,
718+
# "date_published": str(advisory_data.date_published) if advisory_data.date_published else None,
719+
}
720+
721+
return normalized_data
722+
723+
694724
def compute_content_id_v2(advisory_data):
695725
"""
696726
Compute a unique content_id for an advisory by normalizing its data and hashing it.
@@ -1129,3 +1159,72 @@ def safe_altcha_redirect(next_url: str) -> redirect:
11291159
return redirect(next_url)
11301160

11311161
return redirect("/")
1162+
1163+
1164+
def exclude_epss_severities(severities: list) -> list:
1165+
"""
1166+
Return a copy of the severities list with all EPSS entries removed.
1167+
EPSS scores are updated daily and can be tracked separately in EPSS tab.
1168+
"""
1169+
return [
1170+
severity
1171+
for severity in (severities or [])
1172+
if str(severity.get("system", "")).lower() != "epss"
1173+
]
1174+
1175+
1176+
def diff_advisories_v2(old_data: dict, new_data: dict) -> dict:
1177+
"""
1178+
Diff two normalised advisories and return the changes.
1179+
"""
1180+
changes = {}
1181+
all_keys = set(old_data.keys()).union(new_data.keys())
1182+
1183+
for field in all_keys:
1184+
old_field_value = old_data.get(field)
1185+
new_field_value = new_data.get(field)
1186+
1187+
if isinstance(old_field_value, list) or isinstance(new_field_value, list):
1188+
old_items_list = old_field_value or []
1189+
new_items_list = new_field_value or []
1190+
1191+
if field == "severities":
1192+
old_items_list = exclude_epss_severities(old_items_list)
1193+
new_items_list = exclude_epss_severities(new_items_list)
1194+
1195+
if all(isinstance(item, str) for item in old_items_list + new_items_list):
1196+
# "aliases", "weaknesses"
1197+
old_items_set = set(old_items_list)
1198+
new_items_set = set(new_items_list)
1199+
if old_items_set != new_items_set:
1200+
changes[field] = {
1201+
"added": sorted(new_items_set - old_items_set),
1202+
"removed": sorted(old_items_set - new_items_set),
1203+
}
1204+
elif all(isinstance(item, dict) for item in old_items_list + new_items_list):
1205+
# "references", "affected_packages", "patches", "severities"
1206+
old_items_set = {
1207+
json.dumps(item, sort_keys=True, default=str) for item in old_items_list
1208+
}
1209+
new_items_set = {
1210+
json.dumps(item, sort_keys=True, default=str) for item in new_items_list
1211+
}
1212+
if old_items_set != new_items_set:
1213+
changes[field] = {
1214+
"added": [
1215+
json.loads(item) for item in sorted(new_items_set - old_items_set)
1216+
],
1217+
"removed": [
1218+
json.loads(item) for item in sorted(old_items_set - new_items_set)
1219+
],
1220+
}
1221+
elif (
1222+
isinstance(old_field_value, str)
1223+
or isinstance(new_field_value, str)
1224+
or (old_field_value is None and new_field_value is None)
1225+
):
1226+
# "url", "date_published", "summary"
1227+
if old_field_value != new_field_value:
1228+
changes[field] = {"old": old_field_value, "new": new_field_value}
1229+
1230+
return changes

0 commit comments

Comments
 (0)