Skip to content

Commit 5ece658

Browse files
feat: add AttributedOnPolicyEvaluator for vulnerability age-based policy evaluation
Implements production-ready evaluator with caching, error handling, and comprehensive logging. Supports ISO-8601 period formats with injectable dependencies for testing. Signed-off-by: Arjav <[email protected]>
1 parent 77050e4 commit 5ece658

File tree

3 files changed

+375
-2
lines changed

3 files changed

+375
-2
lines changed

src/main/java/org/dependencytrack/model/PolicyCondition.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@ public enum Subject {
9090
CWE,
9191
VULNERABILITY_ID,
9292
VERSION_DISTANCE,
93-
EPSS
93+
EPSS,
94+
ATTRIBUTED_ON
9495
}
9596

9697
public enum FetchGroup {
Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
1+
/*
2+
* This file is part of Dependency-Track.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
* Copyright (c) OWASP Foundation. All Rights Reserved.
18+
*/
19+
package org.dependencytrack.policy;
20+
21+
import org.dependencytrack.model.Component;
22+
import org.dependencytrack.model.FindingAttribution;
23+
import org.dependencytrack.model.Policy;
24+
import org.dependencytrack.model.PolicyCondition;
25+
import org.dependencytrack.model.Vulnerability;
26+
import org.slf4j.Logger;
27+
import org.slf4j.LoggerFactory;
28+
29+
import java.time.Clock;
30+
import java.time.DateTimeException;
31+
import java.time.LocalDate;
32+
import java.time.Period;
33+
import java.time.ZoneId;
34+
import java.time.format.DateTimeParseException;
35+
import java.util.ArrayList;
36+
import java.util.Collections;
37+
import java.util.Date;
38+
import java.util.List;
39+
import java.util.Objects;
40+
import java.util.Optional;
41+
import java.util.concurrent.ConcurrentHashMap;
42+
import java.util.concurrent.ConcurrentMap;
43+
44+
/**
45+
* Evaluates a {@link Vulnerability}'s attributed on date against a {@link Policy}.
46+
* <p>
47+
* This evaluator checks whether vulnerabilities meet age-based policy conditions
48+
* by comparing their attribution date with specified time periods.
49+
* <p>
50+
* Age values must be provided in ISO-8601 period format (e.g., "P30D" for 30 days,
51+
* "P1M" for 1 month). See {@link Period#parse(CharSequence)} for format details.
52+
*
53+
* <p>
54+
* This class is thread-safe and uses caching to improve performance for repeated
55+
* period parsing operations.
56+
*
57+
*/
58+
public class AttributedOnPolicyEvaluator extends AbstractPolicyEvaluator {
59+
60+
private static final Logger LOGGER = LoggerFactory.getLogger(AttributedOnPolicyEvaluator.class);
61+
62+
// Cache for parsed periods to avoid repeated parsing overhead
63+
private static final ConcurrentMap<String, Optional<Period>> PERIOD_CACHE = new ConcurrentHashMap<>();
64+
private static final int MAX_CACHE_SIZE = 1000;
65+
66+
// Injectable clock for testing
67+
private final Clock clock;
68+
private final ZoneId zoneId;
69+
70+
/**
71+
* Default constructor using system clock and default timezone.
72+
*/
73+
public AttributedOnPolicyEvaluator() {
74+
this(Clock.systemDefaultZone(), ZoneId.systemDefault());
75+
}
76+
77+
/**
78+
* Constructor with injectable clock and timezone for testing.
79+
*
80+
* @param clock the clock to use for date/time operations
81+
* @param zoneId the timezone to use for date conversions
82+
*/
83+
public AttributedOnPolicyEvaluator(final Clock clock, final ZoneId zoneId) {
84+
this.clock = Objects.requireNonNull(clock, "Clock cannot be null");
85+
this.zoneId = Objects.requireNonNull(zoneId, "ZoneId cannot be null");
86+
}
87+
88+
/**
89+
* {@inheritDoc}
90+
*/
91+
@Override
92+
public PolicyCondition.Subject supportedSubject() {
93+
return PolicyCondition.Subject.ATTRIBUTED_ON;
94+
}
95+
96+
/**
97+
* {@inheritDoc}
98+
*/
99+
@Override
100+
public List<PolicyConditionViolation> evaluate(final Policy policy, final Component component) {
101+
if (policy == null) {
102+
LOGGER.debug("Policy is null, returning empty violations list");
103+
return Collections.emptyList();
104+
}
105+
106+
if (component == null) {
107+
LOGGER.debug("Component is null, returning empty violations list");
108+
return Collections.emptyList();
109+
}
110+
111+
try {
112+
final List<PolicyCondition> policyConditions = extractSupportedConditions(policy);
113+
if (policyConditions.isEmpty()) {
114+
LOGGER.debug("No supported policy conditions found for policy: {}", policy.getName());
115+
return Collections.emptyList();
116+
}
117+
118+
final List<Vulnerability> vulnerabilities = getVulnerabilities(component);
119+
if (vulnerabilities.isEmpty()) {
120+
LOGGER.debug("No vulnerabilities found for component: {} ({})",
121+
component.getName(), component.getUuid());
122+
return Collections.emptyList();
123+
}
124+
125+
LOGGER.debug("Evaluating {} vulnerabilities against {} policy conditions for component: {} ({})",
126+
vulnerabilities.size(), policyConditions.size(), component.getName(), component.getUuid());
127+
128+
return evaluateVulnerabilities(vulnerabilities, policyConditions, component);
129+
130+
} catch (final Exception e) {
131+
LOGGER.error("Unexpected error during policy evaluation for component: {} ({})",
132+
component.getName(), component.getUuid(), e);
133+
return Collections.emptyList();
134+
}
135+
}
136+
137+
/**
138+
* Retrieves all vulnerabilities for the given component.
139+
*
140+
* @param component the component to get vulnerabilities for
141+
* @return list of vulnerabilities, never null
142+
*/
143+
private List<Vulnerability> getVulnerabilities(final Component component) {
144+
try {
145+
final List<Vulnerability> vulnerabilities = qm.getAllVulnerabilities(component);
146+
return vulnerabilities != null ? vulnerabilities : Collections.emptyList();
147+
} catch (final Exception e) {
148+
LOGGER.warn("Failed to retrieve vulnerabilities for component: {} ({})",
149+
component.getName(), component.getUuid(), e);
150+
return Collections.emptyList();
151+
}
152+
}
153+
154+
/**
155+
* Evaluates vulnerabilities against policy conditions.
156+
*
157+
* @param vulnerabilities the vulnerabilities to evaluate
158+
* @param policyConditions the policy conditions to check against
159+
* @param component the component being evaluated
160+
* @return list of policy violations
161+
*/
162+
private List<PolicyConditionViolation> evaluateVulnerabilities(
163+
final List<Vulnerability> vulnerabilities,
164+
final List<PolicyCondition> policyConditions,
165+
final Component component) {
166+
167+
final List<PolicyConditionViolation> violations = new ArrayList<>();
168+
int processedVulnerabilities = 0;
169+
int skippedVulnerabilities = 0;
170+
171+
for (final Vulnerability vulnerability : vulnerabilities) {
172+
try {
173+
final Optional<Date> attributedOnDate = getAttributedOnDate(vulnerability, component);
174+
if (attributedOnDate.isEmpty()) {
175+
skippedVulnerabilities++;
176+
continue;
177+
}
178+
179+
processedVulnerabilities++;
180+
181+
for (final PolicyCondition condition : policyConditions) {
182+
try {
183+
if (evaluateCondition(condition, attributedOnDate.get())) {
184+
final PolicyConditionViolation violation = new PolicyConditionViolation(condition, component);
185+
violations.add(violation);
186+
LOGGER.debug("Policy violation found: vulnerability {} violates condition {} for component {}",
187+
vulnerability.getVulnId(), condition.getUuid(), component.getUuid());
188+
}
189+
} catch (final Exception e) {
190+
LOGGER.warn("Failed to evaluate condition {} for vulnerability {} on component {}: {}",
191+
condition.getUuid(), vulnerability.getVulnId(), component.getUuid(), e.getMessage());
192+
}
193+
}
194+
} catch (final Exception e) {
195+
LOGGER.warn("Failed to process vulnerability {} for component {}: {}",
196+
vulnerability.getVulnId(), component.getUuid(), e.getMessage());
197+
skippedVulnerabilities++;
198+
}
199+
}
200+
201+
LOGGER.debug("Evaluation complete: {} violations found, {} vulnerabilities processed, {} skipped",
202+
violations.size(), processedVulnerabilities, skippedVulnerabilities);
203+
204+
return violations;
205+
}
206+
207+
/**
208+
* Extracts the attributed on date from a vulnerability.
209+
*
210+
* @param vulnerability the vulnerability to extract the date from
211+
* @param component the component associated with the vulnerability
212+
* @return the attributed on date wrapped in Optional, empty if not available
213+
*/
214+
private Optional<Date> getAttributedOnDate(final Vulnerability vulnerability, final Component component) {
215+
if (vulnerability == null) {
216+
LOGGER.debug("Vulnerability is null, cannot extract attributed on date");
217+
return Optional.empty();
218+
}
219+
220+
try {
221+
final FindingAttribution attribution = qm.getFindingAttribution(vulnerability, component);
222+
if (attribution == null) {
223+
LOGGER.debug("No finding attribution found for vulnerability {} on component {}",
224+
vulnerability.getVulnId(), component.getUuid());
225+
return Optional.empty();
226+
}
227+
228+
final Date attributedOn = attribution.getAttributedOn();
229+
return attributedOn != null ? Optional.of(attributedOn) : Optional.empty();
230+
231+
} catch (final Exception e) {
232+
LOGGER.warn("Failed to retrieve finding attribution for vulnerability {} on component {}: {}",
233+
vulnerability.getVulnId(), component.getUuid(), e.getMessage());
234+
return Optional.empty();
235+
}
236+
}
237+
238+
/**
239+
* Evaluates a single policy condition against an attributed on date.
240+
*
241+
* @param condition the policy condition to evaluate
242+
* @param attributedOn the date when the vulnerability was attributed
243+
* @return true if the condition is violated, false otherwise
244+
* @throws IllegalArgumentException if condition or attributedOn is null
245+
* @throws DateTimeException if evaluation fails due to invalid data
246+
*/
247+
private boolean evaluateCondition(final PolicyCondition condition, final Date attributedOn) {
248+
Objects.requireNonNull(condition, "Policy condition cannot be null");
249+
Objects.requireNonNull(attributedOn, "Attributed on date cannot be null");
250+
251+
final Optional<Period> agePeriod = parseAgePeriod(condition);
252+
if (agePeriod.isEmpty()) {
253+
LOGGER.warn("Failed to parse age period from condition value: {}", condition.getValue());
254+
return false;
255+
}
256+
257+
if (!isValidAgePeriod(agePeriod.get(), condition.getValue())) {
258+
LOGGER.warn("Invalid age period in condition: {} (parsed as: {})",
259+
condition.getValue(), agePeriod.get());
260+
return false;
261+
}
262+
263+
return evaluateAgeCondition(condition, attributedOn, agePeriod.get());
264+
}
265+
266+
/**
267+
* Parses the age period from the policy condition value with caching.
268+
*
269+
* @param condition the policy condition containing the period string
270+
* @return the parsed period wrapped in Optional, empty if parsing fails
271+
*/
272+
private Optional<Period> parseAgePeriod(final PolicyCondition condition) {
273+
final String periodValue = condition.getValue();
274+
if (periodValue == null || periodValue.trim().isEmpty()) {
275+
LOGGER.debug("Policy condition value is null or empty");
276+
return Optional.empty();
277+
}
278+
279+
// Check cache first
280+
Optional<Period> cachedPeriod = PERIOD_CACHE.get(periodValue);
281+
if (cachedPeriod != null) {
282+
return cachedPeriod;
283+
}
284+
285+
// Parse and cache result
286+
try {
287+
final Period period = Period.parse(periodValue.trim());
288+
cachedPeriod = Optional.of(period);
289+
} catch (final DateTimeParseException e) {
290+
LOGGER.debug("Failed to parse period value '{}': {}", periodValue, e.getMessage());
291+
cachedPeriod = Optional.empty();
292+
}
293+
294+
// Cache with size limit
295+
if (PERIOD_CACHE.size() < MAX_CACHE_SIZE) {
296+
PERIOD_CACHE.put(periodValue, cachedPeriod);
297+
}
298+
299+
return cachedPeriod;
300+
}
301+
302+
/**
303+
* Validates that the age period is positive and non-zero.
304+
*
305+
* @param agePeriod the period to validate
306+
* @param originalValue the original string value for logging
307+
* @return true if the period is valid, false otherwise
308+
*/
309+
private boolean isValidAgePeriod(final Period agePeriod, final String originalValue) {
310+
if (agePeriod.isZero()) {
311+
LOGGER.debug("Age period is zero: {}", originalValue);
312+
return false;
313+
}
314+
315+
if (agePeriod.isNegative()) {
316+
LOGGER.debug("Age period is negative: {}", originalValue);
317+
return false;
318+
}
319+
320+
return true;
321+
}
322+
323+
/**
324+
* Evaluates the age-based condition using the specified operator.
325+
*
326+
* @param condition the policy condition with the operator
327+
* @param attributedOn the attribution date
328+
* @param agePeriod the age period to add to the attribution date
329+
* @return true if the condition is met, false otherwise
330+
* @throws DateTimeException if date conversion fails
331+
*/
332+
private boolean evaluateAgeCondition(final PolicyCondition condition, final Date attributedOn, final Period agePeriod) {
333+
try {
334+
final LocalDate attributedOnDate = convertToLocalDate(attributedOn);
335+
final LocalDate ageDate = attributedOnDate.plus(agePeriod);
336+
final LocalDate today = LocalDate.now(clock);
337+
338+
return switch (condition.getOperator()) {
339+
case NUMERIC_GREATER_THAN -> ageDate.isBefore(today);
340+
case NUMERIC_GREATER_THAN_OR_EQUAL -> !ageDate.isAfter(today);
341+
case NUMERIC_EQUAL -> ageDate.isEqual(today);
342+
case NUMERIC_NOT_EQUAL -> !ageDate.isEqual(today);
343+
case NUMERIC_LESSER_THAN_OR_EQUAL -> !ageDate.isBefore(today);
344+
case NUMERIC_LESS_THAN -> ageDate.isAfter(today);
345+
default -> {
346+
LOGGER.warn("Unsupported operator for age-based condition: {}", condition.getOperator());
347+
yield false;
348+
}
349+
};
350+
} catch (final DateTimeException e) {
351+
LOGGER.error("Failed to evaluate age condition: {}", e.getMessage(), e);
352+
throw new DateTimeException("Date/time evaluation failed", e);
353+
}
354+
}
355+
356+
/**
357+
* Converts a Date to LocalDate using the configured timezone.
358+
*
359+
* @param date the date to convert
360+
* @return the corresponding LocalDate
361+
* @throws DateTimeException if conversion fails
362+
*/
363+
private LocalDate convertToLocalDate(final Date date) {
364+
try {
365+
return LocalDate.ofInstant(date.toInstant(), zoneId);
366+
} catch (final DateTimeException e) {
367+
LOGGER.error("Failed to convert date to LocalDate: {}", date, e);
368+
throw e;
369+
}
370+
}
371+
}

src/main/java/org/dependencytrack/policy/PolicyEngine.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ public PolicyEngine() {
6161
evaluators.add(new VulnerabilityIdPolicyEvaluator());
6262
evaluators.add(new VersionDistancePolicyEvaluator());
6363
evaluators.add(new EpssPolicyEvaluator());
64+
evaluators.add(new AttributedOnPolicyEvaluator());
6465
}
6566

6667
public List<PolicyViolation> evaluate(final List<Component> components) {
@@ -145,7 +146,7 @@ public PolicyViolation.Type determineViolationType(final PolicyCondition.Subject
145146
}
146147
return switch (subject) {
147148
case CWE, SEVERITY, VULNERABILITY_ID, EPSS -> PolicyViolation.Type.SECURITY;
148-
case AGE, COORDINATES, PACKAGE_URL, CPE, SWID_TAGID, COMPONENT_HASH, VERSION, VERSION_DISTANCE ->
149+
case AGE, COORDINATES, PACKAGE_URL, CPE, SWID_TAGID, COMPONENT_HASH, VERSION, VERSION_DISTANCE, ATTRIBUTED_ON ->
149150
PolicyViolation.Type.OPERATIONAL;
150151
case LICENSE, LICENSE_GROUP -> PolicyViolation.Type.LICENSE;
151152
};

0 commit comments

Comments
 (0)