diff --git a/apiserver/src/main/java/org/dependencytrack/model/Analysis.java b/apiserver/src/main/java/org/dependencytrack/model/Analysis.java index c772e979c2..fe88c7db44 100644 --- a/apiserver/src/main/java/org/dependencytrack/model/Analysis.java +++ b/apiserver/src/main/java/org/dependencytrack/model/Analysis.java @@ -152,6 +152,18 @@ public class Analysis implements Serializable { @JsonProperty(value = "owaspScore") private BigDecimal owaspScore; + /** + * The source that owns this analysis. + * Tracks whether the analysis was set by POLICY, VEX, MANUAL, or NVD. + * Higher-precedence sources prevent lower-precedence sources from overwriting. + * + * @since 5.8.0 + */ + @Persistent(defaultFetchGroup = "true") + @Column(name = "SOURCE", jdbcType = "VARCHAR", allowsNull = "true") + @JsonProperty(value = "source") + private RatingSource source; + @Persistent @Column(name = "VULNERABILITY_POLICY_ID", allowsNull = "true") @JsonIgnore @@ -306,6 +318,14 @@ public void setOwaspScore(BigDecimal owaspScore) { this.owaspScore = owaspScore; } + public RatingSource getSource() { + return source; + } + + public void setSource(RatingSource source) { + this.source = source; + } + public Long getVulnerabilityPolicyId() { return vulnerabilityPolicyId; } diff --git a/apiserver/src/main/java/org/dependencytrack/model/RatingSource.java b/apiserver/src/main/java/org/dependencytrack/model/RatingSource.java new file mode 100644 index 0000000000..9da2587382 --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/model/RatingSource.java @@ -0,0 +1,46 @@ +/* + * This file is part of Dependency-Track. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright (c) OWASP Foundation. All Rights Reserved. + */ +package org.dependencytrack.model; + +/** + * Defines the source of an analysis. Precedence: POLICY > VEX > MANUAL > NVD. + * + * @since 5.8.0 + */ +public enum RatingSource { + + POLICY(4), + VEX(3), + MANUAL(2), + NVD(1); + + private final int precedence; + + RatingSource(int precedence) { + this.precedence = precedence; + } + + public int getPrecedence() { + return precedence; + } + + public boolean canOverwrite(RatingSource other) { + return other == null || this.precedence >= other.precedence; + } +} diff --git a/apiserver/src/main/java/org/dependencytrack/model/Vulnerability.java b/apiserver/src/main/java/org/dependencytrack/model/Vulnerability.java index c8aeb118c8..94349adb0e 100644 --- a/apiserver/src/main/java/org/dependencytrack/model/Vulnerability.java +++ b/apiserver/src/main/java/org/dependencytrack/model/Vulnerability.java @@ -130,7 +130,8 @@ public enum Source { RETIREJS, // Retire.js INTERNAL, // Internally-managed (and manually entered) vulnerability OSV, // Google OSV Advisories - SNYK; // Snyk Purl Vulnerability + SNYK, // Snyk Purl Vulnerability + UNKNOWN; // Unknown or unrecognized vulnerability source public static boolean isKnownSource(String source) { return Arrays.stream(values()).anyMatch(enumSource -> enumSource.name().equalsIgnoreCase(source)); diff --git a/apiserver/src/main/java/org/dependencytrack/parser/cyclonedx/CycloneDXVexImporter.java b/apiserver/src/main/java/org/dependencytrack/parser/cyclonedx/CycloneDXVexImporter.java index 8590f85249..49fef7ae7d 100644 --- a/apiserver/src/main/java/org/dependencytrack/parser/cyclonedx/CycloneDXVexImporter.java +++ b/apiserver/src/main/java/org/dependencytrack/parser/cyclonedx/CycloneDXVexImporter.java @@ -28,6 +28,7 @@ import org.dependencytrack.model.Component; import org.dependencytrack.model.ComponentIdentity; import org.dependencytrack.model.Project; +import org.dependencytrack.model.RatingSource; import org.dependencytrack.model.Vulnerability; import org.dependencytrack.parser.cyclonedx.util.ModelConverter; import org.dependencytrack.persistence.QueryManager; @@ -130,8 +131,9 @@ private static List getApplicab .formatted(vexVulnSource, vexVulnId, vexVulnPos)); continue; } - if (vexVuln.getAnalysis() == null) { - LOGGER.debug("VEX vulnerability %s/%s at position #%d does not have an analysis; Skipping it" + // Allow VEX entries with either analysis or ratings (or both) + if (vexVuln.getAnalysis() == null && CollectionUtils.isEmpty(vexVuln.getRatings())) { + LOGGER.debug("VEX vulnerability %s/%s at position #%d does not have an analysis or ratings; Skipping it" .formatted(vexVulnSource, vexVulnId, vexVulnPos)); continue; } @@ -145,30 +147,6 @@ private static List getApplicab private static void updateAnalysis(final QueryManager qm, final Component component, final Vulnerability vuln, final org.cyclonedx.model.vulnerability.Vulnerability cdxVuln) { qm.runInTransaction(() -> { - final AnalysisState state = - convertCdxVulnAnalysisStateToDtAnalysisState(cdxVuln.getAnalysis().getState()); - final AnalysisJustification justification = - convertCdxVulnAnalysisJustificationToDtAnalysisJustification(cdxVuln.getAnalysis().getJustification()); - - // CycloneDX supports multiple responses, DT only one. - // The decision to effectively pick the last one is legacy behavior, - // there's no other particular reason for doing it. - final AnalysisResponse response; - if (cdxVuln.getAnalysis().getResponses() != null - && !cdxVuln.getAnalysis().getResponses().isEmpty()) { - response = cdxVuln.getAnalysis().getResponses().stream() - .map(ModelConverter::convertCdxVulnAnalysisResponseToDtAnalysisResponse) - .toList() - .getLast(); - } else { - response = null; - } - - final boolean isSuppressed = - state == AnalysisState.FALSE_POSITIVE - || state == AnalysisState.NOT_AFFECTED - || state == AnalysisState.RESOLVED; - final Component persistentComponent = !isPersistent(component) ? qm.getObjectById(Component.class, component.getId()) : component; @@ -176,14 +154,58 @@ private static void updateAnalysis(final QueryManager qm, final Component compon ? qm.getObjectById(Vulnerability.class, vuln.getId()) : vuln; - qm.makeAnalysis( - new MakeAnalysisCommand(persistentComponent, persistentVuln) - .withState(state) - .withJustification(justification) - .withResponse(response) - .withDetails(cdxVuln.getAnalysis().getDetail()) - .withCommenter(COMMENTER) - .withSuppress(isSuppressed)); + MakeAnalysisCommand command = new MakeAnalysisCommand(persistentComponent, persistentVuln) + .withCommenter(COMMENTER) + .withSource(RatingSource.VEX); + + if (cdxVuln.getAnalysis() != null) { + final AnalysisState state = convertCdxVulnAnalysisStateToDtAnalysisState(cdxVuln.getAnalysis().getState()); + final AnalysisJustification justification = convertCdxVulnAnalysisJustificationToDtAnalysisJustification(cdxVuln.getAnalysis().getJustification()); + + // CycloneDX supports multiple responses, DT only one. + // The decision to effectively pick the last one is legacy behavior, + // there's no other particular reason for doing it. + final AnalysisResponse response; + if (cdxVuln.getAnalysis().getResponses() != null + && !cdxVuln.getAnalysis().getResponses().isEmpty()) { + response = cdxVuln.getAnalysis().getResponses().stream() + .map(ModelConverter::convertCdxVulnAnalysisResponseToDtAnalysisResponse) + .toList() + .getLast(); + } else { + response = null; + } + + final boolean isSuppressed = state == AnalysisState.FALSE_POSITIVE + || state == AnalysisState.NOT_AFFECTED + || state == AnalysisState.RESOLVED; + + command = command + .withState(state) + .withJustification(justification) + .withResponse(response) + .withDetails(cdxVuln.getAnalysis().getDetail()) + .withSuppress(isSuppressed); + } + + if (cdxVuln.getRatings() != null && !cdxVuln.getRatings().isEmpty()) { + for (final org.cyclonedx.model.vulnerability.Vulnerability.Rating rating : cdxVuln.getRatings()) { + if (rating.getMethod() == org.cyclonedx.model.vulnerability.Vulnerability.Rating.Method.OWASP) { + if (rating.getVector() == null && rating.getScore() == null) { + LOGGER.warn("VEX OWASP rating has neither vector nor score - skipping"); + continue; + } + + final java.math.BigDecimal score = rating.getScore() != null + ? java.math.BigDecimal.valueOf(rating.getScore()) + : null; + command = command.withOwasp(rating.getVector(), score); + break; + } + } + } + + qm.makeAnalysis(command); }); } } \ No newline at end of file diff --git a/apiserver/src/main/java/org/dependencytrack/persistence/AnalysisQueryManager.java b/apiserver/src/main/java/org/dependencytrack/persistence/AnalysisQueryManager.java index 3c27656c78..e5f69f48f1 100644 --- a/apiserver/src/main/java/org/dependencytrack/persistence/AnalysisQueryManager.java +++ b/apiserver/src/main/java/org/dependencytrack/persistence/AnalysisQueryManager.java @@ -27,6 +27,7 @@ import org.dependencytrack.model.AnalysisResponse; import org.dependencytrack.model.AnalysisState; import org.dependencytrack.model.Component; +import org.dependencytrack.model.RatingSource; import org.dependencytrack.model.Vulnerability; import org.dependencytrack.notification.JdoNotificationEmitter; import org.dependencytrack.notification.NotificationModelConverter; @@ -102,27 +103,49 @@ public long makeAnalysis(final MakeAnalysisCommand command) { boolean stateChanged = false; boolean suppressionChanged = false; - if (command.state() != null && command.state() != analysis.getAnalysisState()) { - auditTrailComments.add("Analysis: %s → %s".formatted(analysis.getAnalysisState(), command.state())); - analysis.setAnalysisState(command.state()); - stateChanged = true; - } - if (command.justification() != null && command.justification() != analysis.getAnalysisJustification()) { - auditTrailComments.add("Justification: %s → %s".formatted(analysis.getAnalysisJustification(), command.justification())); - analysis.setAnalysisJustification(command.justification()); - } - if (command.response() != null && command.response() != analysis.getAnalysisResponse()) { - auditTrailComments.add("Vendor Response: %s → %s".formatted(analysis.getAnalysisResponse(), command.response())); - analysis.setAnalysisResponse(command.response()); - } - if (command.details() != null && !command.details().equals(analysis.getAnalysisDetails())) { - auditTrailComments.add("Details: %s".formatted(command.details())); - analysis.setAnalysisDetails(command.details()); - } - if (command.suppress() != null && command.suppress() != analysis.isSuppressed()) { - auditTrailComments.add(command.suppress() ? "Suppressed" : "Unsuppressed"); - analysis.setSuppressed(command.suppress()); - suppressionChanged = true; + final boolean canUpdate = canUpdateAnalysis(analysis.getSource(), command.source()); + + if (canUpdate) { + if (command.state() != null && command.state() != analysis.getAnalysisState()) { + auditTrailComments.add("Analysis: %s → %s".formatted(analysis.getAnalysisState(), command.state())); + analysis.setAnalysisState(command.state()); + stateChanged = true; + } + if (command.justification() != null && command.justification() != analysis.getAnalysisJustification()) { + auditTrailComments.add("Justification: %s → %s".formatted(analysis.getAnalysisJustification(), command.justification())); + analysis.setAnalysisJustification(command.justification()); + } + if (command.response() != null && command.response() != analysis.getAnalysisResponse()) { + auditTrailComments.add("Vendor Response: %s → %s".formatted(analysis.getAnalysisResponse(), command.response())); + analysis.setAnalysisResponse(command.response()); + } + if (command.details() != null && !command.details().equals(analysis.getAnalysisDetails())) { + auditTrailComments.add("Details: %s".formatted(command.details())); + analysis.setAnalysisDetails(command.details()); + } + if (command.suppress() != null && command.suppress() != analysis.isSuppressed()) { + auditTrailComments.add(command.suppress() ? "Suppressed" : "Unsuppressed"); + analysis.setSuppressed(command.suppress()); + suppressionChanged = true; + } + + if (command.owaspVector() != null && !command.owaspVector().equals(analysis.getOwaspVector())) { + auditTrailComments.add("OWASP RR Vector: %s → %s".formatted( + analysis.getOwaspVector(), command.owaspVector())); + analysis.setOwaspVector(command.owaspVector()); + } + if (command.owaspScore() != null && !command.owaspScore().equals(analysis.getOwaspScore())) { + auditTrailComments.add("OWASP RR Score: %s → %s".formatted( + analysis.getOwaspScore(), command.owaspScore())); + analysis.setOwaspScore(command.owaspScore()); + } + + if (command.source() != null && analysis.getSource() != command.source()) { + if (analysis.getSource() != null) { + auditTrailComments.add("Source: %s → %s".formatted(analysis.getSource(), command.source())); + } + analysis.setSource(command.source()); + } } final List comments = @@ -198,4 +221,11 @@ private void createAnalysisComments( }); } + private boolean canUpdateAnalysis(final RatingSource existingSource, final RatingSource newSource) { + if (newSource == null || existingSource == null) { + return true; + } + return newSource.canOverwrite(existingSource); + } + } diff --git a/apiserver/src/main/java/org/dependencytrack/persistence/command/MakeAnalysisCommand.java b/apiserver/src/main/java/org/dependencytrack/persistence/command/MakeAnalysisCommand.java index 956d3b21a1..b8eb371f39 100644 --- a/apiserver/src/main/java/org/dependencytrack/persistence/command/MakeAnalysisCommand.java +++ b/apiserver/src/main/java/org/dependencytrack/persistence/command/MakeAnalysisCommand.java @@ -22,9 +22,11 @@ import org.dependencytrack.model.AnalysisResponse; import org.dependencytrack.model.AnalysisState; import org.dependencytrack.model.Component; +import org.dependencytrack.model.RatingSource; import org.dependencytrack.model.Vulnerability; import org.dependencytrack.notification.proto.v1.Group; +import java.math.BigDecimal; import java.util.Collections; import java.util.Set; @@ -38,8 +40,11 @@ * @param response The vendor response to set * @param details The details to set * @param suppress Whether to suppress the finding + * @param source The source that owns this analysis (POLICY, VEX, MANUAL, NVD) * @param commenter Name of the principal on which behalf audit trail entries will be created * @param comment The comment to add to the audit trail + * @param owaspVector OWASP RR vector to set + * @param owaspScore OWASP RR score to set * @param options Additional options * @since 5.0.0 */ @@ -51,8 +56,11 @@ public record MakeAnalysisCommand( AnalysisResponse response, String details, Boolean suppress, + RatingSource source, String commenter, String comment, + String owaspVector, + BigDecimal owaspScore, Set