Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions RATING_SOURCE_TRACKING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Rating Source Tracking for OWASP Ratings

## Rating Source Hierarchy

Ratings are tracked with their source using the `RatingSource` enum, which enforces the following precedence order (highest to lowest):

1. **POLICY** (Precedence: 4) - Rating applied by organizational policies
2. **VEX** (Precedence: 3) - Rating from VEX documents (authoritative context-specific assessment)
3. **MANUAL** (Precedence: 2) - User-provided rating (analyst notes)
4. **NVD** (Precedence: 1) - Default rating from vulnerability databases

**Rationale:** POLICY has highest precedence to enforce organizational security standards. VEX can overwrite MANUAL assessments as it represents authoritative context-aware analysis. MANUAL ratings serve as analyst notes but are subject to policy enforcement.

## Precedence Rules

- Higher precedence sources can overwrite lower precedence sources
- Equal precedence sources can overwrite each other (updates)
- Lower precedence sources **cannot** overwrite higher precedence sources

**Examples:**

```
POLICY (8.0) ← VEX (7.2) ✗ VEX cannot overwrite POLICY
VEX (7.2) ← MANUAL (9.0) ✗ MANUAL cannot overwrite VEX
MANUAL (5.0) ← NVD (5.3) ✗ NVD cannot overwrite MANUAL
VEX (7.2) ← VEX (8.5) ✓ Updated VEX can overwrite previous VEX
POLICY (8.0) ← POLICY (9.0) ✓ Updated POLICY can overwrite previous POLICY
```
37 changes: 37 additions & 0 deletions apiserver/src/main/java/org/dependencytrack/model/Analysis.java
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,27 @@ public class Analysis implements Serializable {
@JsonProperty(value = "owaspScore")
private BigDecimal owaspScore;

@Persistent(defaultFetchGroup = "true")
@Column(name = "OWASPSEVERITY")
@Extensions(value = {
@Extension(vendorName = "datanucleus", key = "insert-function", value = "CAST(? AS severity)"),
@Extension(vendorName = "datanucleus", key = "update-function", value = "CAST(? AS severity)")
})
@JsonProperty(value = "owaspSeverity")
private Severity owaspSeverity;
Comment on lines +155 to +162
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the use case for tracking the severity for OWASP ratings separately? Adding more severity fields adds complexity to all queries that operate on severities, including metrics calculation.

Analogue to a previous comment, if we do this for OWASP ratings, why not also for CVSS?


/**
* 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")
@JsonProperty(value = "source")
private RatingSource source;

@Persistent
@Column(name = "VULNERABILITY_POLICY_ID", allowsNull = "true")
@JsonIgnore
Expand Down Expand Up @@ -306,6 +327,22 @@ public void setOwaspScore(BigDecimal owaspScore) {
this.owaspScore = owaspScore;
}

public Severity getOwaspSeverity() {
return owaspSeverity;
}

public void setOwaspSeverity(Severity owaspSeverity) {
this.owaspSeverity = owaspSeverity;
}

public RatingSource getSource() {
return source;
}

public void setSource(RatingSource source) {
this.source = source;
}

public Long getVulnerabilityPolicyId() {
return vulnerabilityPolicyId;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* 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 hasHigherPrecedenceThan(RatingSource other) {
return other == null || this.precedence > other.precedence;
}

public boolean hasHigherOrEqualPrecedenceThan(RatingSource other) {
return other == null || this.precedence >= other.precedence;
}

public boolean canOverwrite(RatingSource other) {
return hasHigherOrEqualPrecedenceThan(other);
}

public static boolean shouldAllowUpdate(RatingSource currentSource, RatingSource newSource) {
if (newSource == null) {
return false;
}
return currentSource == null || newSource.hasHigherOrEqualPrecedenceThan(currentSource);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,8 @@ public enum Source {
INTERNAL, // Internally-managed (and manually entered) vulnerability
OSV, // Google OSV Advisories
SNYK, // Snyk Purl Vulnerability
CSAF; // CSAF Vulnerability sources
CSAF, // CSAF Vulnerability sources
UNKNOWN; // Unknown or unrecognized vulnerability source
Comment thread
fahedouch marked this conversation as resolved.

public static boolean isKnownSource(String source) {
return Arrays.stream(values()).anyMatch(enumSource -> enumSource.name().equalsIgnoreCase(source));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
import org.dependencytrack.model.Component;
import org.dependencytrack.model.ComponentIdentity;
import org.dependencytrack.model.Project;
import org.dependencytrack.model.RatingSource;
import org.dependencytrack.model.Severity;
import org.dependencytrack.model.Vulnerability;
import org.dependencytrack.parser.cyclonedx.util.ModelConverter;
import org.dependencytrack.persistence.QueryManager;
Expand All @@ -39,6 +41,7 @@
import java.util.List;

import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.dependencytrack.parser.cyclonedx.util.ModelConverter.convertCdxSeverityToDtSeverity;
import static org.dependencytrack.parser.cyclonedx.util.ModelConverter.convertCdxVulnAnalysisJustificationToDtAnalysisJustification;
import static org.dependencytrack.parser.cyclonedx.util.ModelConverter.convertCdxVulnAnalysisStateToDtAnalysisState;
import static org.dependencytrack.persistence.jdbi.JdbiFactory.withJdbiHandle;
Expand Down Expand Up @@ -129,8 +132,9 @@ private static List<org.cyclonedx.model.vulnerability.Vulnerability> 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;
}
Expand All @@ -144,45 +148,73 @@ private static List<org.cyclonedx.model.vulnerability.Vulnerability> 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;
final Vulnerability persistentVuln = !isPersistent(vuln)
? 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.getSeverity() == null) {
LOGGER.warn("VEX rating has neither vector nor severity - skipping");
continue;
}

if (rating.getVector() != null && rating.getScore() == null) {
LOGGER.warn("VEX rating has vector but no score - skipping");
continue;
}

final Severity severity = convertCdxSeverityToDtSeverity(rating.getSeverity());
final String vector = rating.getVector();
final java.math.BigDecimal score = rating.getScore() != null
? java.math.BigDecimal.valueOf(rating.getScore())
: null;

command = command.withOwasp(vector, score, severity);
break;
}
}
}

qm.makeAnalysis(command);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1123,6 +1123,20 @@ private static org.cyclonedx.model.vulnerability.Vulnerability.Rating.Severity c
}
}

public static Severity convertCdxSeverityToDtSeverity(final org.cyclonedx.model.vulnerability.Vulnerability.Rating.Severity severity) {
if (severity == null) {
return null;
}
return switch (severity) {
case CRITICAL -> Severity.CRITICAL;
case HIGH -> Severity.HIGH;
case MEDIUM -> Severity.MEDIUM;
case LOW -> Severity.LOW;
case INFO -> Severity.INFO;
case NONE, UNKNOWN -> Severity.UNASSIGNED;
};
}

private static org.cyclonedx.model.vulnerability.Vulnerability.Source convertDtVulnSourceToCdxVulnSource(final Vulnerability.Source vulnSource) {
org.cyclonedx.model.vulnerability.Vulnerability.Source cdxSource = new org.cyclonedx.model.vulnerability.Vulnerability.Source();
cdxSource.setName(vulnSource.name());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import org.dependencytrack.model.AnalysisState;
import org.dependencytrack.model.Component;
import org.dependencytrack.model.Project;
import org.dependencytrack.model.RatingSource;
import org.dependencytrack.model.Vulnerability;
import org.dependencytrack.notification.JdoNotificationEmitter;
import org.dependencytrack.notification.NotificationModelConverter;
Expand Down Expand Up @@ -138,6 +139,35 @@ public long makeAnalysis(final MakeAnalysisCommand command) {
suppressionChanged = true;
}

final boolean canUpdate = canUpdateAnalysis(analysis.getSource(), command.source());

if (canUpdate && (command.owaspVector() != null || command.owaspScore() != null || command.owaspSeverity() != null)) {
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.owaspSeverity() != null && command.owaspSeverity() != analysis.getOwaspSeverity()) {
auditTrailComments.add("OWASP RR Severity: %s → %s".formatted(
analysis.getOwaspSeverity(), command.owaspSeverity()));
analysis.setOwaspSeverity(command.owaspSeverity());
}
}

if (command.source() != null && canUpdate) {
if (analysis.getSource() != command.source()) {
if (analysis.getSource() != null) {
auditTrailComments.add("Source: %s → %s".formatted(analysis.getSource(), command.source()));
}
analysis.setSource(command.source());
}
}

final List<String> comments =
!command.options().contains(MakeAnalysisCommand.Option.OMIT_AUDIT_TRAIL)
? auditTrailComments
Expand Down Expand Up @@ -211,4 +241,11 @@ private void createAnalysisComments(
});
}

private boolean canUpdateAnalysis(final RatingSource existingSource, final RatingSource newSource) {
if (newSource == null || existingSource == null) {
return true;
}
return newSource.hasHigherOrEqualPrecedenceThan(existingSource);
}

}
Loading
Loading