Skip to content

Commit c7a2ff3

Browse files
committed
Improve VEX import performance
Addresses a few fundamental flaws of the VEX import functionality by: * Wrapping the entire procedure into a transaction, making it atomic and reducing the number of fsyncs the DB has to do. * Replacing N+1 query patterns with more efficient counterparts. * Memoizing lookups instead of re-computing / re-querying for every invocation. Note that there is still a lot more room for improvement, but these changes will already yield a noticeable improvement without us having to do a far-reaching refactor. Signed-off-by: nscuro <nscuro@protonmail.com>
1 parent 09d50b3 commit c7a2ff3

6 files changed

Lines changed: 311 additions & 98 deletions

File tree

src/main/java/org/dependencytrack/parser/cyclonedx/CycloneDXVexImporter.java

Lines changed: 146 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
import org.apache.commons.collections4.CollectionUtils;
2323
import org.cyclonedx.model.Bom;
2424
import org.cyclonedx.util.BomLink;
25-
import org.cyclonedx.util.ObjectLocator;
2625
import org.dependencytrack.model.Analysis;
2726
import org.dependencytrack.model.AnalysisJustification;
2827
import org.dependencytrack.model.AnalysisResponse;
@@ -33,11 +32,15 @@
3332
import org.dependencytrack.model.Vulnerability;
3433
import org.dependencytrack.parser.cyclonedx.util.ModelConverter;
3534
import org.dependencytrack.persistence.QueryManager;
36-
import org.dependencytrack.util.AnalysisCommentUtil;
3735

3836
import java.util.ArrayList;
37+
import java.util.Collections;
38+
import java.util.HashMap;
3939
import java.util.List;
40+
import java.util.Map;
41+
import java.util.Objects;
4042

43+
import static java.util.Objects.requireNonNullElse;
4144
import static org.apache.commons.lang3.StringUtils.isBlank;
4245
import static org.apache.commons.lang3.StringUtils.trimToNull;
4346

@@ -52,17 +55,21 @@ public void applyVex(final QueryManager qm, final Bom bom, final Project project
5255
LOGGER.info("The uploaded VEX does not contain any vulnerabilities; Skipping VEX import");
5356
return;
5457
}
55-
if (qm.getVulnerabilityCount(project, true) == 0) {
56-
LOGGER.info("The project %s does not have any vulnerabilities; Skipping VEX import".formatted(project));
57-
return;
58-
}
5958

6059
final List<org.cyclonedx.model.vulnerability.Vulnerability> vexVulns = getApplicableVexVulnerabilities(bom.getVulnerabilities());
6160
if (vexVulns.isEmpty()) {
6261
LOGGER.info("The uploaded VEX does not contain any applicable vulnerabilities; Skipping VEX import");
6362
return;
6463
}
6564

65+
if (!qm.hasVulnerabilities(project)) {
66+
LOGGER.info("The project %s does not have any vulnerabilities; Skipping VEX import".formatted(project));
67+
return;
68+
}
69+
70+
final Map<String, BomRefTarget> targetByBomRef = indexComponents(bom);
71+
final Map<String, List<Component>> componentsByBomRef = new HashMap<>();
72+
6673
for (final org.cyclonedx.model.vulnerability.Vulnerability vexVuln : vexVulns) {
6774
final Vulnerability dtVuln = qm.getVulnerabilityByVulnId(vexVuln.getSource().getName(), vexVuln.getId());
6875
if (dtVuln == null) {
@@ -73,31 +80,39 @@ public void applyVex(final QueryManager qm, final Bom bom, final Project project
7380
continue;
7481
}
7582

83+
List<Component> vulnerableComponents = null;
84+
7685
for (org.cyclonedx.model.vulnerability.Vulnerability.Affect affect : vexVuln.getAffects()) {
77-
final ObjectLocator ol = new ObjectLocator(bom, affect.getRef()).locate();
78-
if ((ol.found() && ol.isMetadataComponent()) || (!ol.found() && BomLink.isBomLink(affect.getRef()))) {
79-
// Affects the project itself
80-
List<Component> components = qm.getAllVulnerableComponents(project, dtVuln, true);
81-
for (final Component component : components) {
86+
final String affectedBomRef = affect.getRef();
87+
final BomRefTarget affectedBomRefTarget = affectedBomRef != null
88+
? targetByBomRef.get(affectedBomRef)
89+
: null;
90+
91+
final boolean isProjectScoped =
92+
(affectedBomRefTarget != null && affectedBomRefTarget.isMetadataComponent())
93+
|| (affectedBomRefTarget == null && affectedBomRef != null && BomLink.isBomLink(affectedBomRef));
94+
95+
if (isProjectScoped) {
96+
if (vulnerableComponents == null) {
97+
vulnerableComponents = qm.getAllVulnerableComponents(project, dtVuln);
98+
}
99+
for (final Component component : vulnerableComponents) {
82100
updateAnalysis(qm, component, dtVuln, vexVuln);
83101
}
84-
} else if (ol.found() && ol.isComponent()) {
85-
// Affects an individual component
86-
final org.cyclonedx.model.Component cdxComponent = (org.cyclonedx.model.Component) ol.getObject();
87-
final ComponentIdentity cid = new ComponentIdentity(cdxComponent);
88-
List<Component> components = qm.matchIdentity(project, cid);
102+
} else if (affectedBomRefTarget != null) {
103+
final List<Component> components = componentsByBomRef.computeIfAbsent(affectedBomRef, ignored -> {
104+
final var cid = new ComponentIdentity(affectedBomRefTarget.component());
105+
return qm.matchIdentity(project, cid);
106+
});
89107
for (final Component component : components) {
90108
updateAnalysis(qm, component, dtVuln, vexVuln);
91109
}
92-
} else if (ol.found() && ol.isService()) {
93-
// Affects an individual service
94-
// TODO add VEX support for services
95110
} else {
96111
LOGGER.warn("""
97-
Unable to locate affected element (metadata.component, components[].component, \
98-
or services[].service) based on the BOM reference %s. The vulnerability.affects[].ref \
112+
Unable to locate affected element (metadata.component or components[].component) \
113+
based on the BOM reference %s. The vulnerability.affects[].ref \
99114
node of %s/%s is not resolvable; Skipping it\
100-
""".formatted(affect.getRef(), vexVuln.getSource().getName(), vexVuln.getId()));
115+
""".formatted(affectedBomRef, vexVuln.getSource().getName(), vexVuln.getId()));
101116
}
102117
}
103118
}
@@ -106,16 +121,16 @@ Unable to locate affected element (metadata.component, components[].component, \
106121
private static List<org.cyclonedx.model.vulnerability.Vulnerability> getApplicableVexVulnerabilities(
107122
final List<org.cyclonedx.model.vulnerability.Vulnerability> vexVulns) {
108123
final var applicableVulns = new ArrayList<org.cyclonedx.model.vulnerability.Vulnerability>();
109-
for (final var vexVuln : vexVulns) {
110-
final int vexVulnPos = vexVulns.indexOf(vexVuln);
124+
for (int vexVulnPos = 0; vexVulnPos < vexVulns.size(); vexVulnPos++) {
125+
final var vexVuln = vexVulns.get(vexVulnPos);
111126
if (isBlank(vexVuln.getId()) || vexVuln.getSource() == null || isBlank(vexVuln.getSource().getName())) {
112127
LOGGER.warn("VEX vulnerability at position #%d does not have an ID and / or source; Skipping it".formatted(vexVulnPos));
113128
continue;
114129
}
115130

116131
final String vexVulnId = vexVuln.getId();
117132
final String vexVulnSource = vexVuln.getSource().getName();
118-
if (!Vulnerability.Source.isKnownSource(vexVuln.getSource().getName())) {
133+
if (!Vulnerability.Source.isKnownSource(vexVulnSource)) {
119134
LOGGER.warn("VEX vulnerability %s/%s at position #%d is from an unsupported source; Skipping it"
120135
.formatted(vexVulnSource, vexVulnId, vexVulnPos));
121136
continue;
@@ -137,38 +152,115 @@ private static List<org.cyclonedx.model.vulnerability.Vulnerability> getApplicab
137152
return applicableVulns;
138153
}
139154

140-
private static void updateAnalysis(final QueryManager qm, final Component component, final Vulnerability vuln,
155+
private record BomRefTarget(org.cyclonedx.model.Component component, boolean isMetadataComponent) {
156+
}
157+
158+
private static Map<String, BomRefTarget> indexComponents(Bom bom) {
159+
final Map<String, BomRefTarget> targetByBomRef = new HashMap<>();
160+
if (bom == null) {
161+
return targetByBomRef;
162+
}
163+
164+
if (bom.getMetadata() != null && bom.getMetadata().getComponent() != null) {
165+
indexComponents(List.of(bom.getMetadata().getComponent()), targetByBomRef, true);
166+
}
167+
168+
indexComponents(bom.getComponents(), targetByBomRef, false);
169+
return targetByBomRef;
170+
}
171+
172+
private static void indexComponents(
173+
List<org.cyclonedx.model.Component> components,
174+
Map<String, BomRefTarget> targetByBomRef,
175+
boolean metadataComponent) {
176+
if (components == null) {
177+
return;
178+
}
179+
180+
for (final var component : components) {
181+
if (component.getBomRef() != null) {
182+
targetByBomRef.putIfAbsent(
183+
component.getBomRef(),
184+
new BomRefTarget(component, metadataComponent));
185+
}
186+
187+
if (component.getComponents() != null && !component.getComponents().isEmpty()) {
188+
indexComponents(component.getComponents(), targetByBomRef, false);
189+
}
190+
}
191+
}
192+
193+
private static void updateAnalysis(final QueryManager qm, final Component component, final Vulnerability dtVuln,
141194
final org.cyclonedx.model.vulnerability.Vulnerability cdxVuln) {
142-
// The vulnerability object is detached, so refresh it.
143-
final Vulnerability refreshedVuln = qm.getObjectByUuid(Vulnerability.class, vuln.getUuid());
144-
Analysis analysis = qm.getAnalysis(component, refreshedVuln);
145-
AnalysisState analysisState = null;
146-
AnalysisJustification analysisJustification = null;
147-
String analysisDetails = null;
148-
AnalysisResponse analysisResponse = null;
195+
final org.cyclonedx.model.vulnerability.Vulnerability.Analysis cdxAnalysis = cdxVuln.getAnalysis();
196+
197+
final Analysis existing = qm.getAnalysis(component, dtVuln);
198+
final AnalysisState oldState = existing != null
199+
? requireNonNullElse(existing.getAnalysisState(), AnalysisState.NOT_SET)
200+
: AnalysisState.NOT_SET;
201+
final AnalysisJustification oldJustification = existing != null
202+
? requireNonNullElse(existing.getAnalysisJustification(), AnalysisJustification.NOT_SET)
203+
: AnalysisJustification.NOT_SET;
204+
final AnalysisResponse oldResponse = existing != null
205+
? requireNonNullElse(existing.getAnalysisResponse(), AnalysisResponse.NOT_SET)
206+
: AnalysisResponse.NOT_SET;
207+
final String oldDetails = existing != null
208+
? requireNonNullElse(existing.getAnalysisDetails(), "")
209+
: "";
210+
211+
AnalysisState newState = null;
149212
boolean suppress = false;
150-
if (analysis == null) {
151-
analysis = qm.makeAnalysis(component, refreshedVuln, AnalysisState.NOT_SET, null, null, null, null);
152-
}
153-
if (cdxVuln.getAnalysis().getState() != null) {
154-
analysisState = ModelConverter.convertCdxVulnAnalysisStateToDtAnalysisState(cdxVuln.getAnalysis().getState());
155-
suppress = (AnalysisState.FALSE_POSITIVE == analysisState || AnalysisState.NOT_AFFECTED == analysisState || AnalysisState.RESOLVED == analysisState);
156-
AnalysisCommentUtil.makeStateComment(qm, analysis, analysisState, COMMENTER);
157-
}
158-
if (cdxVuln.getAnalysis().getJustification() != null) {
159-
analysisJustification = ModelConverter.convertCdxVulnAnalysisJustificationToDtAnalysisJustification(cdxVuln.getAnalysis().getJustification());
160-
AnalysisCommentUtil.makeJustificationComment(qm, analysis, analysisJustification, COMMENTER);
161-
}
162-
if (trimToNull(cdxVuln.getAnalysis().getDetail()) != null) {
163-
analysisDetails = cdxVuln.getAnalysis().getDetail().trim();
164-
AnalysisCommentUtil.makeAnalysisDetailsComment(qm, analysis, cdxVuln.getAnalysis().getDetail().trim(), COMMENTER);
165-
}
166-
if (cdxVuln.getAnalysis().getResponses() != null) {
167-
for (org.cyclonedx.model.vulnerability.Vulnerability.Analysis.Response cdxRes : cdxVuln.getAnalysis().getResponses()) {
168-
analysisResponse = ModelConverter.convertCdxVulnAnalysisResponseToDtAnalysisResponse(cdxRes);
169-
AnalysisCommentUtil.makeAnalysisResponseComment(qm, analysis, analysisResponse, COMMENTER);
213+
if (cdxAnalysis.getState() != null) {
214+
newState = ModelConverter.convertCdxVulnAnalysisStateToDtAnalysisState(cdxAnalysis.getState());
215+
suppress = AnalysisState.FALSE_POSITIVE == newState
216+
|| AnalysisState.NOT_AFFECTED == newState
217+
|| AnalysisState.RESOLVED == newState;
218+
}
219+
220+
AnalysisJustification newJustification = null;
221+
if (cdxAnalysis.getJustification() != null) {
222+
newJustification = ModelConverter.convertCdxVulnAnalysisJustificationToDtAnalysisJustification(cdxAnalysis.getJustification());
223+
}
224+
225+
String newDetails = null;
226+
if (trimToNull(cdxAnalysis.getDetail()) != null) {
227+
newDetails = cdxAnalysis.getDetail().trim();
228+
}
229+
230+
AnalysisResponse newResponse = null;
231+
final List<AnalysisResponse> responseTrail;
232+
if (cdxAnalysis.getResponses() != null && !cdxAnalysis.getResponses().isEmpty()) {
233+
responseTrail = new ArrayList<>(cdxAnalysis.getResponses().size());
234+
for (var cdxRes : cdxAnalysis.getResponses()) {
235+
final AnalysisResponse response = ModelConverter.convertCdxVulnAnalysisResponseToDtAnalysisResponse(cdxRes);
236+
responseTrail.add(response);
237+
newResponse = response;
238+
}
239+
} else {
240+
responseTrail = Collections.emptyList();
241+
}
242+
243+
final Analysis updated;
244+
if (existing != null) {
245+
updated = qm.updateAnalysis(existing, newState, newJustification, newResponse, newDetails, suppress);
246+
} else {
247+
final AnalysisState createState = newState != null ? newState : AnalysisState.NOT_SET;
248+
updated = qm.makeAnalysis(component, dtVuln, createState, newJustification, newResponse, newDetails, suppress);
249+
}
250+
251+
if (newState != null && !Objects.equals(newState, oldState)) {
252+
qm.makeAnalysisComment(updated, "Analysis: %s → %s".formatted(oldState, newState), COMMENTER);
253+
}
254+
if (newJustification != null && !Objects.equals(newJustification, oldJustification)) {
255+
qm.makeAnalysisComment(updated, "Justification: %s → %s".formatted(oldJustification, newJustification), COMMENTER);
256+
}
257+
if (newDetails != null && !Objects.equals(newDetails, oldDetails)) {
258+
qm.makeAnalysisComment(updated, "Details: %s".formatted(newDetails), COMMENTER);
259+
}
260+
for (final AnalysisResponse response : responseTrail) {
261+
if (response != null && !Objects.equals(response, oldResponse)) {
262+
qm.makeAnalysisComment(updated, "Vendor Response: %s → %s".formatted(oldResponse, response), COMMENTER);
170263
}
171264
}
172-
qm.makeAnalysis(component, refreshedVuln, analysisState, analysisJustification, analysisResponse, analysisDetails, suppress);
173265
}
174266
}

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

Lines changed: 60 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@
4444
import java.util.Objects;
4545
import java.util.stream.Collectors;
4646

47+
import static org.dependencytrack.util.PersistenceUtil.assertPersistent;
48+
4749
public class FindingsQueryManager extends QueryManager implements IQueryManager {
4850

4951

@@ -183,35 +185,66 @@ public Analysis getAnalysis(Component component, Vulnerability vulnerability) {
183185
public Analysis makeAnalysis(Component component, Vulnerability vulnerability, AnalysisState analysisState,
184186
AnalysisJustification analysisJustification, AnalysisResponse analysisResponse,
185187
String analysisDetails, Boolean isSuppressed) {
186-
Analysis analysis = getAnalysis(component, vulnerability);
187-
if (analysis == null) {
188-
analysis = new Analysis();
189-
analysis.setComponent(component);
190-
analysis.setVulnerability(vulnerability);
191-
}
188+
return callInTransaction(() -> {
189+
Analysis analysis = getAnalysis(component, vulnerability);
190+
if (analysis == null) {
191+
analysis = new Analysis();
192+
analysis.setComponent(component);
193+
analysis.setVulnerability(vulnerability);
194+
}
192195

193-
// In case we're updating an existing analysis, setting any of the fields
194-
// to null will wipe them. That is not the expected behavior when an AnalysisRequest
195-
// has some fields unset (so they're null). If fields are not set, there shouldn't
196-
// be any modifications to the existing data.
197-
if (analysisState != null) {
198-
analysis.setAnalysisState(analysisState);
199-
}
200-
if (analysisJustification != null) {
201-
analysis.setAnalysisJustification(analysisJustification);
202-
}
203-
if (analysisResponse != null) {
204-
analysis.setAnalysisResponse(analysisResponse);
205-
}
206-
if (analysisDetails != null) {
207-
analysis.setAnalysisDetails(analysisDetails);
208-
}
209-
if (isSuppressed != null) {
210-
analysis.setSuppressed(isSuppressed);
211-
}
196+
// In case we're updating an existing analysis, setting any of the fields
197+
// to null will wipe them. That is not the expected behavior when an AnalysisRequest
198+
// has some fields unset (so they're null). If fields are not set, there shouldn't
199+
// be any modifications to the existing data.
200+
if (analysisState != null) {
201+
analysis.setAnalysisState(analysisState);
202+
}
203+
if (analysisJustification != null) {
204+
analysis.setAnalysisJustification(analysisJustification);
205+
}
206+
if (analysisResponse != null) {
207+
analysis.setAnalysisResponse(analysisResponse);
208+
}
209+
if (analysisDetails != null) {
210+
analysis.setAnalysisDetails(analysisDetails);
211+
}
212+
if (isSuppressed != null) {
213+
analysis.setSuppressed(isSuppressed);
214+
}
215+
216+
return persist(analysis);
217+
});
218+
}
219+
220+
@Override
221+
public Analysis updateAnalysis(
222+
Analysis analysis,
223+
AnalysisState analysisState,
224+
AnalysisJustification analysisJustification,
225+
AnalysisResponse analysisResponse,
226+
String analysisDetails,
227+
Boolean isSuppressed) {
228+
assertPersistent(analysis, "analysis must be persistent");
229+
return callInTransaction(() -> {
230+
if (analysisState != null) {
231+
analysis.setAnalysisState(analysisState);
232+
}
233+
if (analysisJustification != null) {
234+
analysis.setAnalysisJustification(analysisJustification);
235+
}
236+
if (analysisResponse != null) {
237+
analysis.setAnalysisResponse(analysisResponse);
238+
}
239+
if (analysisDetails != null) {
240+
analysis.setAnalysisDetails(analysisDetails);
241+
}
242+
if (isSuppressed != null) {
243+
analysis.setSuppressed(isSuppressed);
244+
}
212245

213-
analysis = persist(analysis);
214-
return getAnalysis(analysis.getComponent(), analysis.getVulnerability());
246+
return analysis;
247+
});
215248
}
216249

217250
/**

0 commit comments

Comments
 (0)