From 1c910c38daed58d98ca76bce6c61c9312ec07ea0 Mon Sep 17 00:00:00 2001 From: devGregA Date: Fri, 10 Jul 2026 15:14:09 -0600 Subject: [PATCH] Fix Snyk Issue API reachability rendering; pin parser output surfaces The parser collapsed Snyk's per-coordinate reachability into a broken boolean: 'reachability != "not-applicable"' counted 'no-path-found' (Snyk affirmatively found NO path to the vulnerable code) and even a missing value as reachable, so those findings rendered 'Reachable: Yes' in the impact text. Render the raw values Snyk reported instead ('Reachability: reachable / package / no-path-found / not-applicable'), and say nothing when Snyk reports nothing. Also pins existing observable output surfaces with tests so future changes to them are deliberate rather than incidental: - govulncheck V2: the '**Reachability:** ( severity)' description line and the '**Traces:**' block - sysdig_reports: the 'InUse:' tag format New fixture exercises all four Snyk reachability cases including the previously-misrendered no-path-found and missing-value cases. Co-Authored-By: Claude Fable 5 --- dojo/tools/snyk_issue_api/parser.py | 18 +- .../snyk_sca_scan_api_reachability.json | 284 ++++++++++++++++++ unittests/tools/test_govulncheck_parser.py | 11 + .../test_snyk_issue_api_parser_with_json.py | 24 +- unittests/tools/test_sysdig_reports_parser.py | 9 + 5 files changed, 339 insertions(+), 7 deletions(-) create mode 100644 unittests/scans/snyk_issue_api/snyk_sca_scan_api_reachability.json diff --git a/dojo/tools/snyk_issue_api/parser.py b/dojo/tools/snyk_issue_api/parser.py index 2f4daa36cf7..38114271ed6 100644 --- a/dojo/tools/snyk_issue_api/parser.py +++ b/dojo/tools/snyk_issue_api/parser.py @@ -79,13 +79,14 @@ def extract_coordinate_data(self, is_type_code, coordinates): line = None # Always None for SCA component_name = None component_version = None - reachable = False # SCA only + reachability_values = [] # SCA only, raw values as reported by Snyk impact_locations = [] for coordinate in coordinates: if not is_type_code: - if coordinate.get("reachability") != "not-applicable": - reachable = True + reachability = coordinate.get("reachability") + if reachability: + reachability_values.append(reachability) for representation in coordinate.get("representations", []): if not is_type_code: @@ -116,7 +117,7 @@ def extract_coordinate_data(self, is_type_code, coordinates): if region: line = start.get("line") - return file_path, line, component_name, component_version, reachable, impact_locations + return file_path, line, component_name, component_version, reachability_values, impact_locations def get_exploit_details(self, exploit_details): if exploit_details: @@ -219,7 +220,7 @@ def get_finding(self, issue, test): is_out_of_scope = False # attributes.get("is_out_of_scope", False) - file_path, line, component_name, component_version, reachable, impact_locations = self.extract_coordinate_data(issue_type == "code", attributes.get("coordinates", [])) + file_path, line, component_name, component_version, reachability_values, impact_locations = self.extract_coordinate_data(issue_type == "code", attributes.get("coordinates", [])) # Locations (Code only) if impact_locations: @@ -235,7 +236,12 @@ def get_finding(self, issue, test): "", ]) - impact_details.append(f"Reachable: {'Yes' if reachable else 'No'}") + # Render the raw reachability values Snyk reported instead of a derived + # boolean: the previous "Reachable: Yes/No" counted "no-path-found" (Snyk + # affirmatively found NO path to the vulnerable code) and even a missing + # value as "Yes". When Snyk reports nothing, say nothing. + if reachability_values: + impact_details.append("Reachability: " + ", ".join(sorted(set(reachability_values)))) # Create finding finding = Finding( diff --git a/unittests/scans/snyk_issue_api/snyk_sca_scan_api_reachability.json b/unittests/scans/snyk_issue_api/snyk_sca_scan_api_reachability.json new file mode 100644 index 00000000000..579ee90cec9 --- /dev/null +++ b/unittests/scans/snyk_issue_api/snyk_sca_scan_api_reachability.json @@ -0,0 +1,284 @@ +{ + "jsonapi": { + "version": "1.0" + }, + "links": { + "self": "/rest/orgs/11111111111-2222-3333333-4444/issues?version=2024-10-15&limit=100", + "first": "/rest/orgs/11111111111-2222-3333333-4444/issues?limit=100&version=2024-10-15", + "last": "/rest/orgs/11111111111-2222-3333333-4444/issues?ending_before=end&limit=100&version=2024-10-15", + "prev": "/rest/orgs/11111111111-2222-3333333-4444/issues?ending_before=" + }, + "data": [ + { + "id": "0a4f2f6a-13b8-4a3c-9d2e-7f1a2b3c4d5e", + "type": "issue", + "attributes": { + "classes": [ + { + "id": "CWE-502", + "source": "CWE", + "type": "weakness" + } + ], + "coordinates": [ + { + "is_fixable_manually": false, + "is_fixable_snyk": true, + "is_fixable_upstream": false, + "is_patchable": false, + "is_pinnable": false, + "is_upgradeable": true, + "reachability": "reachable", + "representations": [ + { + "dependency": { + "package_name": "pyyaml", + "package_version": "5.3.1" + } + } + ] + } + ], + "created_at": "2025-09-11T18:25:22.457Z", + "effective_severity_level": "critical", + "ignored": false, + "key": "SNYK-PYTHON-PYYAML-590151", + "problems": [ + { + "id": "SNYK-PYTHON-PYYAML-590151", + "source": "SNYK", + "type": "vulnerability", + "updated_at": "2025-09-19T09:01:27.147502Z" + } + ], + "status": "open", + "title": "Arbitrary Code Execution", + "type": "package_vulnerability", + "updated_at": "2025-09-19T09:01:03.316Z" + }, + "relationships": { + "organization": { + "data": { + "id": "3de2b524-eae7-4f3f-ba5f-1a07e1df44c2", + "type": "organization" + }, + "links": { + "related": "/orgs/3de2b524-eae7-4f3f-ba5f-1a07e1df44c2" + } + }, + "scan_item": { + "data": { + "id": "92867a20-30b8-44fd-a59f-da71d59b58f1", + "type": "project" + }, + "links": { + "related": "/orgs/3de2b524-eae7-4f3f-ba5f-1a07e1df44c2/projects/92867a20-30b8-44fd-a59f-da71d59b58f1" + } + } + } + }, + { + "id": "1b5e3a7b-24c9-4b4d-8e3f-8a2b3c4d5e6f", + "type": "issue", + "attributes": { + "classes": [ + { + "id": "CWE-400", + "source": "CWE", + "type": "weakness" + } + ], + "coordinates": [ + { + "is_fixable_manually": false, + "is_fixable_snyk": false, + "is_fixable_upstream": false, + "is_patchable": false, + "is_pinnable": false, + "is_upgradeable": true, + "reachability": "package", + "representations": [ + { + "dependency": { + "package_name": "pillow", + "package_version": "9.5.0" + } + } + ] + } + ], + "created_at": "2025-09-11T18:25:22.457Z", + "effective_severity_level": "high", + "ignored": false, + "key": "SNYK-PYTHON-PILLOW-6219986", + "problems": [ + { + "id": "SNYK-PYTHON-PILLOW-6219986", + "source": "SNYK", + "type": "vulnerability", + "updated_at": "2025-09-19T09:01:27.147502Z" + } + ], + "status": "open", + "title": "Denial of Service (DoS)", + "type": "package_vulnerability", + "updated_at": "2025-09-19T09:01:03.316Z" + }, + "relationships": { + "organization": { + "data": { + "id": "3de2b524-eae7-4f3f-ba5f-1a07e1df44c2", + "type": "organization" + }, + "links": { + "related": "/orgs/3de2b524-eae7-4f3f-ba5f-1a07e1df44c2" + } + }, + "scan_item": { + "data": { + "id": "92867a20-30b8-44fd-a59f-da71d59b58f1", + "type": "project" + }, + "links": { + "related": "/orgs/3de2b524-eae7-4f3f-ba5f-1a07e1df44c2/projects/92867a20-30b8-44fd-a59f-da71d59b58f1" + } + } + } + }, + { + "id": "2c6f4b8c-35da-4c5e-9f4a-9b3c4d5e6f7a", + "type": "issue", + "attributes": { + "classes": [ + { + "id": "CWE-94", + "source": "CWE", + "type": "weakness" + } + ], + "coordinates": [ + { + "is_fixable_manually": false, + "is_fixable_snyk": false, + "is_fixable_upstream": false, + "is_patchable": false, + "is_pinnable": true, + "is_upgradeable": false, + "reachability": "no-path-found", + "representations": [ + { + "dependency": { + "package_name": "setuptools", + "package_version": "40.5.0" + } + } + ] + } + ], + "created_at": "2025-09-11T18:25:22.457Z", + "effective_severity_level": "medium", + "ignored": false, + "key": "SNYK-PYTHON-SETUPTOOLS-7448483", + "problems": [ + { + "id": "SNYK-PYTHON-SETUPTOOLS-7448483", + "source": "SNYK", + "type": "vulnerability", + "updated_at": "2025-09-19T09:01:27.147502Z" + } + ], + "status": "open", + "title": "Improper Control of Generation of Code ('Code Injection')", + "type": "package_vulnerability", + "updated_at": "2025-09-19T09:01:03.316Z" + }, + "relationships": { + "organization": { + "data": { + "id": "3de2b524-eae7-4f3f-ba5f-1a07e1df44c2", + "type": "organization" + }, + "links": { + "related": "/orgs/3de2b524-eae7-4f3f-ba5f-1a07e1df44c2" + } + }, + "scan_item": { + "data": { + "id": "92867a20-30b8-44fd-a59f-da71d59b58f1", + "type": "project" + }, + "links": { + "related": "/orgs/3de2b524-eae7-4f3f-ba5f-1a07e1df44c2/projects/92867a20-30b8-44fd-a59f-da71d59b58f1" + } + } + } + }, + { + "id": "3d7a5c9d-46eb-4d00-a000-000000000004", + "type": "issue", + "attributes": { + "classes": [ + { + "id": "CWE-502", + "source": "CWE", + "type": "weakness" + } + ], + "coordinates": [ + { + "is_fixable_manually": false, + "is_fixable_snyk": true, + "is_fixable_upstream": false, + "is_patchable": false, + "is_pinnable": false, + "is_upgradeable": true, + "representations": [ + { + "dependency": { + "package_name": "pyyaml", + "package_version": "5.3.1" + } + } + ] + } + ], + "created_at": "2025-09-11T18:25:22.457Z", + "effective_severity_level": "critical", + "ignored": false, + "key": "3d7a5c9d-46eb-4d00-a000-000000000004", + "problems": [ + { + "id": "SNYK-PYTHON-PYYAML-590151", + "source": "SNYK", + "type": "vulnerability", + "updated_at": "2025-09-19T09:01:27.147502Z" + } + ], + "status": "open", + "title": "No reachability reported", + "type": "package_vulnerability", + "updated_at": "2025-09-19T09:01:03.316Z" + }, + "relationships": { + "organization": { + "data": { + "id": "3de2b524-eae7-4f3f-ba5f-1a07e1df44c2", + "type": "organization" + }, + "links": { + "related": "/orgs/3de2b524-eae7-4f3f-ba5f-1a07e1df44c2" + } + }, + "scan_item": { + "data": { + "id": "92867a20-30b8-44fd-a59f-da71d59b58f1", + "type": "project" + }, + "links": { + "related": "/orgs/3de2b524-eae7-4f3f-ba5f-1a07e1df44c2/projects/92867a20-30b8-44fd-a59f-da71d59b58f1" + } + } + } + } + ] +} \ No newline at end of file diff --git a/unittests/tools/test_govulncheck_parser.py b/unittests/tools/test_govulncheck_parser.py index f4e06d6ac70..601a486cb42 100644 --- a/unittests/tools/test_govulncheck_parser.py +++ b/unittests/tools/test_govulncheck_parser.py @@ -173,6 +173,17 @@ def test_parse_issue_15033(self): self.assertEqual(17, severities.count("Low")) self.assertEqual(26, severities.count("Info")) + # Pin the description output format: these exact surfaces are part of + # the parser's observable contract (users and downstream tooling read + # them), so changes to them must be deliberate, not incidental. + high = next(f for f in findings if f.severity == "High") + self.assertIn("**Reachability:** symbol (High severity)", high.description) + self.assertIn("**Traces:**", high.description) + low = next(f for f in findings if f.severity == "Low") + self.assertIn("**Reachability:** package (Low severity)", low.description) + info = next(f for f in findings if f.severity == "Info") + self.assertIn("**Reachability:** module (Info severity)", info.description) + # Every finding maps to a vulnerable component. self.assertTrue(all(f.component_name for f in findings)) diff --git a/unittests/tools/test_snyk_issue_api_parser_with_json.py b/unittests/tools/test_snyk_issue_api_parser_with_json.py index 16473bb0e55..39778ef8d68 100644 --- a/unittests/tools/test_snyk_issue_api_parser_with_json.py +++ b/unittests/tools/test_snyk_issue_api_parser_with_json.py @@ -163,6 +163,28 @@ def test_parse_code_finding_hardcoded_password_temp_ignored(self): # attributes.title self.assertEqual("Use of Hardcoded Passwords", finding.title) + def test_parse_sca_reachability_rendered_from_raw_values(self): + """Pin the impact reachability format: raw Snyk values, no derived boolean. + + Guards the fix for the old behavior where "no-path-found" (Snyk + affirmatively found NO path to the vulnerable code) and even a missing + value both rendered as "Reachable: Yes". The exact "Reachability: " + line is part of the parser's observable contract for downstream tooling. + """ + findings = self.parse_json("snyk_sca_scan_api_reachability.json") + self.assertEqual(4, len(findings)) + impacts = {f.unique_id_from_tool: f.impact for f in findings} + + self.assertIn("Reachability: reachable", impacts["0a4f2f6a-13b8-4a3c-9d2e-7f1a2b3c4d5e"]) + self.assertIn("Reachability: package", impacts["1b5e3a7b-24c9-4b4d-8e3f-8a2b3c4d5e6f"]) + self.assertIn("Reachability: no-path-found", impacts["2c6f4b8c-35da-4c5e-9f4a-9b3c4d5e6f7a"]) + + # When Snyk reports no reachability value at all, say nothing — the old + # derived boolean fabricated "Reachable: Yes" here. + no_value_impact = impacts["3d7a5c9d-46eb-4d00-a000-000000000004"] + self.assertNotIn("Reachability:", no_value_impact) + self.assertNotIn("Reachable:", no_value_impact) + def test_parse_sca_findings_status_open(self): findings = self.parse_json("snyk_sca_scan_api_many_vuln.json") @@ -176,7 +198,7 @@ def test_parse_sca_findings_status_open(self): # attributes.coordinates -> fix_available, component info, reachability self.assertEqual(True, finding.fix_available) - self.assertIn("Reachable: No", finding.impact) # Not reachable + self.assertIn("Reachability: not-applicable", finding.impact) # Raw Snyk value, not a derived boolean self.assertEqual("pillow", finding.component_name) self.assertEqual("9.5.0", finding.component_version) self.assertEqual("pillow", finding.file_path) diff --git a/unittests/tools/test_sysdig_reports_parser.py b/unittests/tools/test_sysdig_reports_parser.py index 7993d262db4..693a4277f96 100644 --- a/unittests/tools/test_sysdig_reports_parser.py +++ b/unittests/tools/test_sysdig_reports_parser.py @@ -99,6 +99,15 @@ def test_sysdig_parser_2025_csv_format(self): critical_finding = critical_findings[0] self.assertIn("CVE-", critical_finding.unsaved_vulnerability_ids[0]) + # Pin the runtime in-use tag format: the exact "InUse:" shape is + # part of the parser's observable contract (users filter on it and + # downstream tooling reads it), so changes must be deliberate. + in_use_tags = [t for f in findings for t in (f.unsaved_tags or []) if t.startswith("InUse:")] + self.assertTrue(in_use_tags, "expected InUse tags for rows with cluster context") + self.assertTrue(all(t in {"InUse:True", "InUse:False"} for t in in_use_tags)) + self.assertIn("InUse:True", in_use_tags) + self.assertIn("InUse:False", in_use_tags) + def test_sysdig_parser_2025_json_format(self): """Test JSON parsing with new 2025 format that has metadata before data section""" with (get_unit_tests_scans_path("sysdig_reports") / "sysdig-2025.json").open(encoding="utf-8") as testfile: