From bc74f2af64443d2886954361b31780f4c70ac1e9 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 24 Aug 2026 11:31:53 +0200 Subject: [PATCH 1/7] Import reachability from the scan when available Signed-off-by: tdruez --- product_portfolio/importers.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/product_portfolio/importers.py b/product_portfolio/importers.py index edab66a9..9eba3e16 100644 --- a/product_portfolio/importers.py +++ b/product_portfolio/importers.py @@ -810,9 +810,11 @@ def import_vulnerability(vulnerability_data, product_package): if not vulnerabilities: return + vulnerability = vulnerabilities[0] + if cdx_vulnerability := vulnerability_data.get("cdx_vulnerability_data"): if analysis_data := cdx_vulnerability.get("analysis"): - # CycloneDX model uses "response" while the local model uses "response" + # CycloneDX model uses "response" while the local model uses "responses" if response_value := analysis_data.pop("response", None): analysis_data["responses"] = response_value @@ -820,11 +822,26 @@ def import_vulnerability(vulnerability_data, product_package): user=product_package.dataspace, data={ "product_package": product_package, - "vulnerability": vulnerabilities[0], + "vulnerability": vulnerability, **analysis_data, }, ) + # Import reachability from the "symbol reachability analysis" scan when available. + is_reachable_raw = vulnerability_data.get("is_reachable") + is_reachable = None + if is_reachable_raw == "yes": + is_reachable = True + elif is_reachable_raw == "no": + is_reachable = False + + if is_reachable is not None: + VulnerabilityAnalysis.objects.filter( + product_package=product_package, + vulnerability=vulnerability, + is_reachable__isnull=True, + ).update(is_reachable=is_reachable) + def import_package(self, package_data): # Vulnerabilities are assigned after the package creation. affected_by_vulnerabilities = package_data.pop("affected_by_vulnerabilities", []) From ebf9ca10c76ca90ccbe1944b4ea872284f9cad63 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 24 Aug 2026 11:37:31 +0200 Subject: [PATCH 2/7] update the docsstrings Signed-off-by: tdruez --- product_portfolio/importers.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/product_portfolio/importers.py b/product_portfolio/importers.py index 9eba3e16..50e9de7a 100644 --- a/product_portfolio/importers.py +++ b/product_portfolio/importers.py @@ -713,8 +713,11 @@ def update_scancode_project(self): class ImportPackageFromScanCodeIO: """ - Creates, and assign to a product, packages in Dejacode from a ScanCode.io project - discovered packages. + Import packages discovered by a ScanCode.io project and assign them to a product. + + For each package, associated vulnerabilities are imported and linked, including + reachability data when available. + Dependencies can optionally be imported as well. """ unique_together_fields = [ From c70ac818760eabf709721dd9c245758d627215dc Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 24 Aug 2026 11:53:26 +0200 Subject: [PATCH 3/7] rework the implementation Signed-off-by: tdruez --- product_portfolio/importers.py | 11 ++++++++--- vulnerabilities/models.py | 3 +++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/product_portfolio/importers.py b/product_portfolio/importers.py index 50e9de7a..d8966f56 100644 --- a/product_portfolio/importers.py +++ b/product_portfolio/importers.py @@ -839,11 +839,16 @@ def import_vulnerability(vulnerability_data, product_package): is_reachable = False if is_reachable is not None: - VulnerabilityAnalysis.objects.filter( + analysis, created = VulnerabilityAnalysis.objects.get_or_create( product_package=product_package, vulnerability=vulnerability, - is_reachable__isnull=True, - ).update(is_reachable=is_reachable) + dataspace=product_package.dataspace, + defaults={"is_reachable": is_reachable}, + ) + if not created and analysis.is_reachable is None: + VulnerabilityAnalysis.objects.filter(pk=analysis.pk).update( + is_reachable=is_reachable + ) def import_package(self, package_data): # Vulnerabilities are assigned after the package creation. diff --git a/vulnerabilities/models.py b/vulnerabilities/models.py index 5cb5ca46..8e6e2795 100644 --- a/vulnerabilities/models.py +++ b/vulnerabilities/models.py @@ -584,6 +584,9 @@ class Meta: def __str__(self): return f"{self.vulnerability} analysis" + def has_content_fields(self): + return super().has_content_fields() or self.is_reachable is not None + def save(self, *args, **kwargs): """Set the product and package fields values from the product_package FK.""" self.product_id = self.product_package.product_id From 70d15007c22c538db6399eeea81edff24aaa0099 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 24 Aug 2026 11:54:42 +0200 Subject: [PATCH 4/7] add unit tests Signed-off-by: tdruez --- product_portfolio/tests/test_importers.py | 143 ++++++++++++++++++++ vulnerabilities/triage/tests/test_models.py | 6 +- 2 files changed, 146 insertions(+), 3 deletions(-) diff --git a/product_portfolio/tests/test_importers.py b/product_portfolio/tests/test_importers.py index 39688ad3..de6ce8fa 100644 --- a/product_portfolio/tests/test_importers.py +++ b/product_portfolio/tests/test_importers.py @@ -40,6 +40,7 @@ from product_portfolio.models import ProductPackage from product_portfolio.models import ProductRelationStatus from product_portfolio.models import ScanCodeProject +from vulnerabilities.models import VulnerabilityAnalysis class ProductRelationImporterTestCase(TestCase): @@ -1414,3 +1415,145 @@ def test_product_portfolio_import_packages_from_scio_importer_vex( self.assertEqual("code_not_present", analysis.justification) self.assertEqual("AAAA", analysis.detail) self.assertEqual(["can_not_fix", "update"], analysis.responses) + + @mock.patch("dejacode_toolkit.scancodeio.ScanCodeIO.fetch_project_dependencies") + @mock.patch("dejacode_toolkit.scancodeio.ScanCodeIO.fetch_project_packages") + def test_product_portfolio_import_packages_from_scio_importer_is_reachable( + self, mock_fetch_packages, mock_fetch_dependencies + ): + def make_vulnerability_entry(advisory_id, is_reachable): + return { + "advisory_uid": f"github_osv/{advisory_id}", + "summary": "A vulnerability", + "is_reachable": is_reachable, + "cdx_vulnerability_data": { + "analysis": {"state": "in_triage", "detail": "Under review"}, + }, + } + + mock_fetch_packages.return_value = [ + { + "purl": "pkg:maven/abc/abc@1.0", + "type": "maven", + "namespace": "abc", + "name": "abc", + "version": "1.0", + "affected_by_vulnerabilities": [ + make_vulnerability_entry("GHSA-yes", "yes"), + make_vulnerability_entry("GHSA-no", "no"), + make_vulnerability_entry("GHSA-unknown", "unknown"), + ], + } + ] + mock_fetch_dependencies.return_value = [] + + importer = ImportPackageFromScanCodeIO( + user=self.super_user, + project_uuid=uuid.uuid4(), + product=self.product1, + ) + importer.save() + + yes_analysis = VulnerabilityAnalysis.objects.get( + vulnerability__advisory_uid="github_osv/GHSA-yes" + ) + no_analysis = VulnerabilityAnalysis.objects.get( + vulnerability__advisory_uid="github_osv/GHSA-no" + ) + unknown_analysis = VulnerabilityAnalysis.objects.get( + vulnerability__advisory_uid="github_osv/GHSA-unknown" + ) + + self.assertTrue(yes_analysis.is_reachable) + self.assertFalse(no_analysis.is_reachable) + self.assertIsNone(unknown_analysis.is_reachable) + + @mock.patch("dejacode_toolkit.scancodeio.ScanCodeIO.fetch_project_dependencies") + @mock.patch("dejacode_toolkit.scancodeio.ScanCodeIO.fetch_project_packages") + def test_product_portfolio_import_packages_from_scio_importer_is_reachable_not_overwritten( + self, mock_fetch_packages, mock_fetch_dependencies + ): + mock_fetch_packages.return_value = [ + { + "purl": "pkg:maven/abc/abc@1.0", + "type": "maven", + "namespace": "abc", + "name": "abc", + "version": "1.0", + "affected_by_vulnerabilities": [ + { + "advisory_uid": "github_osv/GHSA-existing", + "summary": "A vulnerability", + "is_reachable": "no", + "cdx_vulnerability_data": { + "analysis": {"state": "in_triage", "detail": "Under review"}, + }, + } + ], + } + ] + mock_fetch_dependencies.return_value = [] + + importer = ImportPackageFromScanCodeIO( + user=self.super_user, + project_uuid=uuid.uuid4(), + product=self.product1, + ) + importer.save() + + analysis = VulnerabilityAnalysis.objects.get( + vulnerability__advisory_uid="github_osv/GHSA-existing" + ) + self.assertFalse(analysis.is_reachable) + + # A second import with a conflicting value must not overwrite the existing one. + mock_fetch_packages.return_value[0]["affected_by_vulnerabilities"][0]["is_reachable"] = ( + "yes" + ) + importer2 = ImportPackageFromScanCodeIO( + user=self.super_user, + project_uuid=uuid.uuid4(), + product=self.product1, + ) + importer2.save() + + analysis.refresh_from_db() + self.assertFalse(analysis.is_reachable) + + @mock.patch("dejacode_toolkit.scancodeio.ScanCodeIO.fetch_project_dependencies") + @mock.patch("dejacode_toolkit.scancodeio.ScanCodeIO.fetch_project_packages") + def test_product_portfolio_import_packages_from_scio_importer_is_reachable_without_cdx( + self, mock_fetch_packages, mock_fetch_dependencies + ): + # When cdx_vulnerability_data is absent, a minimal VulnerabilityAnalysis is still + # created to record the is_reachable value from the scan. + mock_fetch_packages.return_value = [ + { + "purl": "pkg:maven/abc/abc@1.0", + "type": "maven", + "namespace": "abc", + "name": "abc", + "version": "1.0", + "affected_by_vulnerabilities": [ + { + "advisory_uid": "github_osv/GHSA-no-cdx", + "summary": "A vulnerability", + "is_reachable": "yes", + } + ], + } + ] + mock_fetch_dependencies.return_value = [] + + importer = ImportPackageFromScanCodeIO( + user=self.super_user, + project_uuid=uuid.uuid4(), + product=self.product1, + ) + importer.save() + + analysis = VulnerabilityAnalysis.objects.get( + vulnerability__advisory_uid="github_osv/GHSA-no-cdx" + ) + self.assertTrue(analysis.is_reachable) + self.assertIsNone(analysis.state) diff --git a/vulnerabilities/triage/tests/test_models.py b/vulnerabilities/triage/tests/test_models.py index fafeb392..992513e3 100644 --- a/vulnerabilities/triage/tests/test_models.py +++ b/vulnerabilities/triage/tests/test_models.py @@ -29,9 +29,9 @@ def setUp(self): self.dataspace = Dataspace.objects.create(name="nexB") def test_save_requires_at_least_one_content_field(self): - # AnalysisPreset shares its `save` validation with VulnerabilityAnalysis through - # VulnerabilityAnalysisContentMixin: a preset that only sets `is_reachable` has no - # content to apply and must be rejected the same way a bare analysis would be. + # A preset that only sets is_reachable has no content to apply to an analysis + # and must be rejected. Unlike VulnerabilityAnalysis, is_reachable alone is not + # sufficient for AnalysisPreset because the preset's purpose is to carry content. preset = AnalysisPreset(dataspace=self.dataspace, name="No content", is_reachable=True) with self.assertRaises(ValueError): preset.save() From d91b5c5f9a2ac8f4a36b387e161bc64a6707a0e6 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 24 Aug 2026 12:55:37 +0200 Subject: [PATCH 5/7] refine the analysis fields requriements Signed-off-by: tdruez --- product_portfolio/views.py | 4 --- vulnerabilities/models.py | 31 ++++++--------------- vulnerabilities/tests/test_models.py | 7 +---- vulnerabilities/triage/engine.py | 2 -- vulnerabilities/triage/models.py | 7 +++++ vulnerabilities/triage/tests/test_models.py | 4 +-- 6 files changed, 18 insertions(+), 37 deletions(-) diff --git a/product_portfolio/views.py b/product_portfolio/views.py index f69e7720..46eb0901 100644 --- a/product_portfolio/views.py +++ b/product_portfolio/views.py @@ -2989,10 +2989,6 @@ def apply_analysis_preset_view(request, productpackage_uuid, advisory_uid, prese dataspace=dataspace, ) preset.apply_to_analysis(analysis) - - if not analysis.has_content_fields(): - return JsonResponse({"error": "This preset has no content fields to apply."}, status=400) - analysis.applied_by_preset = preset analysis.save() diff --git a/vulnerabilities/models.py b/vulnerabilities/models.py index 8e6e2795..a0496a63 100644 --- a/vulnerabilities/models.py +++ b/vulnerabilities/models.py @@ -353,18 +353,6 @@ class Response(models.TextChoices): ), ) - def has_content_fields(self): - return any([self.state, self.justification, self.responses, self.detail]) - - def save(self, *args, **kwargs): - # At least one of those fields must be provided. - if not self.has_content_fields(): - raise ValueError( - "At least one of state, justification, responses or detail must be provided." - ) - - super().save(*args, **kwargs) - class Meta: abstract = True @@ -385,13 +373,15 @@ class Meta: abstract = True def as_cyclonedx(self): - state = None - if self.state: - state = cdx_vulnerability.ImpactAnalysisState(self.state) - - justification = None - if self.justification: - justification = cdx_vulnerability.ImpactAnalysisJustification(self.justification) + if not any([self.state, self.justification, self.responses, self.detail]): + return None + + state = cdx_vulnerability.ImpactAnalysisState(self.state) if self.state else None + justification = ( + cdx_vulnerability.ImpactAnalysisJustification(self.justification) + if self.justification + else None + ) return cdx_vulnerability.VulnerabilityAnalysis( state=state, @@ -584,9 +574,6 @@ class Meta: def __str__(self): return f"{self.vulnerability} analysis" - def has_content_fields(self): - return super().has_content_fields() or self.is_reachable is not None - def save(self, *args, **kwargs): """Set the product and package fields values from the product_package FK.""" self.product_id = self.product_package.product_id diff --git a/vulnerabilities/tests/test_models.py b/vulnerabilities/tests/test_models.py index 4ddf59c3..2f96d012 100644 --- a/vulnerabilities/tests/test_models.py +++ b/vulnerabilities/tests/test_models.py @@ -327,13 +327,8 @@ def test_vulnerability_model_vulnerability_analysis_save(self): product_package=product_package1, vulnerability=vulnerability1, dataspace=self.dataspace, + state=VulnerabilityAnalysis.State.RESOLVED, ) - - msg = "At least one of state, justification, responses or detail must be provided." - with self.assertRaisesMessage(ValueError, msg): - analysis.save() - - analysis.state = VulnerabilityAnalysis.State.RESOLVED analysis.save() # Refresh from db diff --git a/vulnerabilities/triage/engine.py b/vulnerabilities/triage/engine.py index 3cfe3269..aea7a12d 100644 --- a/vulnerabilities/triage/engine.py +++ b/vulnerabilities/triage/engine.py @@ -106,8 +106,6 @@ def apply_preset_for_vulnerabilities(preset, product, vulnerability_ids): dataspace_id=product.dataspace_id, ) preset.apply_to_analysis(analysis) - if not analysis.has_content_fields(): - continue # Preset has no content fields - cannot save a new analysis else: analysis = existing preset.apply_to_analysis(analysis) diff --git a/vulnerabilities/triage/models.py b/vulnerabilities/triage/models.py index b61974dd..cdd78194 100644 --- a/vulnerabilities/triage/models.py +++ b/vulnerabilities/triage/models.py @@ -54,6 +54,13 @@ class Meta: def __str__(self): return self.name + def save(self, *args, **kwargs): + if not any([self.state, self.justification, self.responses, self.detail]): + raise ValueError( + "At least one of state, justification, responses or detail must be provided." + ) + super().save(*args, **kwargs) + def apply_to_analysis(self, analysis): """Copy non-blank preset fields onto the analysis instance (does not save).""" for field_name in ("state", "justification", "responses", "detail"): diff --git a/vulnerabilities/triage/tests/test_models.py b/vulnerabilities/triage/tests/test_models.py index 992513e3..5bf86543 100644 --- a/vulnerabilities/triage/tests/test_models.py +++ b/vulnerabilities/triage/tests/test_models.py @@ -29,9 +29,7 @@ def setUp(self): self.dataspace = Dataspace.objects.create(name="nexB") def test_save_requires_at_least_one_content_field(self): - # A preset that only sets is_reachable has no content to apply to an analysis - # and must be rejected. Unlike VulnerabilityAnalysis, is_reachable alone is not - # sufficient for AnalysisPreset because the preset's purpose is to carry content. + # A preset that carries no content fields is useless: it has nothing to apply. preset = AnalysisPreset(dataspace=self.dataspace, name="No content", is_reachable=True) with self.assertRaises(ValueError): preset.save() From 3b10f4af79f0b7179c49ae936628d88e694e9c4b Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 24 Aug 2026 15:54:16 +0200 Subject: [PATCH 6/7] fix failing test Signed-off-by: tdruez --- product_portfolio/tests/test_importers.py | 25 +++++++++++++++++---- vulnerabilities/triage/tests/test_engine.py | 7 ------ 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/product_portfolio/tests/test_importers.py b/product_portfolio/tests/test_importers.py index de6ce8fa..6e01e0c6 100644 --- a/product_portfolio/tests/test_importers.py +++ b/product_portfolio/tests/test_importers.py @@ -1507,9 +1507,26 @@ def test_product_portfolio_import_packages_from_scio_importer_is_reachable_not_o self.assertFalse(analysis.is_reachable) # A second import with a conflicting value must not overwrite the existing one. - mock_fetch_packages.return_value[0]["affected_by_vulnerabilities"][0]["is_reachable"] = ( - "yes" - ) + # Reassign return_value because import_package pops "affected_by_vulnerabilities". + mock_fetch_packages.return_value = [ + { + "purl": "pkg:maven/abc/abc@1.0", + "type": "maven", + "namespace": "abc", + "name": "abc", + "version": "1.0", + "affected_by_vulnerabilities": [ + { + "advisory_uid": "github_osv/GHSA-existing", + "summary": "A vulnerability", + "is_reachable": "yes", + "cdx_vulnerability_data": { + "analysis": {"state": "in_triage", "detail": "Under review"}, + }, + } + ], + } + ] importer2 = ImportPackageFromScanCodeIO( user=self.super_user, project_uuid=uuid.uuid4(), @@ -1556,4 +1573,4 @@ def test_product_portfolio_import_packages_from_scio_importer_is_reachable_witho vulnerability__advisory_uid="github_osv/GHSA-no-cdx" ) self.assertTrue(analysis.is_reachable) - self.assertIsNone(analysis.state) + self.assertFalse(analysis.state) diff --git a/vulnerabilities/triage/tests/test_engine.py b/vulnerabilities/triage/tests/test_engine.py index e05c120a..42fe570d 100644 --- a/vulnerabilities/triage/tests/test_engine.py +++ b/vulnerabilities/triage/tests/test_engine.py @@ -145,13 +145,6 @@ def test_updates_an_existing_preset_owned_analysis(self): self.assertEqual(second_preset, analysis.applied_by_preset) self.assertEqual(1, VulnerabilityAnalysis.objects.count()) - def test_skips_creation_when_the_preset_has_no_content_field_set(self): - # An AnalysisPreset always requires at least one content field to be saved (see - # VulnerabilityAnalysisContentMixin.save), so this can only happen with an in-memory - # preset. This exercises the defensive guard against saving a content-less analysis. - content_less_preset = AnalysisPreset(dataspace=self.dataspace, is_reachable=True) - apply_preset_for_vulnerabilities(content_less_preset, self.product, [self.vulnerability.pk]) - self.assertFalse(VulnerabilityAnalysis.objects.exists()) def test_does_nothing_when_no_product_package_carries_the_vulnerability(self): other_package = make_package(self.dataspace) From c58a55cd8f4d9ca28a7ee9ce4e97f5c463f9f349 Mon Sep 17 00:00:00 2001 From: tdruez Date: Mon, 24 Aug 2026 16:36:21 +0200 Subject: [PATCH 7/7] fix format and failing tests Signed-off-by: tdruez --- product_portfolio/tests/test_importers.py | 3 --- vulnerabilities/triage/tests/test_engine.py | 1 - 2 files changed, 4 deletions(-) diff --git a/product_portfolio/tests/test_importers.py b/product_portfolio/tests/test_importers.py index 6e01e0c6..f98a616f 100644 --- a/product_portfolio/tests/test_importers.py +++ b/product_portfolio/tests/test_importers.py @@ -1520,9 +1520,6 @@ def test_product_portfolio_import_packages_from_scio_importer_is_reachable_not_o "advisory_uid": "github_osv/GHSA-existing", "summary": "A vulnerability", "is_reachable": "yes", - "cdx_vulnerability_data": { - "analysis": {"state": "in_triage", "detail": "Under review"}, - }, } ], } diff --git a/vulnerabilities/triage/tests/test_engine.py b/vulnerabilities/triage/tests/test_engine.py index 42fe570d..bdb5dae2 100644 --- a/vulnerabilities/triage/tests/test_engine.py +++ b/vulnerabilities/triage/tests/test_engine.py @@ -145,7 +145,6 @@ def test_updates_an_existing_preset_owned_analysis(self): self.assertEqual(second_preset, analysis.applied_by_preset) self.assertEqual(1, VulnerabilityAnalysis.objects.count()) - def test_does_nothing_when_no_product_package_carries_the_vulnerability(self): other_package = make_package(self.dataspace) other_vulnerability = make_vulnerability(self.dataspace, affecting=other_package)