Skip to content

Commit bd151c5

Browse files
authored
Merge branch 'main' into add-clean-downloads
2 parents 058494a + c62b7b0 commit bd151c5

10 files changed

Lines changed: 186 additions & 10 deletions

CHANGELOG.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
Release notes
22
=============
33

4+
Version v38.5.0
5+
---------------------
6+
7+
- fix: Make package_url field unique for PackageV2
8+
49
Version v38.4.0
510
---------------------
611

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[metadata]
22
name = vulnerablecode
3-
version = 38.4.0
3+
version = 38.5.0
44
license = Apache-2.0 AND CC-BY-SA-4.0
55

66
# description must be on ONE line https://github.com/pypa/setuptools/issues/1390

vulnerabilities/importer.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,16 @@ class VulnerabilitySeverity:
5757
published_at: Optional[datetime.datetime] = None
5858
url: Optional[str] = None
5959

60+
def __post_init__(self):
61+
if not self.system:
62+
raise ValueError("system is required for VulnerabilitySeverity")
63+
64+
if not isinstance(self.system, ScoringSystem):
65+
raise TypeError(f"system must be a ScoringSystem, got {type(self.system)!r}")
66+
67+
if not isinstance(self.value, str):
68+
self.value = str(self.value)
69+
6070
def to_dict(self):
6171
data = {
6272
"system": self.system.identifier,
@@ -469,6 +479,42 @@ def __post_init__(self):
469479
"an affected version range, introduced commit patches, or fixed commit patches."
470480
)
471481

482+
if self.affected_version_range is not None and not isinstance(
483+
self.affected_version_range, VersionRange
484+
):
485+
raise TypeError(
486+
f"affected_version_range must be VersionRange or None, got {type(self.affected_version_range)!r}"
487+
)
488+
489+
if self.fixed_version_range is not None and not isinstance(
490+
self.fixed_version_range, VersionRange
491+
):
492+
raise TypeError(
493+
f"fixed_version_range must be VersionRange or None, got {type(self.fixed_version_range)!r}"
494+
)
495+
496+
if not isinstance(self.introduced_by_commit_patches, list):
497+
raise TypeError(
498+
f"introduced_by_commit_patches must be a list, got {type(self.introduced_by_commit_patches)!r}"
499+
)
500+
501+
if not isinstance(self.fixed_by_commit_patches, list):
502+
raise TypeError(
503+
f"fixed_by_commit_patches must be a list, got {type(self.fixed_by_commit_patches)!r}"
504+
)
505+
506+
for item in self.introduced_by_commit_patches:
507+
if not isinstance(item, PackageCommitPatchData):
508+
raise TypeError(
509+
f"introduced_by_commit_patches items must be PackageCommitPatchData, got {type(item)!r}"
510+
)
511+
512+
for item in self.fixed_by_commit_patches:
513+
if not isinstance(item, PackageCommitPatchData):
514+
raise TypeError(
515+
f"fixed_by_commit_patches items must be PackageCommitPatchData, got {type(item)!r}"
516+
)
517+
472518
def __lt__(self, other):
473519
if not isinstance(other, AffectedPackageV2):
474520
return NotImplemented
@@ -648,6 +694,7 @@ def to_dict(self):
648694
def from_dict(cls, advisory_data):
649695
date_published = advisory_data["date_published"]
650696
transformed = {
697+
"advisory_id": advisory_data["advisory_id"],
651698
"aliases": advisory_data["aliases"],
652699
"summary": advisory_data["summary"],
653700
"affected_packages": [
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from django.db import migrations
2+
from django.db.models import F, Window
3+
from django.db.models.functions import RowNumber
4+
5+
6+
def remove_duplicate_package_urls(apps, schema_editor):
7+
PackageV2 = apps.get_model("vulnerabilities", "PackageV2")
8+
9+
duplicates = (
10+
PackageV2.objects
11+
.annotate(
12+
rn=Window(
13+
expression=RowNumber(),
14+
partition_by=[F("package_url")],
15+
order_by=F("id").desc(),
16+
)
17+
)
18+
.filter(rn__gt=1)
19+
)
20+
21+
BATCH_SIZE = 1000
22+
ids = list(duplicates.values_list("id", flat=True))
23+
24+
for i in range(0, len(ids), BATCH_SIZE):
25+
PackageV2.objects.filter(id__in=ids[i:i+BATCH_SIZE]).delete()
26+
27+
28+
class Migration(migrations.Migration):
29+
30+
dependencies = [
31+
("vulnerabilities", "0121_advisoryv2_is_latest_alter_advisoryv2_advisory_id_and_more"),
32+
]
33+
34+
operations = [
35+
migrations.RunPython(remove_duplicate_package_urls, migrations.RunPython.noop),
36+
]
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Generated by Django 5.2.11 on 2026-04-15 11:59
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("vulnerabilities", "0122_auto_20260415_1155"),
10+
]
11+
12+
operations = [
13+
migrations.AlterModelOptions(
14+
name="packagev2",
15+
options={
16+
"ordering": [
17+
"type",
18+
"namespace",
19+
"name",
20+
"version_rank",
21+
"version",
22+
"qualifiers",
23+
"subpath",
24+
]
25+
},
26+
),
27+
migrations.AlterField(
28+
model_name="packagev2",
29+
name="package_url",
30+
field=models.CharField(
31+
db_index=True,
32+
help_text="The Package URL for this package.",
33+
max_length=1000,
34+
unique=True,
35+
),
36+
),
37+
migrations.AlterUniqueTogether(
38+
name="packagev2",
39+
unique_together={("type", "namespace", "name", "version", "qualifiers", "subpath")},
40+
),
41+
migrations.AddIndex(
42+
model_name="packagev2",
43+
index=models.Index(
44+
fields=["type", "namespace", "name"], name="vulnerabili_type_ca0efc_idx"
45+
),
46+
),
47+
migrations.AddIndex(
48+
model_name="packagev2",
49+
index=models.Index(
50+
fields=["type", "namespace", "name", "qualifiers", "subpath"],
51+
name="vulnerabili_type_c98c98_idx",
52+
),
53+
),
54+
migrations.AddIndex(
55+
model_name="packagev2",
56+
index=models.Index(
57+
fields=["type", "namespace", "name", "version"], name="vulnerabili_type_1af1cc_idx"
58+
),
59+
),
60+
]

vulnerabilities/models.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3477,7 +3477,8 @@ def from_purl(self, purl: Union[PackageURL, str]):
34773477
"""
34783478
Return a new Package given a ``purl`` PackageURL object or PURL string.
34793479
"""
3480-
return PackageV2.objects.create(**purl_to_dict(purl=purl))
3480+
package, _ = PackageV2.objects.get_or_create(**purl_to_dict(purl=purl))
3481+
return package
34813482

34823483

34833484
class PackageV2(PackageURLMixin):
@@ -3490,6 +3491,7 @@ class PackageV2(PackageURLMixin):
34903491
null=False,
34913492
help_text="The Package URL for this package.",
34923493
db_index=True,
3494+
unique=True,
34933495
)
34943496

34953497
plain_package_url = models.CharField(
@@ -3520,6 +3522,24 @@ class PackageV2(PackageURLMixin):
35203522
db_index=True,
35213523
)
35223524

3525+
class Meta:
3526+
unique_together = ["type", "namespace", "name", "version", "qualifiers", "subpath"]
3527+
ordering = ["type", "namespace", "name", "version_rank", "version", "qualifiers", "subpath"]
3528+
indexes = [
3529+
# Index for getting al versions of a package
3530+
models.Index(fields=["type", "namespace", "name"]),
3531+
models.Index(fields=["type", "namespace", "name", "qualifiers", "subpath"]),
3532+
# Index for getting a specific version of a package
3533+
models.Index(
3534+
fields=[
3535+
"type",
3536+
"namespace",
3537+
"name",
3538+
"version",
3539+
]
3540+
),
3541+
]
3542+
35233543
def __str__(self):
35243544
return self.package_url
35253545

vulnerabilities/tests/pipelines/test_compute_advisory_todo_v2.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from django.test import TestCase
1313
from packageurl import PackageURL
14+
from univers.version_range import VersionRange
1415

1516
from vulnerabilities.importer import AdvisoryDataV2
1617
from vulnerabilities.importer import AffectedPackageV2
@@ -30,8 +31,8 @@ def setUp(self):
3031
affected_packages=[
3132
AffectedPackageV2(
3233
package=PackageURL(type="npm", name="package1"),
33-
affected_version_range="vers:npm/>=1.0.0|<2.0.0",
34-
fixed_version_range="vers:npm/2.0.0",
34+
affected_version_range=VersionRange.from_string("vers:npm/>=1.0.0|<2.0.0"),
35+
fixed_version_range=VersionRange.from_string("vers:npm/2.0.0"),
3536
)
3637
],
3738
references=[ReferenceV2(url="https://example.com/vuln1")],
@@ -44,7 +45,7 @@ def setUp(self):
4445
affected_packages=[
4546
AffectedPackageV2(
4647
package=PackageURL(type="npm", name="package1"),
47-
affected_version_range="vers:npm/>=1.0.0|<2.0.0",
48+
affected_version_range=VersionRange.from_string("vers:npm/>=1.0.0|<2.0.0"),
4849
)
4950
],
5051
references=[ReferenceV2(url="https://example.com/vuln1")],
@@ -57,7 +58,7 @@ def setUp(self):
5758
affected_packages=[
5859
AffectedPackageV2(
5960
package=PackageURL(type="npm", name="package1"),
60-
fixed_version_range="vers:npm/2.0.0",
61+
fixed_version_range=VersionRange.from_string("vers:npm/2.0.0"),
6162
)
6263
],
6364
references=[ReferenceV2(url="https://example.com/vuln1")],
@@ -70,8 +71,8 @@ def setUp(self):
7071
affected_packages=[
7172
AffectedPackageV2(
7273
package=PackageURL(type="npm", name="package1"),
73-
affected_version_range="vers:npm/>=1.0.0|<=2.0.0",
74-
fixed_version_range="vers:npm/2.0.1",
74+
affected_version_range=VersionRange.from_string("vers:npm/>=1.0.0|<=2.0.0"),
75+
fixed_version_range=VersionRange.from_string("vers:npm/2.0.1"),
7576
)
7677
],
7778
references=[ReferenceV2(url="https://example.com/vuln1")],

vulnerabilities/tests/pipelines/v2_importers/test_vulnrichment_importer_v2.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ def test_parse_cve_advisory(mock_pathlib, mock_vcs_response, mock_fetch_via_vcs)
196196
assert advisory.summary == "Sample PyPI vulnerability"
197197
assert advisory.url == advisory_url
198198
assert len(advisory.severities) == 1
199-
assert advisory.severities[0].value == 5.3
199+
assert advisory.severities[0].value == "5.3"
200200

201201

202202
def test_collect_advisories_with_invalid_json(mock_pathlib, mock_vcs_response, mock_fetch_via_vcs):

vulnerabilities/tests/test_utils.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,3 +254,10 @@ def test_content_id_from_adv_data_and_adv_model_are_same(self):
254254
id_from_model = utils.compute_content_id_v2(advisory_model)
255255

256256
self.assertEqual(id_from_data, id_from_model)
257+
258+
def test_content_id_from_adv_data_roundtrip_are_same(self):
259+
id_from_data = utils.compute_content_id_v2(self.advisory1)
260+
adv_roundtrip = AdvisoryDataV2.from_dict(self.advisory1.to_dict())
261+
id_from_roundtrip = utils.compute_content_id_v2(adv_roundtrip)
262+
263+
self.assertEqual(id_from_data, id_from_roundtrip)

vulnerablecode/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
import git
1616

17-
__version__ = "38.4.0"
17+
__version__ = "38.5.0"
1818

1919

2020
PROJECT_DIR = Path(__file__).resolve().parent

0 commit comments

Comments
 (0)