diff --git a/src/main/java/org/dependencytrack/model/PolicyCondition.java b/src/main/java/org/dependencytrack/model/PolicyCondition.java
index 21474b41e1..042f25417c 100644
--- a/src/main/java/org/dependencytrack/model/PolicyCondition.java
+++ b/src/main/java/org/dependencytrack/model/PolicyCondition.java
@@ -90,7 +90,8 @@ public enum Subject {
CWE,
VULNERABILITY_ID,
VERSION_DISTANCE,
- EPSS
+ EPSS,
+ ATTRIBUTED_ON
}
public enum FetchGroup {
diff --git a/src/main/java/org/dependencytrack/policy/AttributedOnPolicyEvaluator.java b/src/main/java/org/dependencytrack/policy/AttributedOnPolicyEvaluator.java
new file mode 100644
index 0000000000..9f499e222a
--- /dev/null
+++ b/src/main/java/org/dependencytrack/policy/AttributedOnPolicyEvaluator.java
@@ -0,0 +1,199 @@
+/*
+ * 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.policy;
+
+import org.dependencytrack.model.Component;
+import org.dependencytrack.model.FindingAttribution;
+import org.dependencytrack.model.Policy;
+import org.dependencytrack.model.PolicyCondition;
+import org.dependencytrack.model.Vulnerability;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.LocalDate;
+import java.time.Period;
+import java.time.ZoneId;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+/**
+ * Evaluates vulnerabilities against attributed on age-based policy conditions.
+ *
+ * Checks whether vulnerabilities meet age requirements by comparing their
+ * attribution date with specified time periods in ISO-8601 format (e.g., "P30D").
+ */
+public class AttributedOnPolicyEvaluator extends AbstractPolicyEvaluator {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(AttributedOnPolicyEvaluator.class);
+ private static final ConcurrentMap> PERIOD_CACHE = new ConcurrentHashMap<>();
+ private static final int MAX_CACHE_SIZE = 100;
+
+ @Override
+ public PolicyCondition.Subject supportedSubject() {
+ return PolicyCondition.Subject.ATTRIBUTED_ON;
+ }
+
+ @Override
+ public List evaluate(final Policy policy, final Component component) {
+ if (policy == null || component == null) {
+ return Collections.emptyList();
+ }
+
+ final List conditions = extractSupportedConditions(policy);
+ if (conditions.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ final List vulnerabilities = getVulnerabilities(component);
+ if (vulnerabilities.isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ return evaluateVulnerabilities(vulnerabilities, conditions, component);
+ }
+
+ /**
+ * Retrieves all vulnerabilities for the given component.
+ *
+ * @param component the component to get vulnerabilities for
+ * @return list of vulnerabilities, never null
+ */
+ private List getVulnerabilities(final Component component) {
+ try {
+ final List vulnerabilities = qm.getAllVulnerabilities(component);
+ return vulnerabilities != null ? vulnerabilities : Collections.emptyList();
+ } catch (final Exception e) {
+ LOGGER.warn("Failed to retrieve vulnerabilities for component: {}", component.getUuid(), e);
+ return Collections.emptyList();
+ }
+ }
+
+ /**
+ * Evaluates vulnerabilities against policy conditions.
+ *
+ * @param vulnerabilities the vulnerabilities to evaluate
+ * @param conditions the policy conditions to check against
+ * @param component the component being evaluated
+ * @return list of policy violations
+ */
+ private List evaluateVulnerabilities(
+ final List vulnerabilities,
+ final List conditions,
+ final Component component) {
+
+ final List violations = new ArrayList<>();
+
+ for (final Vulnerability vulnerability : vulnerabilities) {
+ final Optional attributedDate = getAttributedOnDate(vulnerability, component);
+ if (attributedDate.isEmpty()) {
+ continue;
+ }
+
+ for (final PolicyCondition condition : conditions) {
+ if (evaluateCondition(condition, attributedDate.get())) {
+ violations.add(new PolicyConditionViolation(condition, component));
+ }
+ }
+ }
+
+ return violations;
+ }
+
+ /**
+ * Extracts the attributed on date from a vulnerability.
+ *
+ * @param vulnerability the vulnerability to extract the date from
+ * @param component the component associated with the vulnerability
+ * @return the attributed on date wrapped in Optional, empty if not available
+ */
+ private Optional getAttributedOnDate(final Vulnerability vulnerability, final Component component) {
+ try {
+ final FindingAttribution attribution = qm.getFindingAttribution(vulnerability, component);
+ return attribution != null ? Optional.ofNullable(attribution.getAttributedOn()) : Optional.empty();
+ } catch (final Exception e) {
+ LOGGER.debug("Failed to retrieve attribution for vulnerability {} on component {}",
+ vulnerability.getVulnId(), component.getUuid());
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * Evaluates a single policy condition against an attributed on date.
+ *
+ * @param condition the policy condition to evaluate
+ * @param attributedOn the date when the vulnerability was attributed
+ * @return true if the condition is violated, false otherwise
+ * @throws IllegalArgumentException if condition or attributedOn is null
+ */
+ private boolean evaluateCondition(final PolicyCondition condition, final Date attributedOn) {
+ final Optional agePeriod = parseAgePeriod(condition.getValue());
+ if (agePeriod.isEmpty() || !isValidPeriod(agePeriod.get())) {
+ return false;
+ }
+
+ final LocalDate attributedDate = attributedOn.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
+ final LocalDate targetDate = attributedDate.plus(agePeriod.get());
+ final LocalDate today = LocalDate.now();
+
+ return switch (condition.getOperator()) {
+ case NUMERIC_GREATER_THAN -> targetDate.isBefore(today);
+ case NUMERIC_GREATER_THAN_OR_EQUAL -> !targetDate.isAfter(today);
+ case NUMERIC_EQUAL -> targetDate.isEqual(today);
+ case NUMERIC_NOT_EQUAL -> !targetDate.isEqual(today);
+ case NUMERIC_LESSER_THAN_OR_EQUAL -> !targetDate.isBefore(today);
+ case NUMERIC_LESS_THAN -> targetDate.isAfter(today);
+ default -> false;
+ };
+ }
+
+ private Optional parseAgePeriod(final String periodValue) {
+ if (periodValue == null || periodValue.trim().isEmpty()) {
+ return Optional.empty();
+ }
+
+ final String trimmed = periodValue.trim();
+ Optional cached = PERIOD_CACHE.get(trimmed);
+ if (cached != null) {
+ return cached;
+ }
+
+ try {
+ final Period period = Period.parse(trimmed);
+ cached = Optional.of(period);
+ } catch (final DateTimeParseException e) {
+ cached = Optional.empty();
+ }
+
+ if (PERIOD_CACHE.size() < MAX_CACHE_SIZE) {
+ PERIOD_CACHE.put(trimmed, cached);
+ }
+
+ return cached;
+ }
+
+ private boolean isValidPeriod(final Period period) {
+ return !period.isZero() && !period.isNegative();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/dependencytrack/policy/PolicyEngine.java b/src/main/java/org/dependencytrack/policy/PolicyEngine.java
index b6ee0a2d20..63c44275b3 100644
--- a/src/main/java/org/dependencytrack/policy/PolicyEngine.java
+++ b/src/main/java/org/dependencytrack/policy/PolicyEngine.java
@@ -61,6 +61,7 @@ public PolicyEngine() {
evaluators.add(new VulnerabilityIdPolicyEvaluator());
evaluators.add(new VersionDistancePolicyEvaluator());
evaluators.add(new EpssPolicyEvaluator());
+ evaluators.add(new AttributedOnPolicyEvaluator());
}
public List evaluate(final List components) {
@@ -145,7 +146,7 @@ public PolicyViolation.Type determineViolationType(final PolicyCondition.Subject
}
return switch (subject) {
case CWE, SEVERITY, VULNERABILITY_ID, EPSS -> PolicyViolation.Type.SECURITY;
- case AGE, COORDINATES, PACKAGE_URL, CPE, SWID_TAGID, COMPONENT_HASH, VERSION, VERSION_DISTANCE ->
+ case AGE, COORDINATES, PACKAGE_URL, CPE, SWID_TAGID, COMPONENT_HASH, VERSION, VERSION_DISTANCE, ATTRIBUTED_ON ->
PolicyViolation.Type.OPERATIONAL;
case LICENSE, LICENSE_GROUP -> PolicyViolation.Type.LICENSE;
};
diff --git a/src/test/java/org/dependencytrack/policy/AttributedOnPolicyEvaluatorTest.java b/src/test/java/org/dependencytrack/policy/AttributedOnPolicyEvaluatorTest.java
new file mode 100644
index 0000000000..6a751983d6
--- /dev/null
+++ b/src/test/java/org/dependencytrack/policy/AttributedOnPolicyEvaluatorTest.java
@@ -0,0 +1,294 @@
+package org.dependencytrack.policy;
+
+import org.dependencytrack.PersistenceCapableTest;
+import org.dependencytrack.model.Component;
+import org.dependencytrack.model.FindingAttribution;
+import org.dependencytrack.model.Policy;
+import org.dependencytrack.model.PolicyCondition;
+import org.dependencytrack.model.Vulnerability;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+@RunWith(Parameterized.class)
+public class AttributedOnPolicyEvaluatorTest extends PersistenceCapableTest {
+
+ @Parameterized.Parameters(name = "[{index}] attributedDate={0} operator={1} ageValue={2} shouldViolate={3}")
+ public static Collection> testParameters() {
+ return Arrays.asList(new Object[][]{
+ // Test cases: [daysAgo, operator, periodValue, shouldViolate]
+
+ // GREATER_THAN tests - violation when age > specified period
+ {40, PolicyCondition.Operator.NUMERIC_GREATER_THAN, "P30D", true}, // 40 days > 30 days
+ {20, PolicyCondition.Operator.NUMERIC_GREATER_THAN, "P30D", false}, // 20 days < 30 days
+ {30, PolicyCondition.Operator.NUMERIC_GREATER_THAN, "P30D", false}, // 30 days = 30 days
+
+ // GREATER_THAN_OR_EQUAL tests - violation when age >= specified period
+ {40, PolicyCondition.Operator.NUMERIC_GREATER_THAN_OR_EQUAL, "P30D", true}, // 40 days >= 30 days
+ {30, PolicyCondition.Operator.NUMERIC_GREATER_THAN_OR_EQUAL, "P30D", true}, // 30 days >= 30 days
+ {20, PolicyCondition.Operator.NUMERIC_GREATER_THAN_OR_EQUAL, "P30D", false}, // 20 days < 30 days
+
+ // EQUAL tests - violation when age = specified period
+ {30, PolicyCondition.Operator.NUMERIC_EQUAL, "P30D", true}, // 30 days = 30 days
+ {40, PolicyCondition.Operator.NUMERIC_EQUAL, "P30D", false}, // 40 days ≠ 30 days
+ {20, PolicyCondition.Operator.NUMERIC_EQUAL, "P30D", false}, // 20 days ≠ 30 days
+
+ // NOT_EQUAL tests - violation when age ≠ specified period
+ {40, PolicyCondition.Operator.NUMERIC_NOT_EQUAL, "P30D", true}, // 40 days ≠ 30 days
+ {20, PolicyCondition.Operator.NUMERIC_NOT_EQUAL, "P30D", true}, // 20 days ≠ 30 days
+ {30, PolicyCondition.Operator.NUMERIC_NOT_EQUAL, "P30D", false}, // 30 days = 30 days
+
+ // LESS_THAN_OR_EQUAL tests - violation when age <= specified period
+ {20, PolicyCondition.Operator.NUMERIC_LESSER_THAN_OR_EQUAL, "P30D", true}, // 20 days <= 30 days
+ {30, PolicyCondition.Operator.NUMERIC_LESSER_THAN_OR_EQUAL, "P30D", true}, // 30 days <= 30 days
+ {40, PolicyCondition.Operator.NUMERIC_LESSER_THAN_OR_EQUAL, "P30D", false}, // 40 days > 30 days
+
+ // LESS_THAN tests - violation when age < specified period
+ {20, PolicyCondition.Operator.NUMERIC_LESS_THAN, "P30D", true}, // 20 days < 30 days
+ {30, PolicyCondition.Operator.NUMERIC_LESS_THAN, "P30D", false}, // 30 days = 30 days
+ {40, PolicyCondition.Operator.NUMERIC_LESS_THAN, "P30D", false}, // 40 days > 30 days
+
+ // Different period formats
+ {40, PolicyCondition.Operator.NUMERIC_GREATER_THAN, "P1M", true}, // ~40 days > 1 month
+ {25, PolicyCondition.Operator.NUMERIC_GREATER_THAN, "P1M", false}, // ~25 days < 1 month
+ {8, PolicyCondition.Operator.NUMERIC_GREATER_THAN, "P1W", true}, // 8 days > 1 week
+ {6, PolicyCondition.Operator.NUMERIC_GREATER_THAN, "P1W", false}, // 6 days < 1 week
+
+ // Edge cases
+ {1, PolicyCondition.Operator.NUMERIC_GREATER_THAN, "P1D", false}, // 1 day = 1 day
+ {2, PolicyCondition.Operator.NUMERIC_GREATER_THAN, "P1D", true}, // 2 days > 1 day
+ });
+ }
+
+ @Parameterized.Parameter()
+ public int daysAgo;
+
+ @Parameterized.Parameter(1)
+ public PolicyCondition.Operator operator;
+
+ @Parameterized.Parameter(2)
+ public String ageValue;
+
+ @Parameterized.Parameter(3)
+ public boolean shouldViolate;
+
+ private AttributedOnPolicyEvaluator evaluator;
+ private Component component;
+ private Policy policy;
+ private PolicyCondition condition;
+ private Vulnerability vulnerability;
+ private FindingAttribution attribution;
+
+ @Before
+ public void setUp() {
+ evaluator = new AttributedOnPolicyEvaluator();
+
+ // Create test entities
+ component = new Component();
+ component.setName("test-component");
+ component.setVersion("1.0.0");
+
+ vulnerability = new Vulnerability();
+ vulnerability.setVulnId("CVE-2023-1234");
+
+ policy = new Policy();
+ policy.setName("test-policy");
+
+ condition = new PolicyCondition();
+ condition.setSubject(PolicyCondition.Subject.ATTRIBUTED_ON);
+ condition.setOperator(operator);
+ condition.setValue(ageValue);
+
+ attribution = new FindingAttribution();
+ Date attributedDate = Date.from(LocalDate.now().minusDays(daysAgo)
+ .atStartOfDay(ZoneId.systemDefault()).toInstant());
+ attribution.setAttributedOn(attributedDate);
+
+ // Mock the query manager
+ when(qm.getAllVulnerabilities(component)).thenReturn(Collections.singletonList(vulnerability));
+ when(qm.getFindingAttribution(vulnerability, component)).thenReturn(attribution);
+ }
+
+ @Test
+ public void testAgeBasedPolicyEvaluation() {
+ // Arrange
+ policy.setPolicyConditions(Collections.singletonList(condition));
+
+ // Act
+ List violations = evaluator.evaluate(policy, component);
+
+ // Assert
+ if (shouldViolate) {
+ assertThat(violations)
+ .hasSize(1)
+ .extracting(PolicyConditionViolation::getPolicyCondition)
+ .containsExactly(condition);
+ } else {
+ assertThat(violations).isEmpty();
+ }
+ }
+
+ // Additional non-parameterized tests
+
+ @Test
+ public void testNullPolicy() {
+ List violations = evaluator.evaluate(null, component);
+ assertThat(violations).isEmpty();
+ }
+
+ @Test
+ public void testNullComponent() {
+ List violations = evaluator.evaluate(policy, null);
+ assertThat(violations).isEmpty();
+ }
+
+ @Test
+ public void testNoVulnerabilities() {
+ when(qm.getAllVulnerabilities(component)).thenReturn(Collections.emptyList());
+ policy.setPolicyConditions(Collections.singletonList(condition));
+
+ List violations = evaluator.evaluate(policy, component);
+ assertThat(violations).isEmpty();
+ }
+
+ @Test
+ public void testNoFindingAttribution() {
+ when(qm.getAllVulnerabilities(component)).thenReturn(Collections.singletonList(vulnerability));
+ when(qm.getFindingAttribution(vulnerability, component)).thenReturn(null);
+ policy.setPolicyConditions(Collections.singletonList(condition));
+
+ List violations = evaluator.evaluate(policy, component);
+ assertThat(violations).isEmpty();
+ }
+
+ @Test
+ public void testNullAttributedOnDate() {
+ attribution.setAttributedOn(null);
+ when(qm.getAllVulnerabilities(component)).thenReturn(Collections.singletonList(vulnerability));
+ when(qm.getFindingAttribution(vulnerability, component)).thenReturn(attribution);
+ policy.setPolicyConditions(Collections.singletonList(condition));
+
+ List violations = evaluator.evaluate(policy, component);
+ assertThat(violations).isEmpty();
+ }
+
+ @Test
+ public void testInvalidPeriodFormat() {
+ condition.setValue("INVALID_PERIOD");
+ policy.setPolicyConditions(Collections.singletonList(condition));
+ when(qm.getAllVulnerabilities(component)).thenReturn(Collections.singletonList(vulnerability));
+ when(qm.getFindingAttribution(vulnerability, component)).thenReturn(attribution);
+
+ List violations = evaluator.evaluate(policy, component);
+ assertThat(violations).isEmpty();
+ }
+
+ @Test
+ public void testZeroPeriod() {
+ condition.setValue("P0D");
+ policy.setPolicyConditions(Collections.singletonList(condition));
+ when(qm.getAllVulnerabilities(component)).thenReturn(Collections.singletonList(vulnerability));
+ when(qm.getFindingAttribution(vulnerability, component)).thenReturn(attribution);
+
+ List violations = evaluator.evaluate(policy, component);
+ assertThat(violations).isEmpty();
+ }
+
+ @Test
+ public void testNegativePeriod() {
+ condition.setValue("P-30D");
+ policy.setPolicyConditions(Collections.singletonList(condition));
+ when(qm.getAllVulnerabilities(component)).thenReturn(Collections.singletonList(vulnerability));
+ when(qm.getFindingAttribution(vulnerability, component)).thenReturn(attribution);
+
+ List violations = evaluator.evaluate(policy, component);
+ assertThat(violations).isEmpty();
+ }
+
+ @Test
+ public void testUnsupportedOperator() {
+ condition.setOperator(PolicyCondition.Operator.valueOf("CONTAINS"));
+ policy.setPolicyConditions(Collections.singletonList(condition));
+ when(qm.getAllVulnerabilities(component)).thenReturn(Collections.singletonList(vulnerability));
+ when(qm.getFindingAttribution(vulnerability, component)).thenReturn(attribution);
+
+ List violations = evaluator.evaluate(policy, component);
+ assertThat(violations).isEmpty();
+ }
+
+ @Test
+ public void testMultipleVulnerabilities() {
+ // Create additional vulnerabilities with different attribution dates
+ Vulnerability vuln2 = new Vulnerability();
+ vuln2.setVulnId("CVE-2023-5678");
+
+ FindingAttribution attr2 = new FindingAttribution();
+ Date oldDate = Date.from(LocalDate.now().minusDays(60)
+ .atStartOfDay(ZoneId.systemDefault()).toInstant());
+ attr2.setAttributedOn(oldDate);
+
+ condition.setOperator(PolicyCondition.Operator.NUMERIC_GREATER_THAN);
+ condition.setValue("P30D");
+ policy.setPolicyConditions(Collections.singletonList(condition));
+
+ when(qm.getAllVulnerabilities(component)).thenReturn(Arrays.asList(vulnerability, vuln2));
+ when(qm.getFindingAttribution(vulnerability, component)).thenReturn(attribution);
+ when(qm.getFindingAttribution(vuln2, component)).thenReturn(attr2);
+
+ // Set first vulnerability to 20 days ago (no violation) and second to 60 days ago (violation)
+ Date recentDate = Date.from(LocalDate.now().minusDays(20)
+ .atStartOfDay(ZoneId.systemDefault()).toInstant());
+ attribution.setAttributedOn(recentDate);
+
+ List violations = evaluator.evaluate(policy, component);
+ assertThat(violations).hasSize(1); // Only the 60-day-old vulnerability should violate
+ }
+
+ @Test
+ public void testSupportedSubject() {
+ assertThat(evaluator.supportedSubject()).isEqualTo(PolicyCondition.Subject.ATTRIBUTED_ON);
+ }
+
+ @Test
+ public void testEmptyPolicyConditions() {
+ policy.setPolicyConditions(Collections.emptyList());
+ when(qm.getAllVulnerabilities(component)).thenReturn(Collections.singletonList(vulnerability));
+
+ List violations = evaluator.evaluate(policy, component);
+ assertThat(violations).isEmpty();
+ }
+
+ @Test
+ public void testNullPolicyConditionValue() {
+ condition.setValue(null);
+ policy.setPolicyConditions(Collections.singletonList(condition));
+ when(qm.getAllVulnerabilities(component)).thenReturn(Collections.singletonList(vulnerability));
+ when(qm.getFindingAttribution(vulnerability, component)).thenReturn(attribution);
+
+ List violations = evaluator.evaluate(policy, component);
+ assertThat(violations).isEmpty();
+ }
+
+ @Test
+ public void testEmptyPolicyConditionValue() {
+ condition.setValue(" ");
+ policy.setPolicyConditions(Collections.singletonList(condition));
+ when(qm.getAllVulnerabilities(component)).thenReturn(Collections.singletonList(vulnerability));
+ when(qm.getFindingAttribution(vulnerability, component)).thenReturn(attribution);
+
+ List violations = evaluator.evaluate(policy, component);
+ assertThat(violations).isEmpty();
+ }
+}
\ No newline at end of file