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+ }
0 commit comments