diff --git a/src/main/java/org/dependencytrack/parser/cyclonedx/CycloneDXVexImporter.java b/src/main/java/org/dependencytrack/parser/cyclonedx/CycloneDXVexImporter.java index e7729e55b8..252d10436f 100644 --- a/src/main/java/org/dependencytrack/parser/cyclonedx/CycloneDXVexImporter.java +++ b/src/main/java/org/dependencytrack/parser/cyclonedx/CycloneDXVexImporter.java @@ -22,7 +22,6 @@ import org.apache.commons.collections4.CollectionUtils; import org.cyclonedx.model.Bom; import org.cyclonedx.util.BomLink; -import org.cyclonedx.util.ObjectLocator; import org.dependencytrack.model.Analysis; import org.dependencytrack.model.AnalysisJustification; import org.dependencytrack.model.AnalysisResponse; @@ -33,11 +32,15 @@ import org.dependencytrack.model.Vulnerability; import org.dependencytrack.parser.cyclonedx.util.ModelConverter; import org.dependencytrack.persistence.QueryManager; -import org.dependencytrack.util.AnalysisCommentUtil; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Objects; +import static java.util.Objects.requireNonNullElse; import static org.apache.commons.lang3.StringUtils.isBlank; import static org.apache.commons.lang3.StringUtils.trimToNull; @@ -52,10 +55,6 @@ public void applyVex(final QueryManager qm, final Bom bom, final Project project LOGGER.info("The uploaded VEX does not contain any vulnerabilities; Skipping VEX import"); return; } - if (qm.getVulnerabilityCount(project, true) == 0) { - LOGGER.info("The project %s does not have any vulnerabilities; Skipping VEX import".formatted(project)); - return; - } final List vexVulns = getApplicableVexVulnerabilities(bom.getVulnerabilities()); if (vexVulns.isEmpty()) { @@ -63,6 +62,14 @@ public void applyVex(final QueryManager qm, final Bom bom, final Project project return; } + if (!qm.hasVulnerabilities(project)) { + LOGGER.info("The project %s does not have any vulnerabilities; Skipping VEX import".formatted(project)); + return; + } + + final Map targetByBomRef = indexComponents(bom); + final Map> componentsByBomRef = new HashMap<>(); + for (final org.cyclonedx.model.vulnerability.Vulnerability vexVuln : vexVulns) { final Vulnerability dtVuln = qm.getVulnerabilityByVulnId(vexVuln.getSource().getName(), vexVuln.getId()); if (dtVuln == null) { @@ -73,31 +80,39 @@ public void applyVex(final QueryManager qm, final Bom bom, final Project project continue; } + List vulnerableComponents = null; + for (org.cyclonedx.model.vulnerability.Vulnerability.Affect affect : vexVuln.getAffects()) { - final ObjectLocator ol = new ObjectLocator(bom, affect.getRef()).locate(); - if ((ol.found() && ol.isMetadataComponent()) || (!ol.found() && BomLink.isBomLink(affect.getRef()))) { - // Affects the project itself - List components = qm.getAllVulnerableComponents(project, dtVuln, true); - for (final Component component : components) { + final String affectedBomRef = affect.getRef(); + final BomRefTarget affectedBomRefTarget = affectedBomRef != null + ? targetByBomRef.get(affectedBomRef) + : null; + + final boolean isProjectScoped = + (affectedBomRefTarget != null && affectedBomRefTarget.isMetadataComponent()) + || (affectedBomRefTarget == null && affectedBomRef != null && BomLink.isBomLink(affectedBomRef)); + + if (isProjectScoped) { + if (vulnerableComponents == null) { + vulnerableComponents = qm.getAllVulnerableComponents(project, dtVuln); + } + for (final Component component : vulnerableComponents) { updateAnalysis(qm, component, dtVuln, vexVuln); } - } else if (ol.found() && ol.isComponent()) { - // Affects an individual component - final org.cyclonedx.model.Component cdxComponent = (org.cyclonedx.model.Component) ol.getObject(); - final ComponentIdentity cid = new ComponentIdentity(cdxComponent); - List components = qm.matchIdentity(project, cid); + } else if (affectedBomRefTarget != null) { + final List components = componentsByBomRef.computeIfAbsent(affectedBomRef, ignored -> { + final var cid = new ComponentIdentity(affectedBomRefTarget.component()); + return qm.matchIdentity(project, cid); + }); for (final Component component : components) { updateAnalysis(qm, component, dtVuln, vexVuln); } - } else if (ol.found() && ol.isService()) { - // Affects an individual service - // TODO add VEX support for services } else { LOGGER.warn(""" - Unable to locate affected element (metadata.component, components[].component, \ - or services[].service) based on the BOM reference %s. The vulnerability.affects[].ref \ + Unable to locate affected element (metadata.component or components[].component) \ + based on the BOM reference %s. The vulnerability.affects[].ref \ node of %s/%s is not resolvable; Skipping it\ - """.formatted(affect.getRef(), vexVuln.getSource().getName(), vexVuln.getId())); + """.formatted(affectedBomRef, vexVuln.getSource().getName(), vexVuln.getId())); } } } @@ -106,8 +121,8 @@ Unable to locate affected element (metadata.component, components[].component, \ private static List getApplicableVexVulnerabilities( final List vexVulns) { final var applicableVulns = new ArrayList(); - for (final var vexVuln : vexVulns) { - final int vexVulnPos = vexVulns.indexOf(vexVuln); + for (int vexVulnPos = 0; vexVulnPos < vexVulns.size(); vexVulnPos++) { + final var vexVuln = vexVulns.get(vexVulnPos); if (isBlank(vexVuln.getId()) || vexVuln.getSource() == null || isBlank(vexVuln.getSource().getName())) { LOGGER.warn("VEX vulnerability at position #%d does not have an ID and / or source; Skipping it".formatted(vexVulnPos)); continue; @@ -115,7 +130,7 @@ private static List getApplicab final String vexVulnId = vexVuln.getId(); final String vexVulnSource = vexVuln.getSource().getName(); - if (!Vulnerability.Source.isKnownSource(vexVuln.getSource().getName())) { + if (!Vulnerability.Source.isKnownSource(vexVulnSource)) { LOGGER.warn("VEX vulnerability %s/%s at position #%d is from an unsupported source; Skipping it" .formatted(vexVulnSource, vexVulnId, vexVulnPos)); continue; @@ -137,38 +152,115 @@ private static List getApplicab return applicableVulns; } - private static void updateAnalysis(final QueryManager qm, final Component component, final Vulnerability vuln, + private record BomRefTarget(org.cyclonedx.model.Component component, boolean isMetadataComponent) { + } + + private static Map indexComponents(Bom bom) { + final Map targetByBomRef = new HashMap<>(); + if (bom == null) { + return targetByBomRef; + } + + if (bom.getMetadata() != null && bom.getMetadata().getComponent() != null) { + indexComponents(List.of(bom.getMetadata().getComponent()), targetByBomRef, true); + } + + indexComponents(bom.getComponents(), targetByBomRef, false); + return targetByBomRef; + } + + private static void indexComponents( + List components, + Map targetByBomRef, + boolean metadataComponent) { + if (components == null) { + return; + } + + for (final var component : components) { + if (component.getBomRef() != null) { + targetByBomRef.putIfAbsent( + component.getBomRef(), + new BomRefTarget(component, metadataComponent)); + } + + if (component.getComponents() != null && !component.getComponents().isEmpty()) { + indexComponents(component.getComponents(), targetByBomRef, false); + } + } + } + + private static void updateAnalysis(final QueryManager qm, final Component component, final Vulnerability dtVuln, final org.cyclonedx.model.vulnerability.Vulnerability cdxVuln) { - // The vulnerability object is detached, so refresh it. - final Vulnerability refreshedVuln = qm.getObjectByUuid(Vulnerability.class, vuln.getUuid()); - Analysis analysis = qm.getAnalysis(component, refreshedVuln); - AnalysisState analysisState = null; - AnalysisJustification analysisJustification = null; - String analysisDetails = null; - AnalysisResponse analysisResponse = null; + final org.cyclonedx.model.vulnerability.Vulnerability.Analysis cdxAnalysis = cdxVuln.getAnalysis(); + + final Analysis existing = qm.getAnalysis(component, dtVuln); + final AnalysisState oldState = existing != null + ? requireNonNullElse(existing.getAnalysisState(), AnalysisState.NOT_SET) + : AnalysisState.NOT_SET; + final AnalysisJustification oldJustification = existing != null + ? requireNonNullElse(existing.getAnalysisJustification(), AnalysisJustification.NOT_SET) + : AnalysisJustification.NOT_SET; + final AnalysisResponse oldResponse = existing != null + ? requireNonNullElse(existing.getAnalysisResponse(), AnalysisResponse.NOT_SET) + : AnalysisResponse.NOT_SET; + final String oldDetails = existing != null + ? requireNonNullElse(existing.getAnalysisDetails(), "") + : ""; + + AnalysisState newState = null; boolean suppress = false; - if (analysis == null) { - analysis = qm.makeAnalysis(component, refreshedVuln, AnalysisState.NOT_SET, null, null, null, null); - } - if (cdxVuln.getAnalysis().getState() != null) { - analysisState = ModelConverter.convertCdxVulnAnalysisStateToDtAnalysisState(cdxVuln.getAnalysis().getState()); - suppress = (AnalysisState.FALSE_POSITIVE == analysisState || AnalysisState.NOT_AFFECTED == analysisState || AnalysisState.RESOLVED == analysisState); - AnalysisCommentUtil.makeStateComment(qm, analysis, analysisState, COMMENTER); - } - if (cdxVuln.getAnalysis().getJustification() != null) { - analysisJustification = ModelConverter.convertCdxVulnAnalysisJustificationToDtAnalysisJustification(cdxVuln.getAnalysis().getJustification()); - AnalysisCommentUtil.makeJustificationComment(qm, analysis, analysisJustification, COMMENTER); - } - if (trimToNull(cdxVuln.getAnalysis().getDetail()) != null) { - analysisDetails = cdxVuln.getAnalysis().getDetail().trim(); - AnalysisCommentUtil.makeAnalysisDetailsComment(qm, analysis, cdxVuln.getAnalysis().getDetail().trim(), COMMENTER); - } - if (cdxVuln.getAnalysis().getResponses() != null) { - for (org.cyclonedx.model.vulnerability.Vulnerability.Analysis.Response cdxRes : cdxVuln.getAnalysis().getResponses()) { - analysisResponse = ModelConverter.convertCdxVulnAnalysisResponseToDtAnalysisResponse(cdxRes); - AnalysisCommentUtil.makeAnalysisResponseComment(qm, analysis, analysisResponse, COMMENTER); + if (cdxAnalysis.getState() != null) { + newState = ModelConverter.convertCdxVulnAnalysisStateToDtAnalysisState(cdxAnalysis.getState()); + suppress = AnalysisState.FALSE_POSITIVE == newState + || AnalysisState.NOT_AFFECTED == newState + || AnalysisState.RESOLVED == newState; + } + + AnalysisJustification newJustification = null; + if (cdxAnalysis.getJustification() != null) { + newJustification = ModelConverter.convertCdxVulnAnalysisJustificationToDtAnalysisJustification(cdxAnalysis.getJustification()); + } + + String newDetails = null; + if (trimToNull(cdxAnalysis.getDetail()) != null) { + newDetails = cdxAnalysis.getDetail().trim(); + } + + AnalysisResponse newResponse = null; + final List responseTrail; + if (cdxAnalysis.getResponses() != null && !cdxAnalysis.getResponses().isEmpty()) { + responseTrail = new ArrayList<>(cdxAnalysis.getResponses().size()); + for (var cdxRes : cdxAnalysis.getResponses()) { + final AnalysisResponse response = ModelConverter.convertCdxVulnAnalysisResponseToDtAnalysisResponse(cdxRes); + responseTrail.add(response); + newResponse = response; + } + } else { + responseTrail = Collections.emptyList(); + } + + final Analysis updated; + if (existing != null) { + updated = qm.updateAnalysis(existing, newState, newJustification, newResponse, newDetails, suppress); + } else { + final AnalysisState createState = newState != null ? newState : AnalysisState.NOT_SET; + updated = qm.makeAnalysis(component, dtVuln, createState, newJustification, newResponse, newDetails, suppress); + } + + if (newState != null && !Objects.equals(newState, oldState)) { + qm.makeAnalysisComment(updated, "Analysis: %s → %s".formatted(oldState, newState), COMMENTER); + } + if (newJustification != null && !Objects.equals(newJustification, oldJustification)) { + qm.makeAnalysisComment(updated, "Justification: %s → %s".formatted(oldJustification, newJustification), COMMENTER); + } + if (newDetails != null && !Objects.equals(newDetails, oldDetails)) { + qm.makeAnalysisComment(updated, "Details: %s".formatted(newDetails), COMMENTER); + } + for (final AnalysisResponse response : responseTrail) { + if (response != null && !Objects.equals(response, oldResponse)) { + qm.makeAnalysisComment(updated, "Vendor Response: %s → %s".formatted(oldResponse, response), COMMENTER); } } - qm.makeAnalysis(component, refreshedVuln, analysisState, analysisJustification, analysisResponse, analysisDetails, suppress); } } diff --git a/src/main/java/org/dependencytrack/persistence/FindingsQueryManager.java b/src/main/java/org/dependencytrack/persistence/FindingsQueryManager.java index d06392ff55..bdf5e3f300 100644 --- a/src/main/java/org/dependencytrack/persistence/FindingsQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/FindingsQueryManager.java @@ -44,6 +44,8 @@ import java.util.Objects; import java.util.stream.Collectors; +import static org.dependencytrack.util.PersistenceUtil.assertPersistent; + public class FindingsQueryManager extends QueryManager implements IQueryManager { @@ -183,35 +185,66 @@ public Analysis getAnalysis(Component component, Vulnerability vulnerability) { public Analysis makeAnalysis(Component component, Vulnerability vulnerability, AnalysisState analysisState, AnalysisJustification analysisJustification, AnalysisResponse analysisResponse, String analysisDetails, Boolean isSuppressed) { - Analysis analysis = getAnalysis(component, vulnerability); - if (analysis == null) { - analysis = new Analysis(); - analysis.setComponent(component); - analysis.setVulnerability(vulnerability); - } + return callInTransaction(() -> { + Analysis analysis = getAnalysis(component, vulnerability); + if (analysis == null) { + analysis = new Analysis(); + analysis.setComponent(component); + analysis.setVulnerability(vulnerability); + } - // In case we're updating an existing analysis, setting any of the fields - // to null will wipe them. That is not the expected behavior when an AnalysisRequest - // has some fields unset (so they're null). If fields are not set, there shouldn't - // be any modifications to the existing data. - if (analysisState != null) { - analysis.setAnalysisState(analysisState); - } - if (analysisJustification != null) { - analysis.setAnalysisJustification(analysisJustification); - } - if (analysisResponse != null) { - analysis.setAnalysisResponse(analysisResponse); - } - if (analysisDetails != null) { - analysis.setAnalysisDetails(analysisDetails); - } - if (isSuppressed != null) { - analysis.setSuppressed(isSuppressed); - } + // In case we're updating an existing analysis, setting any of the fields + // to null will wipe them. That is not the expected behavior when an AnalysisRequest + // has some fields unset (so they're null). If fields are not set, there shouldn't + // be any modifications to the existing data. + if (analysisState != null) { + analysis.setAnalysisState(analysisState); + } + if (analysisJustification != null) { + analysis.setAnalysisJustification(analysisJustification); + } + if (analysisResponse != null) { + analysis.setAnalysisResponse(analysisResponse); + } + if (analysisDetails != null) { + analysis.setAnalysisDetails(analysisDetails); + } + if (isSuppressed != null) { + analysis.setSuppressed(isSuppressed); + } + + return persist(analysis); + }); + } + + @Override + public Analysis updateAnalysis( + Analysis analysis, + AnalysisState analysisState, + AnalysisJustification analysisJustification, + AnalysisResponse analysisResponse, + String analysisDetails, + Boolean isSuppressed) { + assertPersistent(analysis, "analysis must be persistent"); + return callInTransaction(() -> { + if (analysisState != null) { + analysis.setAnalysisState(analysisState); + } + if (analysisJustification != null) { + analysis.setAnalysisJustification(analysisJustification); + } + if (analysisResponse != null) { + analysis.setAnalysisResponse(analysisResponse); + } + if (analysisDetails != null) { + analysis.setAnalysisDetails(analysisDetails); + } + if (isSuppressed != null) { + analysis.setSuppressed(isSuppressed); + } - analysis = persist(analysis); - return getAnalysis(analysis.getComponent(), analysis.getVulnerability()); + return analysis; + }); } /** diff --git a/src/main/java/org/dependencytrack/persistence/QueryManager.java b/src/main/java/org/dependencytrack/persistence/QueryManager.java index 1cc99165ae..c45ab82470 100644 --- a/src/main/java/org/dependencytrack/persistence/QueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/QueryManager.java @@ -905,6 +905,10 @@ public boolean hasAffectedVersionAttribution(final Vulnerability vulnerability, return getVulnerabilityQueryManager().hasAffectedVersionAttribution(vulnerability, vulnerableSoftware, source); } + public boolean hasVulnerabilities(final Project project) { + return getVulnerabilityQueryManager().hasVulnerabilities(project); + } + public void synchronizeVulnerableSoftware( final Vulnerability persistentVuln, final List vsList, @@ -1064,8 +1068,8 @@ public PaginatedResult getVulnerabilities(Component component, boolean includeSu return getVulnerabilityQueryManager().getVulnerabilities(component, includeSuppressed); } - public List getAllVulnerableComponents(Project project, Vulnerability vulnerability, boolean includeSuppressed) { - return getVulnerabilityQueryManager().getAllVulnerableComponents(project, vulnerability, includeSuppressed); + public List getAllVulnerableComponents(Project project, Vulnerability vulnerability) { + return getVulnerabilityQueryManager().getAllVulnerableComponents(project, vulnerability); } public List getAllVulnerabilities(Component component) { @@ -1146,6 +1150,17 @@ public Analysis makeAnalysis(Component component, Vulnerability vulnerability, A return getFindingsQueryManager().makeAnalysis(component, vulnerability, analysisState, analysisJustification, analysisResponse, analysisDetails, isSuppressed); } + public Analysis updateAnalysis( + Analysis analysis, + AnalysisState analysisState, + AnalysisJustification analysisJustification, + AnalysisResponse analysisResponse, + String analysisDetails, + Boolean isSuppressed) { + return getFindingsQueryManager().updateAnalysis( + analysis, analysisState, analysisJustification, analysisResponse, analysisDetails, isSuppressed); + } + public AnalysisComment makeAnalysisComment(Analysis analysis, String comment, String commenter) { return getFindingsQueryManager().makeAnalysisComment(analysis, comment, commenter); } diff --git a/src/main/java/org/dependencytrack/persistence/VulnerabilityQueryManager.java b/src/main/java/org/dependencytrack/persistence/VulnerabilityQueryManager.java index 083db0a96b..46f3132710 100644 --- a/src/main/java/org/dependencytrack/persistence/VulnerabilityQueryManager.java +++ b/src/main/java/org/dependencytrack/persistence/VulnerabilityQueryManager.java @@ -435,19 +435,12 @@ public List getAllVulnerabilities(Component component, boolean in * @param vulnerability the vulnerability to query on * @return a List of Component objects */ - public List getAllVulnerableComponents(Project project, Vulnerability vulnerability, boolean includeSuppressed) { - final List components = new ArrayList<>(); - for (final Component component: getAllComponents(project)) { - final Collection componentVulns = pm.detachCopyAll( - getAllVulnerabilities(component, includeSuppressed) - ); - for (final Vulnerability componentVuln: componentVulns) { - if (componentVuln.getUuid() == vulnerability.getUuid()) { - components.add(component); - } - } - } - return components; + public List getAllVulnerableComponents(Project project, Vulnerability vulnerability) { + final Query query = pm.newQuery(Component.class, """ + project == :project && vulnerabilities.contains(:vulnerability) + """); + query.setParameters(project, vulnerability); + return executeAndCloseList(query); } /** @@ -1074,4 +1067,19 @@ public boolean hasAffectedVersionAttribution(final Vulnerability vulnerability, return !executeAndCloseResultList(query, Long.class).isEmpty(); } + /** + * Returns whether the given {@link Project} has at least one {@link Component} linked to any + * {@link Vulnerability} (suppressed or not). Equivalent to a {@code SELECT EXISTS} — single + * bounded round-trip via {@code setRange(0, 1)} on the join table. + */ + @Override + public boolean hasVulnerabilities(final Project project) { + final Query query = pm.newQuery( + Component.class, "project == :project && !vulnerabilities.isEmpty()"); + query.setParameters(project); + query.setRange(0, 1); + query.setResult("id"); + return !executeAndCloseResultList(query, Long.class).isEmpty(); + } + } diff --git a/src/main/java/org/dependencytrack/tasks/VexUploadProcessingTask.java b/src/main/java/org/dependencytrack/tasks/VexUploadProcessingTask.java index 2776aa5a74..e582b0f20d 100644 --- a/src/main/java/org/dependencytrack/tasks/VexUploadProcessingTask.java +++ b/src/main/java/org/dependencytrack/tasks/VexUploadProcessingTask.java @@ -29,7 +29,6 @@ import org.dependencytrack.model.ConfigPropertyConstants; import org.dependencytrack.model.Project; import org.dependencytrack.model.Vex; -import org.dependencytrack.model.Vulnerability; import org.dependencytrack.notification.NotificationConstants; import org.dependencytrack.notification.NotificationGroup; import org.dependencytrack.notification.NotificationScope; @@ -37,12 +36,12 @@ import org.dependencytrack.parser.cyclonedx.CycloneDXVexImporter; import org.dependencytrack.persistence.QueryManager; import org.dependencytrack.util.CompressUtil; -import static org.dependencytrack.common.MdcKeys.MDC_PROJECT_UUID; import org.slf4j.MDC; import java.util.Base64; import java.util.Date; -import java.util.List; + +import static org.dependencytrack.common.MdcKeys.MDC_PROJECT_UUID; /** * Subscriber task that performs processing of VEX when it is uploaded. @@ -58,66 +57,63 @@ public class VexUploadProcessingTask implements Subscriber { * {@inheritDoc} */ public void inform(final Event e) { - if (e instanceof VexUploadEvent) { - final VexUploadEvent event = (VexUploadEvent) e; + if (e instanceof final VexUploadEvent event) { final byte[] vexBytes = CompressUtil.optionallyDecompress(event.getVex()); - try(final QueryManager qm = new QueryManager()) { + try (final QueryManager qm = new QueryManager()) { final Project project = qm.getObjectByUuid(Project.class, event.getProjectUuid()); - try (var mdcProjectUuid = MDC.putCloseable(MDC_PROJECT_UUID, project.getUuid().toString())) { - final List vulnerabilities; + try (var ignoredMdcProjectUuid = MDC.putCloseable(MDC_PROJECT_UUID, project.getUuid().toString())) { + final Vex.Format vexFormat; + final String vexSpecVersion; + final Integer vexVersion; + final String serialNumber; + org.cyclonedx.model.Bom cycloneDxBom; - // Holds a list of all Components that are existing dependencies of the specified project - final List existingProjectVulnerabilities = qm.getVulnerabilities(project, true); - final Vex.Format vexFormat; - final String vexSpecVersion; - final Integer vexVersion; - final String serialNumber; - org.cyclonedx.model.Bom cycloneDxBom = null; - if (BomParserFactory.looksLikeCycloneDX(vexBytes)) { - if (qm.isEnabled(ConfigPropertyConstants.ACCEPT_ARTIFACT_CYCLONEDX)) { - LOGGER.info("Processing CycloneDX VEX uploaded."); - vexFormat = Vex.Format.CYCLONEDX; - final Parser parser = BomParserFactory.createParser(vexBytes); - cycloneDxBom = parser.parse(vexBytes); - vexSpecVersion = cycloneDxBom.getSpecVersion(); - vexVersion = cycloneDxBom.getVersion(); - serialNumber = cycloneDxBom.getSerialNumber(); - final CycloneDXVexImporter vexImporter = new CycloneDXVexImporter(); - vexImporter.applyVex(qm, cycloneDxBom, project); - LOGGER.info("Completed processing of CycloneDX VEX."); + if (BomParserFactory.looksLikeCycloneDX(vexBytes)) { + if (qm.isEnabled(ConfigPropertyConstants.ACCEPT_ARTIFACT_CYCLONEDX)) { + LOGGER.info("Processing CycloneDX VEX uploaded."); + vexFormat = Vex.Format.CYCLONEDX; + final Parser parser = BomParserFactory.createParser(vexBytes); + cycloneDxBom = parser.parse(vexBytes); + vexSpecVersion = cycloneDxBom.getSpecVersion(); + vexVersion = cycloneDxBom.getVersion(); + serialNumber = cycloneDxBom.getSerialNumber(); + final CycloneDXVexImporter vexImporter = new CycloneDXVexImporter(); + final org.cyclonedx.model.Bom finalCycloneDxBom = cycloneDxBom; + qm.runInTransaction(() -> vexImporter.applyVex(qm, finalCycloneDxBom, project)); + LOGGER.info("Completed processing of CycloneDX VEX."); + } else { + LOGGER.warn("A CycloneDX VEX was uploaded but accepting CycloneDX format is disabled. Aborting"); + return; + } + // TODO: Add support for CSAF } else { - LOGGER.warn("A CycloneDX VEX was uploaded but accepting CycloneDX format is disabled. Aborting"); + LOGGER.warn("The VEX uploaded is not in a supported format. Supported formats include CycloneDX XML and JSON"); return; } - // TODO: Add support for CSAF - } else { - LOGGER.warn("The VEX uploaded is not in a supported format. Supported formats include CycloneDX XML and JSON"); - return; - } - final Project copyOfProject = qm.detach(Project.class, qm.getObjectById(Project.class, project.getId()).getId()); - Notification.dispatch(new Notification() - .scope(NotificationScope.PORTFOLIO) - .group(NotificationGroup.VEX_CONSUMED) - .title(NotificationConstants.Title.VEX_CONSUMED) - .level(NotificationLevel.INFORMATIONAL) - .content("A " + vexFormat.getFormatShortName() + " VEX was consumed and will be processed") - .subject(new VexConsumedOrProcessed(copyOfProject, Base64.getEncoder().encodeToString(vexBytes), vexFormat, vexSpecVersion))); + final Project copyOfProject = qm.detach(Project.class, qm.getObjectById(Project.class, project.getId()).getId()); + Notification.dispatch(new Notification() + .scope(NotificationScope.PORTFOLIO) + .group(NotificationGroup.VEX_CONSUMED) + .title(NotificationConstants.Title.VEX_CONSUMED) + .level(NotificationLevel.INFORMATIONAL) + .content("A " + vexFormat.getFormatShortName() + " VEX was consumed and will be processed") + .subject(new VexConsumedOrProcessed(copyOfProject, Base64.getEncoder().encodeToString(vexBytes), vexFormat, vexSpecVersion))); - qm.createVex(project, new Date(), vexFormat, vexSpecVersion, vexVersion, serialNumber); + qm.createVex(project, new Date(), vexFormat, vexSpecVersion, vexVersion, serialNumber); - final Project detachedProject = qm.detach(Project.class, project.getId()); + final Project detachedProject = qm.detach(Project.class, project.getId()); - Notification.dispatch(new Notification() - .scope(NotificationScope.PORTFOLIO) - .group(NotificationGroup.VEX_PROCESSED) - .title(NotificationConstants.Title.VEX_PROCESSED) - .level(NotificationLevel.INFORMATIONAL) - .content("A " + vexFormat.getFormatShortName() + " VEX was processed") - .subject(new VexConsumedOrProcessed(detachedProject, Base64.getEncoder().encodeToString(vexBytes), vexFormat, vexSpecVersion))); - } catch (Exception ex) { - LOGGER.error("Error while processing vex", ex); + Notification.dispatch(new Notification() + .scope(NotificationScope.PORTFOLIO) + .group(NotificationGroup.VEX_PROCESSED) + .title(NotificationConstants.Title.VEX_PROCESSED) + .level(NotificationLevel.INFORMATIONAL) + .content("A " + vexFormat.getFormatShortName() + " VEX was processed") + .subject(new VexConsumedOrProcessed(detachedProject, Base64.getEncoder().encodeToString(vexBytes), vexFormat, vexSpecVersion))); + } catch (Exception ex) { + LOGGER.error("Error while processing vex", ex); + } } } - } } } \ No newline at end of file diff --git a/src/test/java/org/dependencytrack/parser/cyclonedx/CycloneDXVexImporterTest.java b/src/test/java/org/dependencytrack/parser/cyclonedx/CycloneDXVexImporterTest.java index 0b98ddf239..414eb3c358 100644 --- a/src/test/java/org/dependencytrack/parser/cyclonedx/CycloneDXVexImporterTest.java +++ b/src/test/java/org/dependencytrack/parser/cyclonedx/CycloneDXVexImporterTest.java @@ -5,9 +5,12 @@ import org.cyclonedx.parsers.BomParserFactory; import org.dependencytrack.PersistenceCapableTest; import org.dependencytrack.model.Analysis; +import org.dependencytrack.model.AnalysisComment; import org.dependencytrack.model.AnalysisJustification; +import org.dependencytrack.model.AnalysisResponse; import org.dependencytrack.model.AnalysisState; import org.dependencytrack.model.Component; +import org.dependencytrack.model.Project; import org.dependencytrack.model.Severity; import org.dependencytrack.model.Vulnerability; import org.dependencytrack.tasks.scanners.AnalyzerIdentity; @@ -16,6 +19,7 @@ import javax.jdo.Query; import java.io.IOException; import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; import java.util.Arrays; @@ -128,4 +132,63 @@ void shouldAuditVulnerabilityFromAllSourcesUsingVex() throws URISyntaxException, }); } + @Test + void shouldPersistLastResponseAndCommentEach() throws ParseException { + final var project = new Project(); + project.setName("acme-app"); + qm.persist(project); + + final var component = new Component(); + component.setProject(project); + component.setName("Acme Component"); + component.setVersion("1.0"); + qm.persist(component); + + final var vuln = new Vulnerability(); + vuln.setVulnId("CVE-2099-0001"); + vuln.setSource(Vulnerability.Source.NVD); + vuln.setSeverity(Severity.HIGH); + vuln.setComponents(List.of(component)); + qm.persist(vuln); + + qm.addVulnerability(vuln, component, AnalyzerIdentity.NONE); + + final byte[] vexBytes = /* language=JSON */ """ + { + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "component": { + "type": "application", + "bom-ref": "project", + "name": "Acme Example", + "version": "1.0" + } + }, + "vulnerabilities": [ + { + "id": "CVE-2099-0001", + "source": { "name": "NVD" }, + "analysis": { + "response": ["will_not_fix", "update"] + }, + "affects": [{ "ref": "project" }] + } + ] + } + """.getBytes(StandardCharsets.UTF_8); + final var vex = BomParserFactory.createParser(vexBytes).parse(vexBytes); + + vexImporter.applyVex(qm, vex, project); + + final Analysis analysis = qm.getAnalysis(component, vuln); + Assertions.assertThat(analysis.getAnalysisResponse()).isEqualTo(AnalysisResponse.UPDATE); + Assertions.assertThat(analysis.getAnalysisComments()) + .extracting(AnalysisComment::getComment) + .containsExactlyInAnyOrder( + "Vendor Response: NOT_SET → WILL_NOT_FIX", + "Vendor Response: NOT_SET → UPDATE"); + } + }