Skip to content

Commit d06a1d2

Browse files
committed
Model changes
Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com>
1 parent f27efc7 commit d06a1d2

File tree

3 files changed

+185
-28
lines changed

3 files changed

+185
-28
lines changed

vulnerabilities/models.py

Lines changed: 38 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1587,12 +1587,16 @@ class CodeChange(models.Model):
15871587
"""
15881588
Abstract base model representing a change in code, either introducing or fixing a vulnerability.
15891589
This includes details about commits, patches, and related metadata.
1590+
1591+
We are tracking commits, pulls and downloads as references to the code change. The goal is to
1592+
keep track and store the actual code patch in the ``patch`` field. When not available the patch
1593+
will be inferred from these references using improvers.
15901594
"""
15911595

15921596
commits = models.JSONField(
15931597
blank=True,
15941598
default=list,
1595-
help_text="List of commit identifiers associated with the code change.",
1599+
help_text="List of commit identifiers using VCS URLs associated with the code change.",
15961600
)
15971601
pulls = models.JSONField(
15981602
blank=True,
@@ -1603,45 +1607,55 @@ class CodeChange(models.Model):
16031607
blank=True, default=list, help_text="List of download URLs for the patched code."
16041608
)
16051609
patch = models.TextField(
1606-
blank=True, null=True, help_text="The code change in patch format (e.g., git diff)."
1607-
)
1608-
notes = models.TextField(
1609-
blank=True, null=True, help_text="Additional notes or instructions about the code change."
1610-
)
1611-
references = models.JSONField(
1612-
blank=True, default=list, help_text="External references related to this code change."
1613-
)
1614-
status_reviewed = models.BooleanField(
1615-
default=False, help_text="Indicates if the code change has been reviewed."
1610+
blank=True, null=True, help_text="The code change as a patch in unified diff format."
16161611
)
1617-
base_version = models.ForeignKey(
1612+
base_package_version = models.ForeignKey(
16181613
"Package",
16191614
null=True,
16201615
blank=True,
16211616
on_delete=models.SET_NULL,
1622-
related_name="base_version_codechanges",
1623-
help_text="The base version of the package to which this code change applies.",
1617+
related_name="codechanges",
1618+
help_text="The base package version to which this code change applies.",
16241619
)
1625-
base_commit = models.CharField(
1626-
max_length=255,
1627-
blank=True,
1628-
null=True,
1629-
help_text="The commit ID representing the state of the code before applying the fix or change.",
1620+
notes = models.TextField(
1621+
blank=True, null=True, help_text="Notes or instructions about this code change."
1622+
)
1623+
references = models.JSONField(
1624+
blank=True, default=list, help_text="URL references related to this code change."
1625+
)
1626+
is_reviewed = models.BooleanField(
1627+
default=False, help_text="Indicates if this code change has been reviewed."
16301628
)
16311629
created_at = models.DateTimeField(
1632-
auto_now_add=True, help_text="Timestamp indicating when the code change was created."
1630+
auto_now_add=True, help_text="Timestamp indicating when this code change was created."
16331631
)
16341632
updated_at = models.DateTimeField(
1635-
auto_now=True, help_text="Timestamp indicating when the code change was last updated."
1633+
auto_now=True, help_text="Timestamp indicating when this code change was last updated."
16361634
)
16371635

16381636
class Meta:
16391637
abstract = True
16401638

16411639

16421640
class CodeFix(CodeChange):
1643-
package_vulnerabilities = models.ManyToManyField(
1641+
"""
1642+
A code fix is a code change that addresses a vulnerability and is associated:
1643+
- with a specific affected package version
1644+
- optionally with a specific fixing package version when it is known
1645+
"""
1646+
1647+
affected_package_vulnerability = models.ForeignKey(
16441648
"AffectedByPackageRelatedVulnerability",
1645-
related_name="code_fixes",
1646-
help_text="The vulnerabilities fixed by this code change.",
1649+
on_delete=models.CASCADE,
1650+
related_name="code_fix",
1651+
help_text="The affected package version to which this code fix applies.",
1652+
)
1653+
1654+
fixed_package_vulnerability = models.ForeignKey(
1655+
"FixingPackageRelatedVulnerability",
1656+
null=True,
1657+
blank=True,
1658+
on_delete=models.SET_NULL,
1659+
related_name="code_fix",
1660+
help_text="The fixing package version with this code fix",
16471661
)

vulnerabilities/pipelines/collect_commits.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,25 @@ def collect_and_store_fix_commits(self):
4444

4545
created_fix_count = 0
4646
progress = LoopProgress(total_iterations=references.count(), logger=self.log)
47+
48+
Reference
49+
AffectedByPackageRelatedVulnerability
50+
# FixingPackageRelatedVulnerability
51+
52+
53+
for apv in AffectedByPackageRelatedVulnerability.objects.all():
54+
vuln = apv.vulnerability
55+
for ref in vuln.references:
56+
4757
for reference in progress.iter(references.paginated(per_page=500)):
4858
for vulnerability in reference.vulnerabilities.all():
49-
vcs_url = normalize_vcs_url(reference.url)
59+
vcs_url = normalize_vcs_url(repo_url=reference.url)
5060

5161
if not vcs_url:
5262
continue
5363

5464
# Skip if already processed
55-
if is_reference_already_processed(reference.url, vcs_url):
65+
if is_reference_already_processed(reference_url=reference.url, commit_id=vcs_url):
5666
self.log(
5767
f"Skipping already processed reference: {reference.url} with VCS URL {vcs_url}"
5868
)
@@ -97,7 +107,8 @@ def create_codefix_entry(self, vulnerability, package, vcs_url, reference):
97107
},
98108
)
99109
if created:
100-
codefix.vulnerabilities.add(vulnerability)
110+
AffectedByPackageRelatedVulnerability.objects.get
111+
codefix.package_vulnerabilities.add(vulnerability)
101112
codefix.save()
102113
return codefix
103114
except Exception as e:
@@ -124,10 +135,13 @@ def create_codefix_entry(self, vulnerability, package, vcs_url, reference):
124135
)
125136

126137

138+
# TODO: This function was borrowed from scancode-toolkit. We need to create a shared library for that.
127139
def normalize_vcs_url(repo_url, vcs_tool=None):
128140
"""
129141
Return a normalized vcs_url version control URL given some `repo_url` and an
130-
optional `vcs_tool` hint (such as 'git', 'hg', etc.
142+
optional `vcs_tool` hint (such as 'git', 'hg', etc.)
143+
144+
Return None if repo_url is not recognized as a VCS URL.
131145
132146
Handles shortcuts for GitHub, GitHub gist, Bitbucket, or GitLab repositories
133147
and more using the same approach as npm install:
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
from unittest.mock import patch
2+
3+
from vulnerabilities.models import CodeFix
4+
from vulnerabilities.pipelines.collect_commits import CollectFixCommitsPipeline
5+
from vulnerabilities.pipelines.collect_commits import is_reference_already_processed
6+
from vulnerabilities.pipelines.collect_commits import normalize_vcs_url
7+
8+
9+
# --- Mocked Dependencies ---
10+
class MockVulnerability:
11+
def __init__(self, id):
12+
self.id = id
13+
14+
15+
class MockReference:
16+
def __init__(self, url, vulnerabilities):
17+
self.url = url
18+
self.vulnerabilities = vulnerabilities
19+
20+
21+
class MockPackage:
22+
def __init__(self, purl):
23+
self.purl = purl
24+
25+
26+
# --- Tests for Utility Functions ---
27+
@patch("vulnerabilities.models.CodeFix.objects.filter")
28+
def test_reference_already_processed_true(mock_filter):
29+
mock_filter.return_value.exists.return_value = True
30+
result = is_reference_already_processed("http://example.com", "commit123")
31+
assert result is True
32+
mock_filter.assert_called_once_with(
33+
references__contains=["http://example.com"], commits__contains=["commit123"]
34+
)
35+
36+
37+
@patch("vulnerabilities.models.CodeFix.objects.filter")
38+
def test_reference_already_processed_false(mock_filter):
39+
mock_filter.return_value.exists.return_value = False
40+
result = is_reference_already_processed("http://example.com", "commit123")
41+
assert result is False
42+
43+
44+
# --- Tests for normalize_vcs_url ---
45+
def test_normalize_plain_url():
46+
url = normalize_vcs_url("https://github.com/user/repo.git")
47+
assert url == "https://github.com/user/repo.git"
48+
49+
50+
def test_normalize_git_ssh_url():
51+
url = normalize_vcs_url("git@github.com:user/repo.git")
52+
assert url == "https://github.com/user/repo.git"
53+
54+
55+
def test_normalize_implicit_github():
56+
url = normalize_vcs_url("user/repo")
57+
assert url == "https://github.com/user/repo"
58+
59+
60+
# --- Tests for CollectFixCommitsPipeline ---
61+
@patch("vulnerabilities.models.VulnerabilityReference.objects.prefetch_related")
62+
@patch("vulnerabilities.pipelines.collect_commits.CollectFixCommitsPipeline.get_or_create_package")
63+
@patch("vulnerabilities.pipelines.collect_commits.is_reference_already_processed")
64+
@patch("vulnerabilities.pipelines.collect_commits.url2purl")
65+
def test_collect_and_store_fix_commits(
66+
mock_url2purl, mock_is_processed, mock_get_package, mock_prefetch
67+
):
68+
mock_vuln = MockVulnerability(id=1)
69+
mock_reference = MockReference(url="http://example.com", vulnerabilities=[mock_vuln])
70+
mock_prefetch.return_value.distinct.return_value.paginated.return_value = [mock_reference]
71+
mock_url2purl.return_value = "pkg:example/package@1.0.0"
72+
mock_is_processed.return_value = False
73+
mock_get_package.return_value = MockPackage(purl="pkg:example/package@1.0.0")
74+
75+
pipeline = CollectFixCommitsPipeline()
76+
pipeline.log = lambda msg: None
77+
pipeline.collect_and_store_fix_commits()
78+
79+
mock_is_processed.assert_called_once_with("http://example.com", "pkg:example/package@1.0.0")
80+
mock_get_package.assert_called_once_with("pkg:example/package@1.0.0")
81+
82+
83+
@patch("vulnerabilities.pipelines.collect_commits.CollectFixCommitsPipeline.get_or_create_package")
84+
def test_get_or_create_package_success(mock_get_or_create):
85+
mock_get_or_create.return_value = (MockPackage(purl="pkg:example/package@1.0.0"), True)
86+
pipeline = CollectFixCommitsPipeline()
87+
package = pipeline.get_or_create_package("pkg:example/package@1.0.0")
88+
assert package.purl == "pkg:example/package@1.0.0"
89+
90+
91+
@patch("vulnerabilities.pipelines.collect_commits.CollectFixCommitsPipeline.get_or_create_package")
92+
def test_get_or_create_package_failure(mock_get_or_create):
93+
mock_get_or_create.side_effect = Exception("Error")
94+
pipeline = CollectFixCommitsPipeline()
95+
logs = []
96+
pipeline.log = lambda msg: logs.append(msg)
97+
result = pipeline.get_or_create_package("pkg:example/package@1.0.0")
98+
assert result is None
99+
assert len(logs) == 1
100+
101+
102+
@patch("vulnerabilities.models.CodeFix.objects.get_or_create")
103+
def test_create_codefix_entry_success(mock_get_or_create):
104+
mock_get_or_create.return_value = (CodeFix(), True)
105+
pipeline = CollectFixCommitsPipeline()
106+
result = pipeline.create_codefix_entry(
107+
MockVulnerability(1),
108+
MockPackage("pkg:example/package@1.0.0"),
109+
"http://example.com",
110+
"http://reference",
111+
)
112+
assert result is not None
113+
mock_get_or_create.assert_called_once()
114+
115+
116+
@patch("vulnerabilities.models.CodeFix.objects.get_or_create")
117+
def test_create_codefix_entry_failure(mock_get_or_create):
118+
mock_get_or_create.side_effect = Exception("Error")
119+
pipeline = CollectFixCommitsPipeline()
120+
logs = []
121+
pipeline.log = lambda msg: logs.append(msg)
122+
result = pipeline.create_codefix_entry(
123+
MockVulnerability(1),
124+
MockPackage("pkg:example/package@1.0.0"),
125+
"http://example.com",
126+
"http://reference",
127+
)
128+
assert result is None
129+
assert len(logs) == 1

0 commit comments

Comments
 (0)