Skip to content

Commit 51efce8

Browse files
Add EPSS score support for GitHub Advisory (GHSA) vulnerabilities
Resolves #4330 - Map `percentage` (exploitation probability) and `percentile` (relative rank) from the GitHub EPSS API response to the `epssScore` and `epssPercentile` fields on GHSA Vulnerability records. - Extend `VulnerabilityQueryManager.hasChanges()` to also trigger an update when an advisory has EPSS data but the stored record does not, enabling backfill without relying on a changed `updatedAt` timestamp. - Add upgrade item `v4140Updater` that resets the GHSA mirror timestamp on first boot, causing the next mirror run to re-fetch all advisories and populate EPSS fields on existing records. - Add `ModelConverterTest` (unit) and extend `GitHubAdvisoryMirrorTaskTest` (integration) with EPSS test cases using real values from the GitHub API. - Add AGENTS.md with guidance on running tests via `tee`.
1 parent 9d012bd commit 51efce8

8 files changed

Lines changed: 465 additions & 2 deletions

File tree

AGENTS.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Agent Guidance
2+
3+
## Running Tests
4+
5+
Always tee test output to a file rather than piping directly into `tail`. This keeps the full output accessible for inspection without truncation.
6+
7+
```bash
8+
# ✅ GOOD — full output preserved, can be grepped/tailed afterwards
9+
mvn test -Dtest="MyTest" 2>&1 | tee /tmp/test-output.txt
10+
grep -E "Tests run|FAIL|ERROR" /tmp/test-output.txt
11+
tail -50 /tmp/test-output.txt
12+
13+
# ❌ BAD — output is lost if the tail window is too small
14+
mvn test -Dtest="MyTest" 2>&1 | tail -60
15+
```

docs/_posts/2026-02-19-v4.14.0.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
title: v4.14.0
3+
type: feature
4+
---
5+
6+
**Features:**
7+
8+
* Add EPSS score support for GitHub Advisory (GHSA) vulnerabilities - [apiserver/#4330]
9+
10+
**Fixes:**
11+
12+
For a complete list of changes, refer to the respective GitHub milestones:
13+
14+
* [API server milestone 4.14.0](https://github.com/DependencyTrack/dependency-track/milestone/?closed=1)
15+
* [Frontend milestone 4.14.0](https://github.com/DependencyTrack/frontend/milestone/?closed=1)
16+
17+
We thank all organizations and individuals who contributed to this release, from logging issues to taking part in discussions on GitHub & Slack to testing of fixes.
18+
19+
###### dependency-track-apiserver.jar
20+
21+
| Algorithm | Checksum |
22+
|:----------|:---------|
23+
| SHA-1 | |
24+
| SHA-256 | |
25+
26+
###### dependency-track-bundled.jar
27+
28+
| Algorithm | Checksum |
29+
|:----------|:---------|
30+
| SHA-1 | |
31+
| SHA-256 | |
32+
33+
###### frontend-dist.zip
34+
35+
| Algorithm | Checksum |
36+
|:----------|:-----------------------------------------------------------------|
37+
| SHA-1 | |
38+
| SHA-256 | |
39+
40+
###### Software Bill of Materials (SBOM)
41+
42+
* API Server: [bom.json](https://github.com/DependencyTrack/dependency-track/releases/download/4.14.0/bom.json)
43+
* Frontend: [bom.json](https://github.com/DependencyTrack/frontend/releases/download/4.14.0/bom.json)
44+
45+
[apiserver/#4330]: https://github.com/DependencyTrack/dependency-track/issues/4330

src/main/java/org/dependencytrack/parser/github/ModelConverter.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import com.github.packageurl.PackageURLBuilder;
2525
import io.github.jeremylong.openvulnerability.client.ghsa.CWE;
2626
import io.github.jeremylong.openvulnerability.client.ghsa.CWEs;
27+
import io.github.jeremylong.openvulnerability.client.ghsa.Epss;
2728
import io.github.jeremylong.openvulnerability.client.ghsa.Package;
2829
import io.github.jeremylong.openvulnerability.client.ghsa.Reference;
2930
import io.github.jeremylong.openvulnerability.client.ghsa.SecurityAdvisory;
@@ -37,6 +38,7 @@
3738
import org.dependencytrack.util.CvssUtil;
3839
import org.dependencytrack.util.VulnerabilityUtil;
3940

41+
import java.math.BigDecimal;
4042
import java.time.ZonedDateTime;
4143
import java.util.ArrayList;
4244
import java.util.Arrays;
@@ -101,6 +103,25 @@ public Vulnerability convert(final SecurityAdvisory advisory) {
101103
vuln.getOwaspRRBusinessImpactScore()));
102104
}
103105

106+
if (advisory.getEpss() != null) {
107+
final Epss epss = advisory.getEpss();
108+
// GitHub's GraphQL API (https://docs.github.com/en/graphql/reference/objects#securityadvisoryepss):
109+
// "percentage" = exploitation probability (EPSS score, 0.0-1.0)
110+
// "percentile" = relative rank compared to other CVEs (0.0-1.0)
111+
//
112+
// NOTE: the open-vulnerability-clients library Javadoc has these two fields documented
113+
// with swapped semantics — trust the live API values, not the Javadoc.
114+
// Verified against real API responses, e.g. GHSA-57j2-w4cx-62h2 (CVE-2020-36518):
115+
// percentage=0.00514 (0.514% exploitation probability)
116+
// percentile=0.66009 (ranked above 66% of all CVEs)
117+
if (epss.getPercentage() != null) {
118+
vuln.setEpssScore(new BigDecimal(epss.getPercentage().toString()));
119+
}
120+
if (epss.getPercentile() != null) {
121+
vuln.setEpssPercentile(new BigDecimal(epss.getPercentile().toString()));
122+
}
123+
}
124+
104125
if (advisory.getIdentifiers() != null && !advisory.getIdentifiers().isEmpty()) {
105126
vuln.setAliases(advisory.getIdentifiers().stream()
106127
.filter(identifier -> "cve".equalsIgnoreCase(identifier.getType()))

src/main/java/org/dependencytrack/persistence/VulnerabilityQueryManager.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,16 @@ public Vulnerability createVulnerability(Vulnerability vulnerability, boolean co
8585
return result;
8686
}
8787

88-
private boolean hasChanges(Vulnerability vulnerability, Vulnerability transientVulnerability) {
89-
return vulnerability.getUpdated() == null || transientVulnerability.getUpdated() == null || !vulnerability.getUpdated().equals(transientVulnerability.getUpdated());
88+
private boolean hasChanges(Vulnerability existing, Vulnerability incoming) {
89+
if (existing.getUpdated() == null || incoming.getUpdated() == null
90+
|| !existing.getUpdated().equals(incoming.getUpdated())) {
91+
return true;
92+
}
93+
// Also trigger an update when EPSS data is newly available for a vulnerability
94+
// that was previously mirrored without it (e.g. GHSA entries backfilled after
95+
// EPSS support was added). This condition is self-limiting: once epssScore is
96+
// populated it will not match again, so normal updatedAt-based detection resumes.
97+
return existing.getEpssScore() == null && incoming.getEpssScore() != null;
9098
}
9199

92100
private Vulnerability getExistingVulnerability(Vulnerability transientVulnerability){
@@ -140,6 +148,8 @@ public Vulnerability updateVulnerability(Vulnerability transientVulnerability, b
140148
differ.applyIfChanged("owaspRRVector", Vulnerability::getOwaspRRVector, existingVulnerability::setOwaspRRVector);
141149
differ.applyIfChanged("cwes", Vulnerability::getCwes, existingVulnerability::setCwes);
142150
differ.applyIfNonNullAndChanged("vulnerableSoftware", Vulnerability::getVulnerableSoftware, existingVulnerability::setVulnerableSoftware);
151+
differ.applyIfNonNullAndChanged("epssScore", Vulnerability::getEpssScore, existingVulnerability::setEpssScore);
152+
differ.applyIfNonNullAndChanged("epssPercentile", Vulnerability::getEpssPercentile, existingVulnerability::setEpssPercentile);
143153
}
144154
else {
145155
// Handle cases where no existing vulnerability is found if needed (e.g., log an error)

src/main/java/org/dependencytrack/upgrade/UpgradeItems.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ class UpgradeItems {
4646
UPGRADE_ITEMS.add(org.dependencytrack.upgrade.v4130.v4130_1Updater.class);
4747
UPGRADE_ITEMS.add(org.dependencytrack.upgrade.v4131.v4131Updater.class);
4848
UPGRADE_ITEMS.add(org.dependencytrack.upgrade.v4135.v4135Updater.class);
49+
UPGRADE_ITEMS.add(org.dependencytrack.upgrade.v4140.v4140Updater.class);
4950
}
5051

5152
static List<Class<? extends UpgradeItem>> getUpgradeItems() {
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* This file is part of Dependency-Track.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
* Copyright (c) OWASP Foundation. All Rights Reserved.
18+
*/
19+
package org.dependencytrack.upgrade.v4140;
20+
21+
import alpine.common.logging.Logger;
22+
import alpine.persistence.AlpineQueryManager;
23+
import alpine.server.upgrade.AbstractUpgradeItem;
24+
25+
import java.sql.Connection;
26+
import java.sql.PreparedStatement;
27+
import java.sql.SQLException;
28+
29+
public class v4140Updater extends AbstractUpgradeItem {
30+
31+
private static final Logger LOGGER = Logger.getLogger(v4140Updater.class);
32+
33+
@Override
34+
public String getSchemaVersion() {
35+
return "4.14.0";
36+
}
37+
38+
@Override
39+
public void executeUpgrade(final AlpineQueryManager qm, final Connection connection) throws Exception {
40+
resetGhsaLastModifiedTimestamp(connection);
41+
}
42+
43+
/**
44+
* Resets the GitHub Advisories last-modified epoch timestamp to force a full re-mirror on next run.
45+
* <p>
46+
* This is necessary to backfill EPSS scores on existing GHSA vulnerability entries. The normal
47+
* mirror update path is skipped when an advisory's {@code updatedAt} has not changed, but EPSS
48+
* data is now available for advisories that were previously mirrored without it.
49+
*/
50+
private void resetGhsaLastModifiedTimestamp(final Connection connection) throws SQLException {
51+
LOGGER.info("Resetting GitHub Advisories last-modified timestamp to trigger full re-mirror for EPSS backfill");
52+
try (final PreparedStatement ps = connection.prepareStatement("""
53+
UPDATE "CONFIGPROPERTY"
54+
SET "PROPERTYVALUE" = NULL
55+
WHERE "GROUPNAME" = 'vuln-source'
56+
AND "PROPERTYNAME" = 'github.advisories.last.modified.epoch.seconds'
57+
""")) {
58+
final int rows = ps.executeUpdate();
59+
LOGGER.info("Reset last-modified timestamp (%d row(s) affected)".formatted(rows));
60+
}
61+
}
62+
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/*
2+
* This file is part of Dependency-Track.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
* Copyright (c) OWASP Foundation. All Rights Reserved.
18+
*/
19+
package org.dependencytrack.parser.github;
20+
21+
import alpine.common.logging.Logger;
22+
import com.fasterxml.jackson.databind.ObjectMapper;
23+
import com.fasterxml.jackson.databind.json.JsonMapper;
24+
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
25+
import io.github.jeremylong.openvulnerability.client.ghsa.SecurityAdvisory;
26+
import org.dependencytrack.model.Severity;
27+
import org.dependencytrack.model.Vulnerability;
28+
import org.junit.jupiter.api.BeforeEach;
29+
import org.junit.jupiter.api.Test;
30+
31+
import static org.assertj.core.api.Assertions.assertThat;
32+
import static org.assertj.core.data.Offset.offset;
33+
34+
class ModelConverterTest {
35+
36+
private final ObjectMapper jsonMapper = new JsonMapper()
37+
.registerModule(new JavaTimeModule());
38+
39+
private ModelConverter converter;
40+
41+
@BeforeEach
42+
void setUp() {
43+
converter = new ModelConverter(Logger.getLogger(ModelConverterTest.class));
44+
}
45+
46+
@Test
47+
void testConvertEpssScore() throws Exception {
48+
// Real values from the GitHub GraphQL API for GHSA-57j2-w4cx-62h2 (CVE-2020-36518):
49+
// "percentage": 0.00514 → exploitation probability (EPSS score, 0.0-1.0)
50+
// "percentile": 0.66009 → relative rank (0.0-1.0, i.e. above 66% of all CVEs)
51+
//
52+
// A 0.514% exploitation probability at the 66th percentile is realistic because EPSS
53+
// scores are heavily skewed toward zero; even a small absolute probability can rank high.
54+
// If the two fields were accidentally swapped the assertions below would fail with values
55+
// that are semantically impossible (e.g. 66% exploitation probability).
56+
final var advisory = jsonMapper.readValue(/* language=JSON */ """
57+
{
58+
"ghsaId": "GHSA-57j2-w4cx-62h2",
59+
"severity": "HIGH",
60+
"publishedAt": "2022-03-12T00:00:36Z",
61+
"updatedAt": "2024-03-15T00:24:56Z",
62+
"epss": {
63+
"percentage": 0.00514,
64+
"percentile": 0.66009
65+
}
66+
}
67+
""", SecurityAdvisory.class);
68+
69+
final Vulnerability vuln = converter.convert(advisory);
70+
71+
assertThat(vuln).isNotNull();
72+
assertThat(vuln.getEpssScore())
73+
.as("epssScore must hold the exploitation probability from the 'percentage' JSON field")
74+
.isNotNull();
75+
assertThat(vuln.getEpssScore().doubleValue()).isCloseTo(0.00514, offset(0.00001));
76+
assertThat(vuln.getEpssPercentile())
77+
.as("epssPercentile must hold the relative rank from the 'percentile' JSON field")
78+
.isNotNull();
79+
assertThat(vuln.getEpssPercentile().doubleValue()).isCloseTo(0.66009, offset(0.00001));
80+
}
81+
82+
@Test
83+
void testConvertEpssAbsent() throws Exception {
84+
final var advisory = jsonMapper.readValue(/* language=JSON */ """
85+
{
86+
"ghsaId": "GHSA-57j2-w4cx-62h2",
87+
"severity": "HIGH",
88+
"publishedAt": "2022-03-12T00:00:36Z",
89+
"updatedAt": "2024-03-15T00:24:56Z"
90+
}
91+
""", SecurityAdvisory.class);
92+
93+
final Vulnerability vuln = converter.convert(advisory);
94+
95+
assertThat(vuln).isNotNull();
96+
assertThat(vuln.getEpssScore()).isNull();
97+
assertThat(vuln.getEpssPercentile()).isNull();
98+
}
99+
100+
@Test
101+
void testConvertEpssPartialDataPercentileOnly() throws Exception {
102+
// Only the rank/percentile field is present — epssScore must remain null.
103+
final var advisory = jsonMapper.readValue(/* language=JSON */ """
104+
{
105+
"ghsaId": "GHSA-57j2-w4cx-62h2",
106+
"severity": "HIGH",
107+
"publishedAt": "2022-03-12T00:00:36Z",
108+
"updatedAt": "2024-03-15T00:24:56Z",
109+
"epss": {
110+
"percentile": 0.66009
111+
}
112+
}
113+
""", SecurityAdvisory.class);
114+
115+
final Vulnerability vuln = converter.convert(advisory);
116+
117+
assertThat(vuln).isNotNull();
118+
assertThat(vuln.getEpssScore()).isNull();
119+
assertThat(vuln.getEpssPercentile()).isNotNull();
120+
assertThat(vuln.getEpssPercentile().doubleValue()).isCloseTo(0.66009, offset(0.00001));
121+
}
122+
123+
@Test
124+
void testConvertWithdrawnAdvisoryReturnsNull() throws Exception {
125+
final var advisory = jsonMapper.readValue(/* language=JSON */ """
126+
{
127+
"ghsaId": "GHSA-57j2-w4cx-62h2",
128+
"severity": "HIGH",
129+
"publishedAt": "2022-03-12T00:00:00Z",
130+
"updatedAt": "2022-08-11T00:00:00Z",
131+
"withdrawnAt": "2023-01-01T00:00:00Z"
132+
}
133+
""", SecurityAdvisory.class);
134+
135+
assertThat(converter.convert(advisory)).isNull();
136+
}
137+
138+
@Test
139+
void testConvertSeverityMapping() throws Exception {
140+
for (final var entry : new Object[][]{
141+
{"LOW", Severity.LOW},
142+
{"MODERATE", Severity.MEDIUM},
143+
{"HIGH", Severity.HIGH},
144+
{"CRITICAL", Severity.CRITICAL},
145+
}) {
146+
final String ghsaSeverity = (String) entry[0];
147+
final Severity expected = (Severity) entry[1];
148+
149+
final var advisory = jsonMapper.readValue(/* language=JSON */ """
150+
{
151+
"ghsaId": "GHSA-57j2-w4cx-62h2",
152+
"severity": "%s",
153+
"publishedAt": "2022-03-12T00:00:00Z",
154+
"updatedAt": "2022-08-11T00:00:00Z"
155+
}
156+
""".formatted(ghsaSeverity), SecurityAdvisory.class);
157+
158+
assertThat(converter.convert(advisory).getSeverity())
159+
.as("severity for GHSA %s", ghsaSeverity)
160+
.isEqualTo(expected);
161+
}
162+
}
163+
}

0 commit comments

Comments
 (0)