diff --git a/scanpipe/pipelines/analyze_symbols_reachability.py b/scanpipe/pipelines/analyze_symbols_reachability.py index 6084612316..4eaf4116e1 100644 --- a/scanpipe/pipelines/analyze_symbols_reachability.py +++ b/scanpipe/pipelines/analyze_symbols_reachability.py @@ -57,6 +57,7 @@ def steps(cls): cls.collect_patch_symbols, cls.collect_and_match_resources, cls.generate_advisory_reachability_report, + cls.apply_reachability_to_packages_and_dependencies, ) def get_vulnerabilities_patches(self): @@ -96,8 +97,20 @@ def collect_and_match_resources(self): def generate_advisory_reachability_report(self): """Generate a reachability report summarizing status by advisory.""" - reachability.generate_advisory_reachability_report( - project=self.project, - patches=self.patches, - candidate_resources=self.candidate_resources, + self.advisories_reachability_report = ( + reachability.generate_advisory_reachability_report( + project=self.project, + patches=self.patches, + candidate_resources=self.candidate_resources, + ) + ) + + def apply_reachability_to_packages_and_dependencies(self): + """ + Save reachability results by updating DiscoveredPackage and + DiscoveredDependency records with the computed reachability data + in their affected_by_vulnerabilities JSON field. + """ + reachability.apply_reachability_to_packages_and_dependencies( + project=self.project, advisory_report=self.advisories_reachability_report ) diff --git a/scanpipe/pipes/reachability.py b/scanpipe/pipes/reachability.py index 844324bb65..ee0dc5993a 100644 --- a/scanpipe/pipes/reachability.py +++ b/scanpipe/pipes/reachability.py @@ -32,6 +32,8 @@ from typecode import get_type from aboutcode.pipeline import LoopProgress +from scanpipe.models import DiscoveredDependency +from scanpipe.models import DiscoveredPackage from scanpipe.pipes.symbols import TS_QUERIES from scanpipe.pipes.symbols import SymbolExtractor from scanpipe.pipes.symbols import create_sha256_fingerprint @@ -784,7 +786,7 @@ def generate_advisory_reachability_report(project, patches, candidate_resources) ReachabilityStatus.NOT_REACHABLE.value: 1, } - advisory_reachability_report = { + advisories_reachability_report = { "purl": project.purl, "advisories": [], } @@ -799,7 +801,7 @@ def generate_advisory_reachability_report(project, patches, candidate_resources) "details": [], } advisory_map[adv_uid] = adv_data - advisory_reachability_report["advisories"].append(adv_data) + advisories_reachability_report["advisories"].append(adv_data) for resource in candidate_resources: for report in resource.extra_data.get("symbols_reachability", []): @@ -817,7 +819,7 @@ def generate_advisory_reachability_report(project, patches, candidate_resources) "details": [], } advisory_map[adv_uid] = adv_data - advisory_reachability_report["advisories"].append(adv_data) + advisories_reachability_report["advisories"].append(adv_data) tool_details = { "resource_path": resource.path, @@ -839,4 +841,54 @@ def generate_advisory_reachability_report(project, patches, candidate_resources) reachability_output_path = project.get_output_file_path("reachability", "json") with open(reachability_output_path, "w") as f: - json.dump(advisory_reachability_report, f, indent=2) + json.dump(advisories_reachability_report, f, indent=2) + + return advisories_reachability_report + + +def inject_reachability_data(vulns, advisory_map): + """ + Inject reachability data into a list of vulnerabilities. + Returns True if any vulnerability was updated, False otherwise. + """ + updated = False + for vuln in vulns: + adv_uid = vuln.get("advisory_uid") + if adv_uid in advisory_map: + adv_data = advisory_map[adv_uid] + vuln["is_reachable"] = adv_data.get("is_reachable", "unknown") + vuln["reachability_analysis"] = adv_data.get("details", []) + updated = True + + return updated + + +def apply_reachability_to_packages_and_dependencies(project, advisory_report): + """ + Update DiscoveredPackage and DiscoveredDependency records by injecting the + computed reachability data into their affected_by_vulnerabilities JSON field. + """ + advisories = advisory_report.get("advisories", []) + if not advisories: + return + + advisory_map = {adv["advisory_uid"]: adv for adv in advisories} + targets = ( + (project.discoveredpackages.all(), DiscoveredPackage), + (project.discovereddependencies.all(), DiscoveredDependency), + ) + + for queryset, model in targets: + unsaved = [ + item + for item in queryset + if inject_reachability_data( + item.affected_by_vulnerabilities or [], advisory_map + ) + ] + if unsaved: + model.objects.bulk_update( + objs=unsaved, + fields=["affected_by_vulnerabilities"], + batch_size=10, + ) diff --git a/scanpipe/tests/test_api.py b/scanpipe/tests/test_api.py index 3fb58b3f7f..90719208d9 100644 --- a/scanpipe/tests/test_api.py +++ b/scanpipe/tests/test_api.py @@ -56,6 +56,7 @@ from scanpipe.models import WebhookSubscription from scanpipe.pipes.input import copy_input from scanpipe.pipes.output import JSONResultsGenerator +from scanpipe.pipes.reachability import apply_reachability_to_packages_and_dependencies from scanpipe.tests import dependency_data1 from scanpipe.tests import filter_warnings from scanpipe.tests import make_message @@ -1374,3 +1375,85 @@ def test_scanpipe_api_serializer_get_serializer_fields(self): with self.assertRaises(LookupError): get_serializer_fields(None) + + def test_scanpipe_api_project_action_package_with_reachability(self): + self.discovered_package1.affected_by_vulnerabilities = [ + { + "advisory_id": "PYSEC-2026-1", + "advisory_uid": "pypa/scancode/PYSEC-2026-1", + "summary": "summary 1", + "risk_score": 1, + }, + { + "advisory_id": "PYSEC-2026-2", + "advisory_uid": "pypa/scancode/PYSEC-2026-2", + "summary": "summary 2", + "risk_score": 2, + }, + { + "advisory_id": "PYSEC-2026-3", + "advisory_uid": "pypa/scancode/PYSEC-2026-3", + "summary": "summary 3", + "risk_score": 3, + }, + ] + self.discovered_package1.save() + advisory_map = { + "purl": "pkg:pypi/daglib@0.3.2", + "advisories": [ + { + "advisory_uid": "pypa/scancode/PYSEC-2026-1", + "is_reachable": "unknown", + "details": [ + { + "resource_path": "scancode/session.py", + "is_reachable": "unknown", + "vulnerable_symbols": ["SqliteAccountInfo"], + } + ], + }, + { + "advisory_uid": "pypa/scancode/PYSEC-2026-2", + "is_reachable": "yes", + "details": [ + { + "resource_path": "b2sdk/session.py", + "is_reachable": "yes", + "vulnerable_symbols": ["SqliteAccountInfo"], + } + ], + }, + ], + } + + apply_reachability_to_packages_and_dependencies(self.project1, advisory_map) + url = reverse("project-packages", args=[self.project1.uuid]) + response = self.csrf_client.get(url) + + self.assertEqual(status.HTTP_200_OK, response.status_code) + self.assertEqual(1, response.data["count"]) + + pkg_response = response.data["results"][0] + vulns = pkg_response["affected_by_vulnerabilities"] + + self.assertEqual(3, len(vulns)) + + self.assertEqual("pypa/scancode/PYSEC-2026-1", vulns[0]["advisory_uid"]) + self.assertEqual("unknown", vulns[0]["is_reachable"]) + self.assertIn("reachability_analysis", vulns[0]) + self.assertEqual(1, len(vulns[0]["reachability_analysis"])) + self.assertEqual( + "scancode/session.py", vulns[0]["reachability_analysis"][0]["resource_path"] + ) + + self.assertEqual("pypa/scancode/PYSEC-2026-2", vulns[1]["advisory_uid"]) + self.assertEqual("yes", vulns[1]["is_reachable"]) + self.assertIn("reachability_analysis", vulns[1]) + self.assertEqual(1, len(vulns[1]["reachability_analysis"])) + self.assertEqual( + "b2sdk/session.py", vulns[1]["reachability_analysis"][0]["resource_path"] + ) + + self.assertEqual("pypa/scancode/PYSEC-2026-3", vulns[2]["advisory_uid"]) + self.assertNotIn("is_reachable", vulns[2]) + self.assertNotIn("reachability_analysis", vulns[2])