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