Skip to content

Commit dcc9a25

Browse files
fix: Add file_path based detail mode for Anchore Grype parser (#14592)
* fix: Add file_path to deduplication key for Anchore Grype parser Modified dupe_key in parser to include file_path, preventing same CVE found in different binaries from being merged into a single finding Reference: #14573 * Apply suggestion from @valentijnscholten Co-authored-by: valentijnscholten <valentijnscholten@gmail.com> * Feat: Add Anchore Grype detailed scan type for per-file-path deduplication documentation Reference: #14573 --------- Co-authored-by: valentijnscholten <valentijnscholten@gmail.com>
1 parent 3699b76 commit dcc9a25

5 files changed

Lines changed: 210 additions & 4 deletions

File tree

docs/content/supported_tools/parsers/file/anchore_grype.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,31 @@ By default, DefectDojo identifies duplicate Findings using these [hashcode field
203203
- severity
204204
- component name
205205
- component version
206+
207+
### Anchore Grype Detailed
208+
209+
Both scan types accept the same JSON report format. The difference is in how Findings are deduplicated:
210+
211+
- **`Anchore Grype`** — Aggregates all matches for the same CVE, component name, and version into a single Finding, regardless of file path. Deduplication is based on hashcode fields (`title`, `severity`, `component_name`, `component_version`).
212+
- **`Anchore Grype detailed`** — Creates a separate Finding for each unique file path. Deduplication is based on `unique_id_from_tool`, composed as `{vuln_id}|{component_name}|{component_version}|{file_path}`.
213+
214+
A typical case is a package installed at multiple paths in a container image (e.g., /usr/lib/x86_64-linux-gnu/libc.so.6 and /lib/x86_64-linux-gnu/libc.so.6) — the same CVE would produce one Finding in default mode and two in detailed mode.
215+
216+
**Field mapping:**
217+
218+
| Finding Field | Grype JSON Source |
219+
|---|---|
220+
| `title` | `{vulnerability.id} in {artifact.name}:{artifact.version}` |
221+
| `severity` | `vulnerability.severity` (mapped: `Unknown`/`Negligible``Info`) |
222+
| `description` | `vulnerability.namespace`, `vulnerability.description`, `matchDetails[].matcher`, `artifact.purl` |
223+
| `component_name` | `artifact.name` |
224+
| `component_version` | `artifact.version` |
225+
| `file_path` | `artifact.locations[0].path` |
226+
| `vuln_id_from_tool` | `vulnerability.id` |
227+
| `unique_id_from_tool` | `vuln_id\|component_name\|component_version\|file_path` (detailed mode only) |
228+
| `references` | `vulnerability.dataSource`, `vulnerability.urls`, `relatedVulnerabilities[0].dataSource`, `relatedVulnerabilities[0].urls` |
229+
| `mitigation` | `vulnerability.fix.versions` |
230+
| `fix_available` | `true` if `vulnerability.fix.versions` is non-empty |
231+
| `fix_version` | `vulnerability.fix.versions[0]` (or comma-joined if multiple) |
232+
| `cvssv3` | `vulnerability.cvss` or `relatedVulnerabilities[0].cvss` |
233+
| `epss_score` / `epss_percentile` | `vulnerability.epss` or `relatedVulnerabilities[0].epss` |

dojo/settings/settings.dist.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1520,6 +1520,7 @@ def saml2_attrib_map_format(din):
15201520
"AnchoreCTL Policies Report": True,
15211521
"Anchore Enterprise Policy Check": True,
15221522
"Anchore Grype": True,
1523+
"Anchore Grype detailed": True,
15231524
"AWS Prowler Scan": True,
15241525
"AWS Prowler V3": True,
15251526
"Checkmarx Scan": False,
@@ -1617,6 +1618,7 @@ def saml2_attrib_map_format(din):
16171618
"AnchoreCTL Policies Report": DEDUPE_ALGO_HASH_CODE,
16181619
"Anchore Enterprise Policy Check": DEDUPE_ALGO_HASH_CODE,
16191620
"Anchore Grype": DEDUPE_ALGO_HASH_CODE,
1621+
"Anchore Grype detailed": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL,
16201622
"Aqua Scan": DEDUPE_ALGO_HASH_CODE,
16211623
"AuditJS Scan": DEDUPE_ALGO_UNIQUE_ID_FROM_TOOL,
16221624
"AWS Prowler Scan": DEDUPE_ALGO_HASH_CODE,

dojo/tools/anchore_grype/parser.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,26 @@ class AnchoreGrypeParser:
2020
"""
2121

2222
def get_scan_types(self):
23-
return ["Anchore Grype"]
23+
return ["Anchore Grype", "Anchore Grype detailed"]
2424

2525
def get_label_for_scan_types(self, scan_type):
26-
return "Anchore Grype"
26+
return scan_type
2727

2828
def get_description_for_scan_types(self, scan_type):
29-
return (
29+
if scan_type == "Anchore Grype":
30+
return (
3031
"A vulnerability scanner for container images, filesystems, and SBOMs. "
3132
"JSON report generated with '--output=json' format."
3233
)
34+
return "Detailed Report. Import all vulnerabilities from Anchore Grype without aggregation, creating a separate finding per file path."
35+
36+
# mode:
37+
# None (default): aggregates findings per CVE, component name and version (legacy behavior)
38+
# 'detailed': no aggregation - creates separate findings per file_path
39+
mode = None
40+
41+
def set_mode(self, mode):
42+
self.mode = mode
3343

3444
def get_findings(self, file, test):
3545
logger.debug("file: %s", file)
@@ -185,7 +195,10 @@ def get_findings(self, file, test):
185195
if finding_epss_score is None and rel_vuln_id:
186196
finding_epss_score, finding_epss_percentile = self.get_epss_values(vuln_id, vuln_epss)
187197

188-
dupe_key = finding_title
198+
if self.mode == "detailed":
199+
dupe_key = f"{vuln_id}|{artifact_name}|{artifact_version}|{file_path}"
200+
else:
201+
dupe_key = f"{vuln_id}|{artifact_name}|{artifact_version}"
189202
if dupe_key in dupes:
190203
finding = dupes[dupe_key]
191204
finding.nb_occurences += 1
@@ -210,6 +223,8 @@ def get_findings(self, file, test):
210223
fix_available=fix_available,
211224
fix_version=fix_version,
212225
)
226+
if self.mode == "detailed":
227+
dupes[dupe_key].unique_id_from_tool = dupe_key
213228
dupes[dupe_key].unsaved_vulnerability_ids = vulnerability_ids
214229
if settings.V3_FEATURE_LOCATIONS and artifact_purl:
215230
dupes[dupe_key].unsaved_locations.append(
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
{
2+
"matches": [
3+
{
4+
"vulnerability": {
5+
"id": "CVE-2021-33574",
6+
"dataSource": "https://security-tracker.debian.org/tracker/CVE-2021-33574",
7+
"namespace": "debian:10",
8+
"severity": "Critical",
9+
"urls": [
10+
"https://security-tracker.debian.org/tracker/CVE-2021-33574"
11+
],
12+
"cvss": [],
13+
"fix": {
14+
"versions": [],
15+
"state": "not-fixed"
16+
},
17+
"advisories": []
18+
},
19+
"relatedVulnerabilities": [],
20+
"matchDetails": [
21+
{
22+
"matcher": "dpkg-matcher",
23+
"searchedBy": {
24+
"distro": {
25+
"type": "debian",
26+
"version": "10"
27+
},
28+
"namespace": "debian:10",
29+
"package": {
30+
"name": "libc6",
31+
"version": "2.28-10"
32+
}
33+
},
34+
"found": {
35+
"versionConstraint": "none (deb)"
36+
}
37+
}
38+
],
39+
"artifact": {
40+
"name": "libc6",
41+
"version": "2.28-10",
42+
"type": "deb",
43+
"locations": [
44+
{
45+
"path": "/usr/lib/x86_64-linux-gnu/libc.so.6",
46+
"layerID": "sha256:e3d73f29c6746d2b19dbcc7bfa7b2464c4951807237658ad483faa014f9f9187"
47+
}
48+
],
49+
"language": "",
50+
"licenses": [],
51+
"cpes": [],
52+
"purl": "pkg:deb/debian/libc6@2.28-10?arch=amd64",
53+
"metadataType": "DpkgMetadata"
54+
}
55+
},
56+
{
57+
"vulnerability": {
58+
"id": "CVE-2021-33574",
59+
"dataSource": "https://security-tracker.debian.org/tracker/CVE-2021-33574",
60+
"namespace": "debian:10",
61+
"severity": "Critical",
62+
"urls": [
63+
"https://security-tracker.debian.org/tracker/CVE-2021-33574"
64+
],
65+
"cvss": [],
66+
"fix": {
67+
"versions": [],
68+
"state": "not-fixed"
69+
},
70+
"advisories": []
71+
},
72+
"relatedVulnerabilities": [],
73+
"matchDetails": [
74+
{
75+
"matcher": "dpkg-matcher",
76+
"searchedBy": {
77+
"distro": {
78+
"type": "debian",
79+
"version": "10"
80+
},
81+
"namespace": "debian:10",
82+
"package": {
83+
"name": "libc6",
84+
"version": "2.28-10"
85+
}
86+
},
87+
"found": {
88+
"versionConstraint": "none (deb)"
89+
}
90+
}
91+
],
92+
"artifact": {
93+
"name": "libc6",
94+
"version": "2.28-10",
95+
"type": "deb",
96+
"locations": [
97+
{
98+
"path": "/lib/x86_64-linux-gnu/libc.so.6",
99+
"layerID": "sha256:10bf86ff9f6a0d24fa41491e74b8dcdeb8b23cc654a2a5e36573eac5e40ea25c"
100+
}
101+
],
102+
"language": "",
103+
"licenses": [],
104+
"cpes": [],
105+
"purl": "pkg:deb/debian/libc6@2.28-10?arch=amd64",
106+
"metadataType": "DpkgMetadata"
107+
}
108+
}
109+
],
110+
"source": {
111+
"type": "image",
112+
"target": {
113+
"userInput": "test-image:latest",
114+
"imageID": "sha256:test",
115+
"manifestDigest": "sha256:test",
116+
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
117+
"tags": [],
118+
"imageSize": 100,
119+
"layers": [],
120+
"manifest": null,
121+
"config": null,
122+
"repoDigests": [],
123+
"architecture": "amd64",
124+
"os": "linux"
125+
}
126+
},
127+
"distro": {
128+
"name": "debian",
129+
"version": "10",
130+
"idLike": []
131+
},
132+
"descriptor": {
133+
"name": "grype",
134+
"version": "0.36.0"
135+
}
136+
}

unittests/tools/test_anchore_grype_parser.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,3 +341,28 @@ def test_grype_epss_problem(self):
341341
self.assertAlmostEqual(epss_score, finding.epss_score, places=5)
342342
self.assertIsNotNone(finding.epss_percentile)
343343
self.assertAlmostEqual(epss_percentile, finding.epss_percentile, places=5)
344+
345+
def test_parser_scan_types(self):
346+
parser = AnchoreGrypeParser()
347+
scan_types = parser.get_scan_types()
348+
self.assertIn("Anchore Grype", scan_types)
349+
self.assertIn("Anchore Grype detailed", scan_types)
350+
351+
def test_default_mode_deduplicates_same_cve_different_paths(self):
352+
"""In default mode, same CVE/component/version at different file paths should be merged into one finding."""
353+
with (get_unit_tests_scans_path("anchore_grype") / "same_cve_different_paths.json").open(encoding="utf-8") as testfile:
354+
parser = AnchoreGrypeParser()
355+
findings = parser.get_findings(testfile, Test())
356+
self.assertEqual(1, len(findings))
357+
self.assertEqual(findings[0].nb_occurences, 2)
358+
359+
def test_detailed_mode_separates_same_cve_different_paths(self):
360+
"""In detailed mode, same CVE/component/version at different file paths should produce separate findings."""
361+
with (get_unit_tests_scans_path("anchore_grype") / "same_cve_different_paths.json").open(encoding="utf-8") as testfile:
362+
parser = AnchoreGrypeParser()
363+
parser.set_mode("detailed")
364+
findings = parser.get_findings(testfile, Test())
365+
self.assertEqual(2, len(findings))
366+
file_paths = {f.file_path for f in findings}
367+
self.assertIn("/usr/lib/x86_64-linux-gnu/libc.so.6", file_paths)
368+
self.assertIn("/lib/x86_64-linux-gnu/libc.so.6", file_paths)

0 commit comments

Comments
 (0)