Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions scanpipe/pipelines/analyze_symbols_reachability.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
)
60 changes: 56 additions & 4 deletions scanpipe/pipes/reachability.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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": [],
}
Expand All @@ -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", []):
Expand All @@ -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,
Expand All @@ -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,
)
83 changes: 83 additions & 0 deletions scanpipe/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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])
Loading