Skip to content

Commit 2e55930

Browse files
fix: remove stale vulnerability findings when analyzer no longer reports them
Signed-off-by: Arjav <arjavdongaonkar@gmail.com>
1 parent e4272bb commit 2e55930

File tree

4 files changed

+114
-4
lines changed

4 files changed

+114
-4
lines changed

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -841,6 +841,10 @@ void deleteFindingAttributions(Project project) {
841841
getVulnerabilityQueryManager().deleteFindingAttributions(project);
842842
}
843843

844+
public void reconcileFindingsForComponentAnalyzer(Component component, AnalyzerIdentity analyzerIdentity, Set<VulnIdAndSource> currentVulnIdAndSources) {
845+
getVulnerabilityQueryManager().reconcileFindingsForComponentAnalyzer(component, analyzerIdentity, currentVulnIdAndSources);
846+
}
847+
844848
public List<VulnerableSoftware> reconcileVulnerableSoftware(final Vulnerability vulnerability,
845849
final List<VulnerableSoftware> vsListOld,
846850
final List<VulnerableSoftware> vsList,

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
import java.util.HashMap;
5050
import java.util.List;
5151
import java.util.Map;
52+
import java.util.Set;
5253
import java.util.UUID;
5354
import java.util.function.Function;
5455
import java.util.stream.Collectors;
@@ -297,6 +298,36 @@ void deleteFindingAttributions(Project project) {
297298
query.deletePersistentAll(project);
298299
}
299300

301+
/**
302+
* Reconciles findings for a component from a specific analyzer by removing
303+
* any FindingAttributions that are no longer reported by the analyzer.
304+
*
305+
* @param component the component to reconcile findings for
306+
* @param analyzerIdentity the analyzer identity to scope reconciliation
307+
* @param currentVulnIdAndSources the set of VulnIdAndSource reported by the latest scan
308+
*/
309+
public void reconcileFindingsForComponentAnalyzer(Component component, AnalyzerIdentity analyzerIdentity, Set<VulnIdAndSource> currentVulnIdAndSources) {
310+
runInTransaction(() -> {
311+
final Query<FindingAttribution> query = pm.newQuery(FindingAttribution.class, "component == :component && analyzerIdentity == :analyzerIdentity");
312+
final List<FindingAttribution> existing = (List<FindingAttribution>) query.execute(component, analyzerIdentity);
313+
final Component persistentComponent = getObjectById(Component.class, component.getId());
314+
boolean changed = false;
315+
for (final FindingAttribution fa : existing) {
316+
final Vulnerability vuln = getObjectById(Vulnerability.class, fa.getVulnerability().getId());
317+
final VulnIdAndSource vid = new VulnIdAndSource(vuln.getVulnId(), vuln.getSource());
318+
if (!currentVulnIdAndSources.contains(vid)) {
319+
persistentComponent.removeVulnerability(vuln);
320+
delete(fa);
321+
changed = true;
322+
}
323+
}
324+
if (changed) {
325+
persist(persistentComponent);
326+
}
327+
});
328+
}
329+
330+
300331
/**
301332
* Determines if a Component is affected by a specific Vulnerability by checking
302333
* {@link Vulnerability#getSource()} and {@link Vulnerability#getVulnId()}.

src/main/java/org/dependencytrack/tasks/scanners/TrivyAnalysisTask.java

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
import org.dependencytrack.model.Component;
4747
import org.dependencytrack.model.ComponentProperty;
4848
import org.dependencytrack.model.ConfigPropertyConstants;
49+
import org.dependencytrack.model.VulnIdAndSource;
4950
import org.dependencytrack.model.Vulnerability;
5051
import org.dependencytrack.model.VulnerabilityAnalysisLevel;
5152
import org.dependencytrack.parser.trivy.TrivyParser;
@@ -71,9 +72,11 @@
7172
import java.util.Collection;
7273
import java.util.Collections;
7374
import java.util.HashMap;
75+
import java.util.HashSet;
7476
import java.util.List;
7577
import java.util.Map;
7678
import java.util.Optional;
79+
import java.util.Set;
7780

7881
import static java.util.Objects.requireNonNullElseGet;
7982
import static org.dependencytrack.common.ConfigKey.TRIVY_RETRY_BACKOFF_INITIAL_DURATION_MS;
@@ -361,17 +364,18 @@ private void handleResults(final Map<String, Component> componentByPurl, final A
361364
}
362365
}
363366

364-
for (final Map.Entry<Component, List<trivy.proto.common.Vulnerability>> entry : vulnsByComponent.entrySet()) {
365-
final Component component = entry.getKey();
366-
final List<trivy.proto.common.Vulnerability> vulns = entry.getValue();
367+
// Ensure we call handle() for all components that were submitted for analysis,
368+
// even if Trivy reported no vulnerabilities for them (so reconciliation can remove stale findings).
369+
for (final Component component : componentByPurl.values()) {
370+
final List<trivy.proto.common.Vulnerability> vulns = vulnsByComponent.getOrDefault(component, Collections.emptyList());
367371
handle(component, vulns);
368372
}
369373
}
370374

371375
private ArrayList<Result> analyzeBlob(final Collection<BlobInfo> blobs) {
372376
final var output = new ArrayList<Result>();
373377

374-
for (final BlobInfo info : blobs) {
378+
for (final BlobInfo info : blobs) {
375379
final PutBlobRequest putBlobRequest = PutBlobRequest.newBuilder()
376380
.setBlobInfo(info)
377381
.setDiffId("sha256:" + DigestUtils.sha256Hex(java.util.UUID.randomUUID().toString()))
@@ -492,9 +496,13 @@ private void handle(final Component component, final Collection<trivy.proto.comm
492496
}
493497

494498
boolean didCreateVulns = false;
499+
final Set<VulnIdAndSource> reportedVulns = new HashSet<>();
495500
for (final trivy.proto.common.Vulnerability trivyVuln : trivyVulns) {
496501
final Vulnerability parsedVulnerability = trivyParser.parse(trivyVuln);
497502

503+
// track reported vulnerabilities so we can reconcile stale findings
504+
reportedVulns.add(new VulnIdAndSource(parsedVulnerability.getVulnId(), parsedVulnerability.getSource()));
505+
498506
Vulnerability vulnerability = qm.getVulnerabilityByVulnId(parsedVulnerability.getSource(), parsedVulnerability.getVulnId());
499507
if (vulnerability == null) {
500508
LOGGER.debug("Creating unavailable vulnerability:" + parsedVulnerability.getSource() + " - " + parsedVulnerability.getVulnId());
@@ -507,6 +515,9 @@ private void handle(final Component component, final Collection<trivy.proto.comm
507515
qm.addVulnerability(vulnerability, persistentComponent, this.getAnalyzerIdentity());
508516
}
509517

518+
// Reconcile findings for this component and analyzer: remove any attributions not reported in this scan
519+
qm.reconcileFindingsForComponentAnalyzer(persistentComponent, this.getAnalyzerIdentity(), reportedVulns);
520+
510521
if (didCreateVulns) {
511522
Event.dispatch(new IndexEvent(IndexEvent.Action.COMMIT, Vulnerability.class));
512523
}

src/test/java/org/dependencytrack/tasks/scanners/TrivyAnalysisTaskTest.java

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,70 @@ void testAnalyzeRespectsConfiguredScanningOption() throws InvalidProtocolBufferE
490490
assertThat(scanRequest.getOptions().getPkgTypes(0)).isEqualTo("library");
491491
}
492492

493+
@Test
494+
void testReconcilesAndRemovesStaleTrivyFindings() {
495+
stubFor(post(urlPathEqualTo("/twirp/trivy.cache.v1.Cache/PutBlob"))
496+
.willReturn(aResponse()
497+
.withStatus(200)
498+
.withHeader("Content-Type", "application/protobuf")));
499+
500+
stubFor(post(urlPathEqualTo("/twirp/trivy.scanner.v1.Scanner/Scan"))
501+
.willReturn(aResponse()
502+
.withStatus(200)
503+
.withHeader("Content-Type", "application/protobuf")
504+
.withBody(ScanResponse.newBuilder()
505+
.addResults(Result.newBuilder()
506+
.setClass_("lang-pkgs")
507+
.setTarget("java")
508+
.setType("jar")
509+
.build())
510+
.build()
511+
.toByteArray())));
512+
513+
stubFor(post(urlPathEqualTo("/twirp/trivy.cache.v1.Cache/DeleteBlobs"))
514+
.willReturn(aResponse()
515+
.withStatus(200)
516+
.withHeader("Content-Type", "application/json")));
517+
518+
var project = new Project();
519+
project.setName("acme-app");
520+
project = qm.createProject(project, null, false);
521+
522+
var component = new Component();
523+
component.setProject(project);
524+
component.setGroup("com.example");
525+
component.setName("openssl-provider");
526+
component.setVersion("3.5.4-1~deb13u1");
527+
component.setPurl("pkg:maven/com.example/openssl-provider@3.5.4-1~deb13u1?foo=bar#baz");
528+
component = qm.createComponent(component, false);
529+
530+
// previously Trivy reported CVE-2025-15467; create a vulnerability and attribute it to the component
531+
var vuln = new Vulnerability();
532+
vuln.setVulnId("CVE-2025-15467");
533+
vuln.setSource(Vulnerability.Source.NVD.name());
534+
vuln.setTitle("reverted vuln");
535+
vuln = qm.createVulnerability(vuln, false);
536+
537+
qm.addVulnerability(vuln, component, org.dependencytrack.tasks.scanners.AnalyzerIdentity.TRIVY_ANALYZER);
538+
539+
assertThat(qm.getAllVulnerabilities(component)).hasSize(1);
540+
541+
// run analysis again; Trivy reports no vulnerabilities for this component
542+
new TrivyAnalysisTask().inform(new TrivyAnalysisEvent(
543+
List.of(component), VulnerabilityAnalysisLevel.BOM_UPLOAD_ANALYSIS));
544+
545+
// stale finding should be removed
546+
assertThat(qm.getAllVulnerabilities(component)).isEmpty();
547+
548+
assertThat(NOTIFICATIONS).satisfiesExactly(
549+
notification ->
550+
assertThat(notification.getGroup()).isEqualTo(NotificationGroup.PROJECT_CREATED.name())
551+
);
552+
verify(postRequestedFor(urlPathEqualTo("/twirp/trivy.cache.v1.Cache/PutBlob")));
553+
verify(postRequestedFor(urlPathEqualTo("/twirp/trivy.scanner.v1.Scanner/Scan")));
554+
verify(postRequestedFor(urlPathEqualTo("/twirp/trivy.cache.v1.Cache/DeleteBlobs")));
555+
}
556+
493557
private static final ConcurrentLinkedQueue<Notification> NOTIFICATIONS = new ConcurrentLinkedQueue<>();
494558

495559
public static class NotificationSubscriber implements Subscriber {

0 commit comments

Comments
 (0)