diff --git a/vulnerabilities/api_v2.py b/vulnerabilities/api_v2.py index b570570ed..10ffb6d98 100644 --- a/vulnerabilities/api_v2.py +++ b/vulnerabilities/api_v2.py @@ -21,6 +21,7 @@ from rest_framework.response import Response from rest_framework.reverse import reverse +from vulnerabilities.models import CodeFix from vulnerabilities.models import Package from vulnerabilities.models import Vulnerability from vulnerabilities.models import VulnerabilityReference @@ -198,14 +199,25 @@ def get_affected_by_vulnerabilities(self, obj): Return a dictionary with vulnerabilities as keys and their details, including fixed_by_packages. """ result = {} + request = self.context.get("request") for vuln in getattr(obj, "prefetched_affected_vulnerabilities", []): fixed_by_package = vuln.fixed_by_packages.first() purl = None if fixed_by_package: purl = fixed_by_package.package_url + # Get code fixed for a vulnerability + code_fixes = CodeFix.objects.filter( + affected_package_vulnerability__vulnerability=vuln + ).distinct() + code_fix_urls = [ + reverse("codefix-detail", args=[code_fix.id], request=request) + for code_fix in code_fixes + ] + result[vuln.vulnerability_id] = { "vulnerability_id": vuln.vulnerability_id, "fixed_by_packages": purl, + "code_fixes": code_fix_urls, } return result @@ -521,3 +533,76 @@ def lookup(self, request): qs = self.get_queryset().for_purls([purl]).with_is_vulnerable() return Response(PackageV2Serializer(qs, many=True, context={"request": request}).data) + + +class CodeFixSerializer(serializers.ModelSerializer): + """ + Serializer for the CodeFix model. + Provides detailed information about a code fix. + """ + + affected_vulnerability_id = serializers.CharField( + source="affected_package_vulnerability.vulnerability.vulnerability_id", + read_only=True, + help_text="ID of the affected vulnerability.", + ) + affected_package_purl = serializers.CharField( + source="affected_package_vulnerability.package.package_url", + read_only=True, + help_text="PURL of the affected package.", + ) + fixed_package_purl = serializers.CharField( + source="fixed_package_vulnerability.package.package_url", + read_only=True, + help_text="PURL of the fixing package (if available).", + ) + created_at = serializers.DateTimeField( + format="%Y-%m-%dT%H:%M:%SZ", + read_only=True, + help_text="Timestamp when the code fix was created.", + ) + updated_at = serializers.DateTimeField( + format="%Y-%m-%dT%H:%M:%SZ", + read_only=True, + help_text="Timestamp when the code fix was last updated.", + ) + + class Meta: + model = CodeFix + fields = [ + "id", + "commits", + "pulls", + "downloads", + "patch", + "affected_vulnerability_id", + "affected_package_purl", + "fixed_package_purl", + "notes", + "references", + "is_reviewed", + "created_at", + "updated_at", + ] + read_only_fields = ["created_at", "updated_at"] + + +class CodeFixViewSet(viewsets.ReadOnlyModelViewSet): + """ + API endpoint that allows viewing CodeFix entries. + """ + + queryset = CodeFix.objects.all() + serializer_class = CodeFixSerializer + + def get_queryset(self): + """ + Optionally filter by vulnerability ID. + """ + queryset = super().get_queryset() + vulnerability_id = self.request.query_params.get("vulnerability_id") + if vulnerability_id: + queryset = queryset.filter( + affected_package_vulnerability__vulnerability__vulnerability_id=vulnerability_id + ) + return queryset diff --git a/vulnerabilities/improvers/__init__.py b/vulnerabilities/improvers/__init__.py index dd73eb02d..44a65df47 100644 --- a/vulnerabilities/improvers/__init__.py +++ b/vulnerabilities/improvers/__init__.py @@ -10,6 +10,7 @@ from vulnerabilities.improvers import valid_versions from vulnerabilities.improvers import vulnerability_status from vulnerabilities.pipelines import VulnerableCodePipeline +from vulnerabilities.pipelines import collect_commits from vulnerabilities.pipelines import compute_package_risk from vulnerabilities.pipelines import compute_package_version_rank from vulnerabilities.pipelines import enhance_with_exploitdb @@ -41,6 +42,7 @@ enhance_with_exploitdb.ExploitDBImproverPipeline, compute_package_risk.ComputePackageRiskPipeline, compute_package_version_rank.ComputeVersionRankPipeline, + collect_commits.CollectFixCommitsPipeline, ] IMPROVERS_REGISTRY = { diff --git a/vulnerabilities/migrations/0086_codefix.py b/vulnerabilities/migrations/0086_codefix.py new file mode 100644 index 000000000..df67c3ae8 --- /dev/null +++ b/vulnerabilities/migrations/0086_codefix.py @@ -0,0 +1,127 @@ +# Generated by Django 4.2.16 on 2025-01-08 13:28 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("vulnerabilities", "0085_alter_package_is_ghost_alter_package_version_rank_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="CodeFix", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "commits", + models.JSONField( + blank=True, + default=list, + help_text="List of commit identifiers using VCS URLs associated with the code change.", + ), + ), + ( + "pulls", + models.JSONField( + blank=True, + default=list, + help_text="List of pull request URLs associated with the code change.", + ), + ), + ( + "downloads", + models.JSONField( + blank=True, + default=list, + help_text="List of download URLs for the patched code.", + ), + ), + ( + "patch", + models.TextField( + blank=True, + help_text="The code change as a patch in unified diff format.", + null=True, + ), + ), + ( + "notes", + models.TextField( + blank=True, + help_text="Notes or instructions about this code change.", + null=True, + ), + ), + ( + "references", + models.JSONField( + blank=True, + default=list, + help_text="URL references related to this code change.", + ), + ), + ( + "is_reviewed", + models.BooleanField( + default=False, help_text="Indicates if this code change has been reviewed." + ), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, + help_text="Timestamp indicating when this code change was created.", + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, + help_text="Timestamp indicating when this code change was last updated.", + ), + ), + ( + "affected_package_vulnerability", + models.ForeignKey( + help_text="The affected package version to which this code fix applies.", + on_delete=django.db.models.deletion.CASCADE, + related_name="code_fix", + to="vulnerabilities.affectedbypackagerelatedvulnerability", + ), + ), + ( + "base_package_version", + models.ForeignKey( + blank=True, + help_text="The base package version to which this code change applies.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="codechanges", + to="vulnerabilities.package", + ), + ), + ( + "fixed_package_vulnerability", + models.ForeignKey( + blank=True, + help_text="The fixing package version with this code fix", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="code_fix", + to="vulnerabilities.fixingpackagerelatedvulnerability", + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/vulnerabilities/models.py b/vulnerabilities/models.py index 6248e1e47..1a58ec4dc 100644 --- a/vulnerabilities/models.py +++ b/vulnerabilities/models.py @@ -1101,6 +1101,8 @@ class AffectedByPackageRelatedVulnerability(PackageRelatedVulnerabilityBase): related_name="affected_package_vulnerability_relations", ) + objects = BaseQuerySet.as_manager() + class Meta(PackageRelatedVulnerabilityBase.Meta): verbose_name_plural = "Affected By Package Related Vulnerabilities" @@ -1581,3 +1583,81 @@ class Exploit(models.Model): @property def get_known_ransomware_campaign_use_type(self): return "Known" if self.known_ransomware_campaign_use else "Unknown" + + +class CodeChange(models.Model): + """ + Abstract base model representing a change in code, either introducing or fixing a vulnerability. + This includes details about commits, patches, and related metadata. + + We are tracking commits, pulls and downloads as references to the code change. The goal is to + keep track and store the actual code patch in the ``patch`` field. When not available the patch + will be inferred from these references using improvers. + """ + + commits = models.JSONField( + blank=True, + default=list, + help_text="List of commit identifiers using VCS URLs associated with the code change.", + ) + pulls = models.JSONField( + blank=True, + default=list, + help_text="List of pull request URLs associated with the code change.", + ) + downloads = models.JSONField( + blank=True, default=list, help_text="List of download URLs for the patched code." + ) + patch = models.TextField( + blank=True, null=True, help_text="The code change as a patch in unified diff format." + ) + base_package_version = models.ForeignKey( + "Package", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="codechanges", + help_text="The base package version to which this code change applies.", + ) + notes = models.TextField( + blank=True, null=True, help_text="Notes or instructions about this code change." + ) + references = models.JSONField( + blank=True, default=list, help_text="URL references related to this code change." + ) + is_reviewed = models.BooleanField( + default=False, help_text="Indicates if this code change has been reviewed." + ) + created_at = models.DateTimeField( + auto_now_add=True, help_text="Timestamp indicating when this code change was created." + ) + updated_at = models.DateTimeField( + auto_now=True, help_text="Timestamp indicating when this code change was last updated." + ) + + class Meta: + abstract = True + + +class CodeFix(CodeChange): + """ + A code fix is a code change that addresses a vulnerability and is associated: + - with a specific affected package version + - optionally with a specific fixing package version when it is known + """ + + affected_package_vulnerability = models.ForeignKey( + "AffectedByPackageRelatedVulnerability", + on_delete=models.CASCADE, + related_name="code_fix", + help_text="The affected package version to which this code fix applies.", + ) + + fixed_package_vulnerability = models.ForeignKey( + "FixingPackageRelatedVulnerability", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="code_fix", + help_text="The fixing package version with this code fix", + ) diff --git a/vulnerabilities/pipelines/collect_commits.py b/vulnerabilities/pipelines/collect_commits.py new file mode 100644 index 000000000..92145c051 --- /dev/null +++ b/vulnerabilities/pipelines/collect_commits.py @@ -0,0 +1,257 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# VulnerableCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/aboutcode-org/vulnerablecode for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# + +import re + +from aboutcode.pipeline import LoopProgress + +from vulnerabilities.models import AffectedByPackageRelatedVulnerability +from vulnerabilities.models import CodeFix +from vulnerabilities.pipelines import VulnerableCodePipeline + + +def is_vcs_url_already_processed(commit_id): + """ + Check if a VCS URL exists in a CodeFix entry. + """ + return CodeFix.objects.filter(commits__contains=[commit_id]).exists() + + +class CollectFixCommitsPipeline(VulnerableCodePipeline): + """ + Improver pipeline to scout References and create CodeFix entries. + """ + + pipeline_id = "collect_fix_commits" + license_expression = None + + @classmethod + def steps(cls): + return (cls.collect_and_store_fix_commits,) + + def collect_and_store_fix_commits(self): + affected_by_package_related_vulnerabilities = ( + AffectedByPackageRelatedVulnerability.objects.all().prefetch_related( + "vulnerability", "vulnerability__references" + ) + ) + + self.log( + f"Processing {affected_by_package_related_vulnerabilities.count():,d} references to collect fix commits." + ) + + created_fix_count = 0 + progress = LoopProgress( + total_iterations=affected_by_package_related_vulnerabilities.count(), logger=self.log + ) + + for apv in progress.iter( + affected_by_package_related_vulnerabilities.paginated(per_page=500) + ): + vulnerability = apv.vulnerability + for reference in vulnerability.references.all(): + if not "/commit/" in reference.url: + continue + if not is_vcs_url(reference.url): + continue + + vcs_url = normalize_vcs_url(repo_url=reference.url) + + if not vcs_url: + continue + + # Skip if already processed + if is_vcs_url_already_processed(commit_id=vcs_url): + self.log( + f"Skipping already processed reference: {reference.url} with VCS URL {vcs_url}" + ) + continue + # check if vcs_url has commit + code_fix, created = CodeFix.objects.get_or_create( + commits=[vcs_url], + affected_package_vulnerability=apv, + ) + + if created: + created_fix_count += 1 + self.log( + f"Created CodeFix entry for reference: {reference.url} with VCS URL {vcs_url}" + ) + + self.log(f"Successfully created {created_fix_count:,d} CodeFix entries.") + + +PLAIN_URLS = ( + "https://", + "http://", +) + +VCS_URLS = ( + "git://", + "git+git://", + "git+https://", + "git+http://", + "hg://", + "hg+http://", + "hg+https://", + "svn://", + "svn+https://", + "svn+http://", +) + + +# TODO: This function was borrowed from scancode-toolkit. We need to create a shared library for that. +def normalize_vcs_url(repo_url, vcs_tool=None): + """ + Return a normalized vcs_url version control URL given some `repo_url` and an + optional `vcs_tool` hint (such as 'git', 'hg', etc.) + + Return None if repo_url is not recognized as a VCS URL. + + Handles shortcuts for GitHub, GitHub gist, Bitbucket, or GitLab repositories + and more using the same approach as npm install: + + See https://docs.npmjs.com/files/package.json#repository + or https://getcomposer.org/doc/05-repositories.md + + This is done here in npm: + https://github.com/npm/npm/blob/d3c858ce4cfb3aee515bb299eb034fe1b5e44344/node_modules/hosted-git-info/git-host-info.js + + These should be resolved: + npm/npm + gist:11081aaa281 + bitbucket:example/repo + gitlab:another/repo + expressjs/serve-static + git://github.com/angular/di.js.git + git://github.com/hapijs/boom + git@github.com:balderdashy/waterline-criteria.git + http://github.com/ariya/esprima.git + http://github.com/isaacs/nopt + https://github.com/chaijs/chai + https://github.com/christkv/kerberos.git + https://gitlab.com/foo/private.git + git@gitlab.com:foo/private.git + """ + if not repo_url or not isinstance(repo_url, str): + return + + repo_url = repo_url.strip() + if not repo_url: + return + + # TODO: If we match http and https, we may should add more check in + # case if the url is not a repo one. For example, check the domain + # name in the url... + if repo_url.startswith(VCS_URLS + PLAIN_URLS): + return repo_url + + if repo_url.startswith("git@"): + tool, _, right = repo_url.partition("@") + if ":" in repo_url: + host, _, repo = right.partition(":") + else: + # git@github.com/Filirom1/npm2aur.git + host, _, repo = right.partition("/") + + if any(r in host for r in ("bitbucket", "gitlab", "github")): + scheme = "https" + else: + scheme = "git" + + return f"{scheme}://{host}/{repo}" + + # FIXME: where these URL schemes come from?? + if repo_url.startswith(("bitbucket:", "gitlab:", "github:", "gist:")): + repo = repo_url.split(":")[1] + hoster_urls = { + "bitbucket": f"https://bitbucket.org/{repo}", + "github": f"https://github.com/{repo}", + "gitlab": f"https://gitlab.com/{repo}", + "gist": f"https://gist.github.com/{repo}", + } + hoster, _, repo = repo_url.partition(":") + return hoster_urls[hoster] % locals() + + if len(repo_url.split("/")) == 2: + # implicit github, but that's only on NPM? + return f"https://github.com/{repo_url}" + return repo_url + + +def is_vcs_url(repo_url): + """ + Check if a given URL or string matches a valid VCS (Version Control System) URL. + + Supports: + - Standard VCS URL protocols (git, http, https, ssh) + - Shortcut syntax (e.g., github:user/repo, gitlab:group/repo) + - GitHub shortcut (e.g., user/repo) + + Args: + repo_url (str): The repository URL or shortcut to validate. + + Returns: + bool: True if the string is a valid VCS URL, False otherwise. + + Examples: + >>> is_vcs_url("git://github.com/angular/di.js.git") + True + >>> is_vcs_url("github:user/repo") + True + >>> is_vcs_url("user/repo") + True + >>> is_vcs_url("https://github.com/user/repo.git") + True + >>> is_vcs_url("git@github.com:user/repo.git") + True + >>> is_vcs_url("http://github.com/isaacs/nopt") + True + >>> is_vcs_url("https://gitlab.com/foo/private.git") + True + >>> is_vcs_url("git@gitlab.com:foo/private.git") + True + >>> is_vcs_url("bitbucket:example/repo") + True + >>> is_vcs_url("gist:11081aaa281") + True + >>> is_vcs_url("ftp://example.com/not-a-repo") + False + >>> is_vcs_url("random-string") + False + >>> is_vcs_url("https://example.com/not-a-repo") + False + """ + if not repo_url or not isinstance(repo_url, str): + return False + + repo_url = repo_url.strip() + if not repo_url: + return False + + # Define valid VCS domains + vcs_domains = r"(github\.com|gitlab\.com|bitbucket\.org|gist\.github\.com)" + + # 1. Match URLs with standard protocols pointing to VCS domains + if re.match(rf"^(git|ssh|http|https)://{vcs_domains}/[\w\-.]+/[\w\-.]+", repo_url): + return True + + # 2. Match SSH URLs (e.g., git@github.com:user/repo.git) + if re.match(rf"^git@{vcs_domains}:[\w\-.]+/[\w\-.]+(\.git)?$", repo_url): + return True + + # 3. Match shortcut syntax (e.g., github:user/repo) + if re.match(r"^(github|gitlab|bitbucket|gist):[\w\-./]+$", repo_url): + return True + + # 4. Match implicit GitHub shortcut (e.g., user/repo) + if re.match(r"^[\w\-]+/[\w\-]+$", repo_url): + return True + + return False diff --git a/vulnerabilities/tests/test_api_v2.py b/vulnerabilities/tests/test_api_v2.py index af4dc47c8..e3434c6a9 100644 --- a/vulnerabilities/tests/test_api_v2.py +++ b/vulnerabilities/tests/test_api_v2.py @@ -216,7 +216,7 @@ def test_list_packages(self): Should return a list of packages with their details and associated vulnerabilities. """ url = reverse("package-v2-list") - with self.assertNumQueries(31): + with self.assertNumQueries(32): response = self.client.get(url, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIn("results", response.data) @@ -238,7 +238,7 @@ def test_filter_packages_by_purl(self): Test filtering packages by one or more PURLs. """ url = reverse("package-v2-list") - with self.assertNumQueries(19): + with self.assertNumQueries(20): response = self.client.get(url, {"purl": "pkg:pypi/django@3.2"}, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data["results"]["packages"]), 1) @@ -249,7 +249,7 @@ def test_filter_packages_by_affected_vulnerability(self): Test filtering packages by affected_by_vulnerability. """ url = reverse("package-v2-list") - with self.assertNumQueries(19): + with self.assertNumQueries(20): response = self.client.get( url, {"affected_by_vulnerability": "VCID-1234"}, format="json" ) @@ -308,7 +308,11 @@ def test_package_serializer_fields(self): # Verify affected_by_vulnerabilities structure expected_affected_by_vulnerabilities = { - "VCID-1234": {"vulnerability_id": "VCID-1234", "fixed_by_packages": None} + "VCID-1234": { + "code_fixes": [], + "vulnerability_id": "VCID-1234", + "fixed_by_packages": None, + } } self.assertEqual(data["affected_by_vulnerabilities"], expected_affected_by_vulnerabilities) @@ -387,7 +391,13 @@ def test_get_affected_by_vulnerabilities(self): vulnerabilities = serializer.get_affected_by_vulnerabilities(package) self.assertEqual( vulnerabilities, - {"VCID-1234": {"vulnerability_id": "VCID-1234", "fixed_by_packages": None}}, + { + "VCID-1234": { + "code_fixes": [], + "vulnerability_id": "VCID-1234", + "fixed_by_packages": None, + } + }, ) def test_get_fixing_vulnerabilities(self): @@ -591,7 +601,7 @@ def test_lookup_with_valid_purl(self): """ url = reverse("package-v2-lookup") data = {"purl": "pkg:pypi/django@3.2"} - with self.assertNumQueries(12): + with self.assertNumQueries(13): response = self.client.post(url, data, format="json") self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(1, len(response.data)) @@ -603,7 +613,13 @@ def test_lookup_with_valid_purl(self): self.assertEqual(response.data[0]["purl"], "pkg:pypi/django@3.2") self.assertEqual( response.data[0]["affected_by_vulnerabilities"], - {"VCID-1234": {"vulnerability_id": "VCID-1234", "fixed_by_packages": None}}, + { + "VCID-1234": { + "code_fixes": [], + "vulnerability_id": "VCID-1234", + "fixed_by_packages": None, + } + }, ) self.assertEqual(response.data[0]["fixing_vulnerabilities"], []) diff --git a/vulnerabilities/tests/test_collect_commits.py b/vulnerabilities/tests/test_collect_commits.py new file mode 100644 index 000000000..c478244e1 --- /dev/null +++ b/vulnerabilities/tests/test_collect_commits.py @@ -0,0 +1,166 @@ +from django.test import TestCase + +from vulnerabilities.models import AffectedByPackageRelatedVulnerability +from vulnerabilities.models import CodeFix +from vulnerabilities.models import Package +from vulnerabilities.models import Vulnerability +from vulnerabilities.models import VulnerabilityReference +from vulnerabilities.models import VulnerabilityRelatedReference +from vulnerabilities.pipelines.collect_commits import CollectFixCommitsPipeline +from vulnerabilities.pipelines.collect_commits import is_vcs_url +from vulnerabilities.pipelines.collect_commits import is_vcs_url_already_processed +from vulnerabilities.pipelines.collect_commits import normalize_vcs_url + + +class CollectFixCommitsPipelineTests(TestCase): + def setUp(self): + self.vulnerability = Vulnerability.objects.create( + vulnerability_id="VCID-1234", summary="Test vulnerability" + ) + + package = Package.objects.create(type="npm", namespace="abc", name="def", version="1") + + self.affected_by_vuln = AffectedByPackageRelatedVulnerability.objects.create( + package=package, vulnerability=self.vulnerability + ) + + self.reference1 = VulnerabilityReference.objects.create( + url="https://github.com/example/repo/commit/abcd1234" + ) + + self.reference2 = VulnerabilityReference.objects.create( + url="https://gitlab.com/example/repo/commit/efgh5678" + ) + VulnerabilityRelatedReference.objects.create( + vulnerability=self.vulnerability, reference=self.reference2 + ) + VulnerabilityRelatedReference.objects.create( + vulnerability=self.vulnerability, reference=self.reference1 + ) + + def test_is_vcs_url(self): + valid_urls = [ + "git://github.com/angular/di.js.git", + "https://github.com/user/repo.git", + "git@gitlab.com:user/repo.git", + ] + invalid_urls = [ + "ftp://example.com/not-a-repo", + "random-string", + "https://example.com/not-a-repo", + ] + for url in valid_urls: + assert is_vcs_url(url) is True + + for url in invalid_urls: + assert is_vcs_url(url) is False + + def test_normalize_vcs_url(self): + + assert ( + normalize_vcs_url("git@github.com:user/repo.git") == "https://github.com/user/repo.git" + ) + assert normalize_vcs_url("github:user/repo") == "https://github.com/user/repo" + assert normalize_vcs_url( + "https://github.com/user/repo.git" + ), "https://github.com/user/repo.git" + + def test_is_vcs_url_already_processed(self): + CodeFix.objects.create( + commits=["https://github.com/example/repo/commit/abcd1234"], + affected_package_vulnerability=self.affected_by_vuln, + ) + assert ( + is_vcs_url_already_processed("https://github.com/example/repo/commit/abcd1234") is True + ) + assert ( + is_vcs_url_already_processed("https://github.com/example/repo/commit/unknown") is False + ) + + def test_collect_and_store_fix_commits(self): + pipeline = CollectFixCommitsPipeline() + pipeline.collect_and_store_fix_commits() + + assert ( + CodeFix.objects.filter( + commits__contains=["https://github.com/example/repo/commit/abcd1234"] + ).exists() + is True + ) + assert ( + CodeFix.objects.filter( + commits__contains=["https://gitlab.com/example/repo/commit/efgh5678"] + ).exists() + is True + ) + + def test_skip_already_processed_commit(self): + CodeFix.objects.create( + commits=["https://github.com/example/repo/commit/abcd1234"], + affected_package_vulnerability=self.affected_by_vuln, + ) + + pipeline = CollectFixCommitsPipeline() + pipeline.collect_and_store_fix_commits() + + # Ensure duplicate entry was not created + self.assertEqual( + CodeFix.objects.filter( + commits__contains=["https://github.com/example/repo/commit/abcd1234"] + ).count(), + 1, + ) + + +class IsVCSURLTests(TestCase): + def test_valid_vcs_urls(self): + valid_urls = [ + "git://github.com/example/repo.git", + "https://github.com/example/repo.git", + "git@github.com:example/repo.git", + "github:user/repo", + ] + for url in valid_urls: + with self.subTest(url=url): + self.assertTrue(is_vcs_url(url)) + + def test_invalid_vcs_urls(self): + invalid_urls = ["http://example.com", "ftp://example.com/repo", "random-string"] + for url in invalid_urls: + with self.subTest(url=url): + self.assertFalse(is_vcs_url(url)) + + +class NormalizeVCSURLTests(TestCase): + def test_normalize_valid_vcs_urls(self): + self.assertEqual( + normalize_vcs_url("git@github.com:user/repo.git"), "https://github.com/user/repo.git" + ) + self.assertEqual(normalize_vcs_url("github:user/repo"), "https://github.com/user/repo") + self.assertEqual( + normalize_vcs_url("https://github.com/user/repo.git"), + "https://github.com/user/repo.git", + ) + + +class IsVCSURLAlreadyProcessedTests(TestCase): + def setUp(self): + self.vulnerability = Vulnerability.objects.create(vulnerability_id="VCID-5678") + package = Package.objects.create(type="npm", namespace="abc", name="def", version="1") + self.affected_by_vuln = AffectedByPackageRelatedVulnerability.objects.create( + package=package, vulnerability=self.vulnerability + ) + self.code_fix = CodeFix.objects.create( + commits=["https://github.com/example/repo/commit/commit1"], + affected_package_vulnerability=self.affected_by_vuln, + ) + + def test_commit_already_processed(self): + self.assertTrue( + is_vcs_url_already_processed("https://github.com/example/repo/commit/commit1") + ) + + def test_commit_not_processed(self): + self.assertFalse( + is_vcs_url_already_processed("https://github.com/example/repo/commit/commit2") + ) diff --git a/vulnerablecode/urls.py b/vulnerablecode/urls.py index 10f7db13f..54540a66d 100644 --- a/vulnerablecode/urls.py +++ b/vulnerablecode/urls.py @@ -20,6 +20,7 @@ from vulnerabilities.api import CPEViewSet from vulnerabilities.api import PackageViewSet from vulnerabilities.api import VulnerabilityViewSet +from vulnerabilities.api_v2 import CodeFixViewSet from vulnerabilities.api_v2 import PackageV2ViewSet from vulnerabilities.api_v2 import VulnerabilityV2ViewSet from vulnerabilities.views import ApiUserCreateView @@ -48,6 +49,8 @@ def __init__(self, *args, **kwargs): api_v2_router = OptionalSlashRouter() api_v2_router.register("packages", PackageV2ViewSet, basename="package-v2") api_v2_router.register("vulnerabilities", VulnerabilityV2ViewSet, basename="vulnerability-v2") +api_v2_router.register("codefixes", CodeFixViewSet, basename="codefix") + urlpatterns = [ path("api/v2/", include(api_v2_router.urls)),