diff --git a/vulnerabilities/migrations/0103_codecommit_impactedpackage_affecting_commits_and_more.py b/vulnerabilities/migrations/0103_codecommit_impactedpackage_affecting_commits_and_more.py new file mode 100644 index 000000000..4a8037974 --- /dev/null +++ b/vulnerabilities/migrations/0103_codecommit_impactedpackage_affecting_commits_and_more.py @@ -0,0 +1,84 @@ +# Generated by Django 4.2.22 on 2025-10-31 14:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("vulnerabilities", "0102_alter_impactedpackage_affecting_vers_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="CodeCommit", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "commit_hash", + models.CharField( + help_text="Unique commit identifier (e.g., SHA).", max_length=64 + ), + ), + ( + "vcs_url", + models.URLField( + help_text="URL of the repository containing the commit.", max_length=1024 + ), + ), + ( + "commit_rank", + models.IntegerField( + default=0, + help_text="Rank of the commit to support ordering by commit. Rank zero means the rank has not been defined yet", + ), + ), + ( + "commit_author", + models.CharField( + blank=True, help_text="Author of the commit.", max_length=100, null=True + ), + ), + ( + "commit_date", + models.DateTimeField( + blank=True, + help_text="Timestamp indicating when this commit was created.", + null=True, + ), + ), + ( + "commit_message", + models.TextField( + blank=True, help_text="Commit message or description.", null=True + ), + ), + ], + options={ + "unique_together": {("commit_hash", "vcs_url")}, + }, + ), + migrations.AddField( + model_name="impactedpackage", + name="affecting_commits", + field=models.ManyToManyField( + help_text="Commits introducing this impact.", + related_name="affecting_commits_in_impacts", + to="vulnerabilities.codecommit", + ), + ), + migrations.AddField( + model_name="impactedpackage", + name="fixed_by_commits", + field=models.ManyToManyField( + help_text="Commits fixing this impact.", + related_name="fixing_commits_in_impacts", + to="vulnerabilities.codecommit", + ), + ), + ] diff --git a/vulnerabilities/models.py b/vulnerabilities/models.py index e550925da..e1c4ddc6b 100644 --- a/vulnerabilities/models.py +++ b/vulnerabilities/models.py @@ -2957,6 +2957,18 @@ class ImpactedPackage(models.Model): help_text="Packages vulnerable to this impact.", ) + affecting_commits = models.ManyToManyField( + "CodeCommit", + related_name="affecting_commits_in_impacts", + help_text="Commits introducing this impact.", + ) + + fixed_by_commits = models.ManyToManyField( + "CodeCommit", + related_name="fixing_commits_in_impacts", + help_text="Commits fixing this impact.", + ) + created_at = models.DateTimeField( auto_now_add=True, db_index=True, @@ -3373,3 +3385,32 @@ class AdvisoryExploit(models.Model): @property def get_known_ransomware_campaign_use_type(self): return "Known" if self.known_ransomware_campaign_use else "Unknown" + + +class CodeCommit(models.Model): + """ + A CodeCommit Represents a single VCS commit (e.g., Git) related to a ImpactedPackage. + """ + + commit_hash = models.CharField(max_length=64, help_text="Unique commit identifier (e.g., SHA).") + vcs_url = models.URLField( + max_length=1024, help_text="URL of the repository containing the commit." + ) + + commit_rank = models.IntegerField( + default=0, + help_text="Rank of the commit to support ordering by commit. Rank " + "zero means the rank has not been defined yet", + ) + commit_author = models.CharField( + max_length=100, null=True, blank=True, help_text="Author of the commit." + ) + commit_date = models.DateTimeField( + null=True, blank=True, help_text="Timestamp indicating when this commit was created." + ) + commit_message = models.TextField( + null=True, blank=True, help_text="Commit message or description." + ) + + class Meta: + unique_together = ("commit_hash", "vcs_url") diff --git a/vulnerabilities/tests/test_commit_code.py b/vulnerabilities/tests/test_commit_code.py new file mode 100644 index 000000000..a85ca509d --- /dev/null +++ b/vulnerabilities/tests/test_commit_code.py @@ -0,0 +1,58 @@ +from datetime import datetime + +import pytest + +from vulnerabilities.models import AdvisoryV2 +from vulnerabilities.models import CodeCommit +from vulnerabilities.models import ImpactedPackage + + +@pytest.mark.django_db +class TestCodeCommit: + def setup_method(self): + date = datetime.now() + adv = AdvisoryV2.objects.create( + unique_content_id="test_id", + url="https://example.com", + summary="summary", + date_imported=date, + date_collected=date, + advisory_id="test_id", + avid="test_pipeline/test_id", + datasource_id="test_pipeline", + ) + + self.impacted = ImpactedPackage.objects.create( + advisory=adv, + base_purl="pkg:pypi/redis", + ) + + self.code_commit1 = CodeCommit.objects.create( + commit_hash="8c001a11dbcb3eb6d851e18f4cefa080af5fb398", + vcs_url="https://github.com/aboutcode-org/test1/", + commit_author="tester1", + commit_message="test message1", + commit_date=datetime.now(), + ) + + self.code_commit2 = CodeCommit.objects.create( + commit_hash="8c001a1", + vcs_url="https://github.com/aboutcode-org/test1/", + ) + + self.impacted.fixed_by_commits.add(self.code_commit1) + self.impacted.affecting_commits.add(self.code_commit2) + + def test_commits_are_created(self): + commits = CodeCommit.objects.all() + assert commits.count() == 2 + + def test_commit_fields(self): + commit = CodeCommit.objects.get(commit_hash="8c001a11dbcb3eb6d851e18f4cefa080af5fb398") + assert commit.commit_author == "tester1" + assert "test message1" == commit.commit_message + assert commit.commit_date is not None + + def test_impacted_packages_creation(self): + assert ImpactedPackage.objects.count() == 1 + assert self.code_commit1 == self.impacted.fixed_by_commits.first() diff --git a/vulnerabilities/tests/test_data_migrations.py b/vulnerabilities/tests/test_data_migrations.py index 491410fb1..2ed8865a0 100644 --- a/vulnerabilities/tests/test_data_migrations.py +++ b/vulnerabilities/tests/test_data_migrations.py @@ -6,8 +6,10 @@ # See https://github.com/aboutcode-org/vulnerablecode for support or download. # See https://aboutcode.org for more information about nexB OSS projects. # +from datetime import datetime from django.apps import apps +from django.db import IntegrityError from django.db import connection from django.db.migrations.executor import MigrationExecutor from django.test import TestCase @@ -951,3 +953,82 @@ def test_fix_alpine_purl(self): assert package.filter(type="alpine").count() == 0 assert package.filter(type="apk").count() == 1 + + +class TestCodeCommitMigration(TestMigrations): + """ + Tests the migration that introduces the CodeCommit model + and adds new ManyToMany fields to ImpactedPackage ( affecting_commits, fixed_by_commits ). + """ + + app_name = "vulnerabilities" + migrate_from = "0102_alter_impactedpackage_affecting_vers_and_more" + migrate_to = "0103_codecommit_impactedpackage_affecting_commits_and_more" + + def setUpBeforeMigration(self, apps): + """ + Prepare old data before migration — this should be destroyed afterward. + """ + ImpactedPackage = apps.get_model("vulnerabilities", "ImpactedPackage") + AdvisoryV2 = apps.get_model("vulnerabilities", "AdvisoryV2") + + date = datetime.now() + adv = AdvisoryV2.objects.create( + unique_content_id="old_adv", + url="https://old.example.com", + summary="Old advisory", + date_imported=date, + date_collected=date, + advisory_id="old_adv", + avid="test_pipeline/old_adv", + datasource_id="test_pipeline", + ) + ImpactedPackage.objects.create(advisory=adv, base_purl="pkg:pypi/oldpkg") + + def test_unique_constraint_on_commit_hash_and_vcs_url(self): + """Ensure the (commit_hash, vcs_url) uniqueness constraint works.""" + CodeCommit = self.apps.get_model("vulnerabilities", "CodeCommit") + + CodeCommit.objects.create( + commit_hash="abc123", + vcs_url="https://github.com/example/repo.git", + commit_rank="0", + commit_author="tester", + commit_message="message 1", + commit_date=datetime.now(), + ) + + with self.assertRaises(IntegrityError): + CodeCommit.objects.create( + commit_hash="abc123", + vcs_url="https://github.com/example/repo.git", + ) + + def test_m2m_relationships_work(self): + """Ensure that the new M2M relationships can store data.""" + ImpactedPackage = self.apps.get_model("vulnerabilities", "ImpactedPackage") + AdvisoryV2 = self.apps.get_model("vulnerabilities", "AdvisoryV2") + CodeCommit = self.apps.get_model("vulnerabilities", "CodeCommit") + + adv = AdvisoryV2.objects.get( + unique_content_id="old_adv", + advisory_id="old_adv", + avid="test_pipeline/old_adv", + datasource_id="test_pipeline", + ) + + impacted = ImpactedPackage.objects.get(advisory=adv, base_purl="pkg:pypi/oldpkg") + commit1 = CodeCommit.objects.create( + commit_hash="def456", + vcs_url="https://example.com/repo.git", + ) + commit2 = CodeCommit.objects.create( + commit_hash="eef456", + vcs_url="https://example.com/repo.git", + ) + + impacted.affecting_commits.add(commit1) + impacted.fixed_by_commits.add(commit2) + + self.assertIn(commit1, impacted.affecting_commits.all()) + self.assertIn(commit2, impacted.fixed_by_commits.all())