Skip to content
71 changes: 71 additions & 0 deletions dojo/tools/dependency_track/parser.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import contextlib
import json
import logging

from dateutil import parser
from packaging.version import InvalidVersion, Version

from dojo.location.feature import locations_enabled
from dojo.models import Finding
Expand Down Expand Up @@ -32,6 +34,74 @@ def _convert_dependency_track_severity_to_dojo_severity(self, dependency_track_s
return "Informational"
return None

def _component_version_in_range(self, component_version, affected_range):
bound_fields = ("versionStartIncluding", "versionStartExcluding", "versionEndIncluding", "versionEndExcluding")
if all(affected_range.get(field) is None for field in bound_fields):
# No range bounds (for example an exact-version entry): it describes no band,
# so it cannot be said to contain a version.
return False
try:
version_start_including = affected_range.get("versionStartIncluding")
if version_start_including is not None and component_version < Version(version_start_including):
return False
version_start_excluding = affected_range.get("versionStartExcluding")
if version_start_excluding is not None and component_version <= Version(version_start_excluding):
return False
version_end_including = affected_range.get("versionEndIncluding")
if version_end_including is not None and component_version > Version(version_end_including):
return False
version_end_excluding = affected_range.get("versionEndExcluding")
if version_end_excluding is not None and component_version >= Version(version_end_excluding):
return False
except InvalidVersion:
return False
return True

def _build_message(self, chosen_range):
version_end_excluding = chosen_range.get("versionEndExcluding")
if version_end_excluding is not None:
return f"Upgrade to {version_end_excluding} or later"
version_end_including = chosen_range.get("versionEndIncluding")
if version_end_including is not None:
return f"Upgrade to a version after {version_end_including}"
return None

def _derive_mitigation_from_affected_versions(self, dependency_track_finding):
affected_versions = dependency_track_finding["vulnerability"].get("affectedVersions")
if not affected_versions:
return None
component = dependency_track_finding.get("component", {})
purl = component.get("purl")
if purl is None:
return None
component_version = None
component_version_string = component.get("version")
if component_version_string is not None:
with contextlib.suppress(InvalidVersion):
component_version = Version(component_version_string)
clean_purl = purl.rsplit("@", 1)[0]
filtered_affected_ranges = [
entry
for entry in affected_versions
if entry.get("identityType") == "PURL" and entry.get("identity") == clean_purl
]
if not filtered_affected_ranges:
return None
chosen_range = None
if component_version is None:
# Without a comparable version we cannot choose between ranges; only a lone
# range is unambiguous enough to act on.
if len(filtered_affected_ranges) == 1:
chosen_range = filtered_affected_ranges[0]
else:
for affected_range in filtered_affected_ranges:
if self._component_version_in_range(component_version, affected_range):
chosen_range = affected_range
break
if chosen_range is None:
return None
return self._build_message(chosen_range)

def _convert_dependency_track_finding_to_dojo_finding(self, dependency_track_finding, test):
"""
Converts a Dependency Track finding to a DefectDojo finding
Expand Down Expand Up @@ -179,6 +249,7 @@ def _convert_dependency_track_finding_to_dojo_finding(self, dependency_track_fin
test=test,
cwe=cwe,
description=vulnerability_description,
mitigation=self._derive_mitigation_from_affected_versions(dependency_track_finding),
severity=vulnerability_severity,
false_p=is_false_positive,
component_name=component_name,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"meta": {
"application": "Dependency-Track",
"version": "4.14.0",
"timestamp": "2026-01-01T00:00:00Z"
},
"findings": [
{
"component": {
"name": "example-lib",
"project": "d38ebf15-96cd-4a01-9bca-7b49b5b0e7c4",
"purl": "pkg:maven/com.example/example-lib@1.5.0",
"uuid": "c8a65fcd-dbf4-41eb-b1ec-55e67c91b22b",
"version": "1.5.0"
},
"attribution": {
"analyzerIdentity": "INTERNAL_ANALYZER",
"attributedOn": "2026-01-01 00:00:00.000"
},
"vulnerability": {
"uuid": "f8469ce4-019a-4482-8510-624dcf65f005",
"source": "NVD",
"vulnId": "CVE-2026-0001",
"severity": "HIGH",
"description": "Example vulnerability affecting example-lib up to and including 1.5.0.",
"aliases": [],
"affectedVersions": [
{
"identityType": "PURL",
"identity": "pkg:maven/com.example/example-lib",
"versionType": "RANGE",
"versionStartIncluding": "1.0.0",
"versionEndIncluding": "1.5.0",
"uuid": "0f0dcf3b-0f38-4d3b-8f6c-9c0d5f0a1b2c"
}
]
},
"matrix": "d38ebf15-96cd-4a01-9bca-7b49b5b0e7c4:c8a65fcd-dbf4-41eb-b1ec-55e67c91b22b:f8469ce4-019a-4482-8510-624dcf65f005"
}
],
"project": {
"name": "test-app",
"uuid": "d38ebf15-96cd-4a01-9bca-7b49b5b0e7c4",
"version": "1.0.0"
},
"version": "1.6"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"meta": {
"application": "Dependency-Track",
"version": "4.14.0",
"timestamp": "2026-01-01T00:00:00Z"
},
"findings": [
{
"component": {
"name": "example-lib",
"project": "d38ebf15-96cd-4a01-9bca-7b49b5b0e7c4",
"purl": "pkg:maven/com.example/example-lib@2.14.1",
"uuid": "c8a65fcd-dbf4-41eb-b1ec-55e67c91b22b",
"version": "2.14.1"
},
"attribution": {
"analyzerIdentity": "INTERNAL_ANALYZER",
"attributedOn": "2026-01-01 00:00:00.000"
},
"vulnerability": {
"uuid": "f8469ce4-019a-4482-8510-624dcf65f005",
"source": "NVD",
"vulnId": "CVE-2026-0002",
"severity": "HIGH",
"description": "Example vulnerability with one exact affected version and one affected range.",
"aliases": [],
"affectedVersions": [
{
"identityType": "PURL",
"identity": "pkg:maven/com.example/example-lib",
"versionType": "EXACT",
"version": "1.0.0",
"uuid": "aaaa1111-0f38-4d3b-8f6c-9c0d5f0a1b2c"
},
{
"identityType": "PURL",
"identity": "pkg:maven/com.example/example-lib",
"versionType": "RANGE",
"versionStartIncluding": "2.0.0",
"versionEndExcluding": "2.15.0",
"uuid": "bbbb2222-0f38-4d3b-8f6c-9c0d5f0a1b2c"
}
]
},
"matrix": "d38ebf15-96cd-4a01-9bca-7b49b5b0e7c4:c8a65fcd-dbf4-41eb-b1ec-55e67c91b22b:f8469ce4-019a-4482-8510-624dcf65f005"
}
],
"project": {
"name": "test-app",
"uuid": "d38ebf15-96cd-4a01-9bca-7b49b5b0e7c4",
"version": "1.0.0"
},
"version": "1.6"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{
"meta": {
"application": "Dependency-Track",
"version": "4.14.0",
"timestamp": "2026-01-01T00:00:00Z"
},
"findings": [
{
"component": {
"name": "log4j-core",
"project": "d38ebf15-96cd-4a01-9bca-7b49b5b0e7c4",
"purl": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
"uuid": "c8a65fcd-dbf4-41eb-b1ec-55e67c91b22b",
"version": "2.14.1"
},
"attribution": {
"analyzerIdentity": "INTERNAL_ANALYZER",
"attributedOn": "2026-01-01 00:00:00.000"
},
"vulnerability": {
"uuid": "f8469ce4-019a-4482-8510-624dcf65f005",
"source": "NVD",
"vulnId": "CVE-2021-44228",
"severity": "CRITICAL",
"description": "Apache Log4j2 JNDI features do not protect against attacker-controlled LDAP.",
"aliases": [],
"affectedVersions": [
{
"identityType": "PURL",
"identity": "pkg:maven/org.apache.logging.log4j/log4j-core",
"versionType": "RANGE",
"versionStartIncluding": "3.0.0",
"versionEndExcluding": "3.2.0",
"uuid": "1a2b3c4d-5e6f-4a3b-9c8d-7e6f5a4b3c2d"
},
{
"identityType": "PURL",
"identity": "pkg:maven/org.apache.logging.log4j/log4j-core",
"versionType": "RANGE",
"versionStartIncluding": "2.0.0",
"versionEndExcluding": "2.15.0",
"uuid": "0f0dcf3b-0f38-4d3b-8f6c-9c0d5f0a1b2c"
}
]
},
"matrix": "d38ebf15-96cd-4a01-9bca-7b49b5b0e7c4:c8a65fcd-dbf4-41eb-b1ec-55e67c91b22b:f8469ce4-019a-4482-8510-624dcf65f005"
}
],
"project": {
"name": "test-app",
"uuid": "d38ebf15-96cd-4a01-9bca-7b49b5b0e7c4",
"version": "1.0.0"
},
"version": "1.6"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"meta": {
"application": "Dependency-Track",
"version": "4.14.0",
"timestamp": "2026-01-01T00:00:00Z"
},
"findings": [
{
"component": {
"name": "example-lib",
"project": "d38ebf15-96cd-4a01-9bca-7b49b5b0e7c4",
"purl": "pkg:maven/com.example/example-lib@2.0.0.RELEASE",
"uuid": "c8a65fcd-dbf4-41eb-b1ec-55e67c91b22b",
"version": "2.0.0.RELEASE"
},
"attribution": {
"analyzerIdentity": "INTERNAL_ANALYZER",
"attributedOn": "2026-01-01 00:00:00.000"
},
"vulnerability": {
"uuid": "f8469ce4-019a-4482-8510-624dcf65f005",
"source": "NVD",
"vulnId": "CVE-2026-0003",
"severity": "HIGH",
"description": "Example vulnerability where the component version does not parse as a standard version.",
"aliases": [],
"affectedVersions": [
{
"identityType": "PURL",
"identity": "pkg:maven/com.example/example-lib",
"versionType": "RANGE",
"versionStartIncluding": "1.0.0",
"versionEndExcluding": "3.0.0",
"uuid": "cccc3333-0f38-4d3b-8f6c-9c0d5f0a1b2c"
}
]
},
"matrix": "d38ebf15-96cd-4a01-9bca-7b49b5b0e7c4:c8a65fcd-dbf4-41eb-b1ec-55e67c91b22b:f8469ce4-019a-4482-8510-624dcf65f005"
}
],
"project": {
"name": "test-app",
"uuid": "d38ebf15-96cd-4a01-9bca-7b49b5b0e7c4",
"version": "1.0.0"
},
"version": "1.6"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"meta": {
"application": "Dependency-Track",
"version": "4.14.0",
"timestamp": "2026-01-01T00:00:00Z"
},
"findings": [
{
"component": {
"name": "log4j-core",
"project": "d38ebf15-96cd-4a01-9bca-7b49b5b0e7c4",
"purl": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
"uuid": "c8a65fcd-dbf4-41eb-b1ec-55e67c91b22b",
"version": "2.14.1"
},
"attribution": {
"analyzerIdentity": "INTERNAL_ANALYZER",
"attributedOn": "2026-01-01 00:00:00.000"
},
"vulnerability": {
"uuid": "f8469ce4-019a-4482-8510-624dcf65f005",
"source": "NVD",
"vulnId": "CVE-2021-44228",
"severity": "CRITICAL",
"description": "Apache Log4j2 JNDI features do not protect against attacker-controlled LDAP.",
"aliases": [],
"affectedVersions": [
{
"identityType": "PURL",
"identity": "pkg:maven/org.apache.logging.log4j/log4j-core",
"versionType": "RANGE",
"versionStartIncluding": "2.0.0",
"versionEndExcluding": "2.15.0",
"uuid": "0f0dcf3b-0f38-4d3b-8f6c-9c0d5f0a1b2c"
}
]
},
"matrix": "d38ebf15-96cd-4a01-9bca-7b49b5b0e7c4:c8a65fcd-dbf4-41eb-b1ec-55e67c91b22b:f8469ce4-019a-4482-8510-624dcf65f005"
}
],
"project": {
"name": "test-app",
"uuid": "d38ebf15-96cd-4a01-9bca-7b49b5b0e7c4",
"version": "1.0.0"
},
"version": "1.6"
}
44 changes: 44 additions & 0 deletions unittests/tools/test_dependency_track_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,47 @@ def test_dependency_track_parser_finding_with_analysis_detail(self):
findings = parser.get_findings(testfile, Test())
self.assertEqual(1, len(findings))
self.assertIn("Audit Detail: Reviewed and confirmed vulnerable. Upgrade scheduled for next sprint.", findings[0].description)

def test_dependency_track_parser_finding_with_affected_versions(self):
with (get_unit_tests_scans_path("dependency_track") / "one_finding_with_affected_versions.json").open(encoding="utf-8") as testfile:
parser = DependencyTrackParser()
findings = parser.get_findings(testfile, Test())
self.assertEqual(1, len(findings))
self.assertIn("Upgrade to 2.15.0 or later", findings[0].mitigation)

def test_dependency_track_parser_finding_picks_range_containing_component_version(self):
with (get_unit_tests_scans_path("dependency_track") / "finding_with_multiple_affected_ranges.json").open(encoding="utf-8") as testfile:
parser = DependencyTrackParser()
findings = parser.get_findings(testfile, Test())
self.assertEqual(1, len(findings))
# The installed version 2.14.1 falls in the [2.0.0, 2.15.0) range, so the fix is 2.15.0,
# not the 3.2.0 from the unrelated [3.0.0, 3.2.0) range.
self.assertIn("Upgrade to 2.15.0 or later", findings[0].mitigation)
self.assertNotIn("3.2.0", findings[0].mitigation)

def test_dependency_track_parser_finding_with_end_including_range(self):
with (get_unit_tests_scans_path("dependency_track") / "finding_with_affected_range_end_including.json").open(encoding="utf-8") as testfile:
parser = DependencyTrackParser()
findings = parser.get_findings(testfile, Test())
self.assertEqual(1, len(findings))
# The range gives only versionEndIncluding (1.5.0 is the last affected version), so the exact
# fix cannot be named; the mitigation points at a version after it.
self.assertIn("Upgrade to a version after 1.5.0", findings[0].mitigation)

def test_dependency_track_parser_finding_ignores_exact_version_entry(self):
with (get_unit_tests_scans_path("dependency_track") / "finding_with_exact_and_range_versions.json").open(encoding="utf-8") as testfile:
parser = DependencyTrackParser()
findings = parser.get_findings(testfile, Test())
self.assertEqual(1, len(findings))
# The exact-version entry (1.0.0, no bounds) must not swallow the finding; the fix comes from
# the range [2.0.0, 2.15.0) that the installed 2.14.1 actually falls in.
self.assertIn("Upgrade to 2.15.0 or later", findings[0].mitigation)

def test_dependency_track_parser_finding_with_unparseable_component_version(self):
with (get_unit_tests_scans_path("dependency_track") / "finding_with_unparseable_component_version.json").open(encoding="utf-8") as testfile:
parser = DependencyTrackParser()
findings = parser.get_findings(testfile, Test())
self.assertEqual(1, len(findings))
# The component version does not parse, so no range can be compared; with a single affected
# range the fix is still unambiguous and is used as a fallback.
self.assertIn("Upgrade to 3.0.0 or later", findings[0].mitigation)
Loading