Repository: DependencyTrack/dependency-track (java)

Issue:
Policy: Add support for component age in policy conditions
Extend functionality implemented in #83 to support component age in policy conditions.


Pull request description:
Add support for component age policy conditions
### Description

<!-- REQUIRED
    Provide a concise description of your change. What does it do? Why is it necessary?
    As a guideline, think about how you would describe your change if you were to write a changelog entry for it.
-->

This PR implements a policy condition for component age.

The age can be provided in [ISO-8601 period format](https://en.wikipedia.org/wiki/ISO_8601#Durations), e.g.:

* `P1Y`: 1 year
* `P2Y3M`: 2 years, 3 months
* `P7D`: 7 days

Lowest allowed time unit is days. Specifying hours, minutes, or seconds is not allowed.
This limitation is enforced by Java's [`Period`](https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Period.html) class, which is used behind the scenes.

The frontend will show a tooltip to let users know about the format.

<img width="1267" alt="image" src="https://user-images.githubusercontent.com/5693141/215279293-7c97ca67-4347-4eda-9ebc-b9716ce68035.png">

### Addressed Issue

<!-- REQUIRED
    Reference the issue addressed by this PR, e.g. `#1234`.
    Use keywords like `closes` or `fixes` to signal that this PR resolves the issue,
    causing the issue to be closed automatically when the PR is merged:
        https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword
-->

Closes #772

### Additional Details

<!-- OPTIONAL
    If desired, share more technical details about the change here.
    Elaborating on why you implemented the change the way you did can be super helpful to the reviewer.
    Did you consider other solutions? Any problems you ran into along the way?
-->

We have to rely on external package repositories to provide the publish date of components. 

Not all repositories support this. In particular, for Rubygems and NPM components we currently do not get the publish date.

### Checklist

<!-- REQUIRED
    Mark items in this list as done by adding a `x` between the square brackets.
    Non-applicable items may be marked as such by surrounding their text with tildes (`~`).

    This is not meant to be a strict to-do list. If you're unsure about anything,
    just leave it empty for now. The maintainers are happy to assist you in figuring it out!
-->

- [x] I have read and understand the [contributing guidelines](https://github.com/DependencyTrack/dependency-track/blob/github-templates/CONTRIBUTING.md#pull-requests)
- [ ] ~This PR fixes a defect, and I have provided tests to verify that the fix is effective~
- [x] This PR implements an enhancement, and I have provided tests to verify that it works as intended
- [ ] ~This PR introduces changes to the database model, and I have added corresponding [update logic](https://github.com/DependencyTrack/dependency-track/tree/master/src/main/java/org/dependencytrack/upgrade)~
- [ ] ~This PR introduces new or alters existing behavior, and I have updated the [documentation](https://github.com/DependencyTrack/dependency-track/tree/master/docs/_docs) accordingly~


Patch:
diff --git a/.lift.toml b/.lift.toml
deleted file mode 100644
index 1add3694e..000000000
--- a/.lift.toml
+++ /dev/null
@@ -1,5 +0,0 @@
-ignoreRules = [
-    "UnnecessaryFinal", # Being explicit with final is a good thing
-    "Var",              # Recommends @Var annotations instead of final
-    "YodaCondition"     # Encourages behavior that could lead to NPEs
-]
diff --git a/src/main/java/org/dependencytrack/model/PolicyCondition.java b/src/main/java/org/dependencytrack/model/PolicyCondition.java
index b9a670302..644e2b23e 100644
--- a/src/main/java/org/dependencytrack/model/PolicyCondition.java
+++ b/src/main/java/org/dependencytrack/model/PolicyCondition.java
@@ -63,7 +63,7 @@ public class PolicyCondition implements Serializable {
     }
 
     public enum Subject {
-        //AGE,
+        AGE,
         //ANALYZER,
         //BOM,
         COORDINATES,
diff --git a/src/main/java/org/dependencytrack/policy/ComponentAgePolicyEvaluator.java b/src/main/java/org/dependencytrack/policy/ComponentAgePolicyEvaluator.java
new file mode 100644
index 000000000..1bb40f5d1
--- /dev/null
+++ b/src/main/java/org/dependencytrack/policy/ComponentAgePolicyEvaluator.java
@@ -0,0 +1,122 @@
+/*
+ * 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) Steve Springett. All Rights Reserved.
+ */
+package org.dependencytrack.policy;
+
+import alpine.common.logging.Logger;
+import org.dependencytrack.model.Component;
+import org.dependencytrack.model.Policy;
+import org.dependencytrack.model.PolicyCondition;
+import org.dependencytrack.model.RepositoryMetaComponent;
+import org.dependencytrack.model.RepositoryType;
+import org.dependencytrack.persistence.QueryManager;
+
+import java.time.LocalDate;
+import java.time.Period;
+import java.time.ZoneId;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * Evaluates a {@link Component}'s published date against a {@link Policy}.
+ * <p>
+ * Age values can be provided in ISO-8601 period format, see {@link Period#parse(CharSequence)}.
+ *
+ * @since 4.8.0
+ */
+public class ComponentAgePolicyEvaluator extends AbstractPolicyEvaluator {
+
+    private static final Logger LOGGER = Logger.getLogger(ComponentAgePolicyEvaluator.class);
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public PolicyCondition.Subject supportedSubject() {
+        return PolicyCondition.Subject.AGE;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public List<PolicyConditionViolation> evaluate(final Policy policy, final Component component) {
+        final var violations = new ArrayList<PolicyConditionViolation>();
+        if (component.getPurl() == null) {
+            return violations;
+        }
+
+        final RepositoryType repoType = RepositoryType.resolve(component.getPurl());
+        if (RepositoryType.UNSUPPORTED == repoType) {
+            return violations;
+        }
+
+        final RepositoryMetaComponent metaComponent;
+        try (final var qm = new QueryManager()) {
+            metaComponent = qm.getRepositoryMetaComponent(repoType,
+                    component.getPurl().getNamespace(), component.getPurl().getName());
+            qm.getPersistenceManager().detachCopy(metaComponent);
+        }
+        if (metaComponent == null || metaComponent.getPublished() == null) {
+            return violations;
+        }
+
+        for (final PolicyCondition condition : super.extractSupportedConditions(policy)) {
+            if (evaluate(condition, metaComponent.getPublished())) {
+                violations.add(new PolicyConditionViolation(condition, component));
+            }
+        }
+
+        return violations;
+    }
+
+    private boolean evaluate(final PolicyCondition condition, final Date published) {
+        final Period agePeriod;
+        try {
+            agePeriod = Period.parse(condition.getValue());
+        } catch (DateTimeParseException e) {
+            LOGGER.error("Invalid age duration format", e);
+            return false;
+        }
+
+        if (agePeriod.isZero() || agePeriod.isNegative()) {
+            LOGGER.warn("Age durations must not be zero or negative");
+            return false;
+        }
+
+        final LocalDate publishedDate = LocalDate.ofInstant(published.toInstant(), ZoneId.systemDefault());
+        final LocalDate ageDate = publishedDate.plus(agePeriod);
+        final LocalDate today = LocalDate.now();
+
+        return switch (condition.getOperator()) {
+            case NUMERIC_GREATER_THAN -> ageDate.isBefore(today);
+            case NUMERIC_GREATER_THAN_OR_EQUAL -> ageDate.isEqual(today) || ageDate.isBefore(today);
+            case NUMERIC_EQUAL -> ageDate.isEqual(today);
+            case NUMERIC_NOT_EQUAL -> !ageDate.isEqual(today);
+            case NUMERIC_LESSER_THAN_OR_EQUAL -> ageDate.isEqual(today) || ageDate.isAfter(today);
+            case NUMERIC_LESS_THAN -> ageDate.isAfter(LocalDate.now());
+            default -> {
+                LOGGER.warn("Operator %s is not supported for component age conditions".formatted(condition.getOperator()));
+                yield false;
+            }
+        };
+    }
+
+}
diff --git a/src/main/java/org/dependencytrack/policy/CoordinatesPolicyEvaluator.java b/src/main/java/org/dependencytrack/policy/CoordinatesPolicyEvaluator.java
index 8c7bb0a3e..ab099c273 100644
--- a/src/main/java/org/dependencytrack/policy/CoordinatesPolicyEvaluator.java
+++ b/src/main/java/org/dependencytrack/policy/CoordinatesPolicyEvaluator.java
@@ -74,11 +74,22 @@ public class CoordinatesPolicyEvaluator extends AbstractPolicyEvaluator {
             return true;
         }
         final String p = StringUtils.trimToNull(part);
-        if (p != null) {
-            if (PolicyCondition.Operator.MATCHES == operator) {
-                return org.dependencytrack.policy.Matcher.matches(p, conditionValue);
-            } else if (PolicyCondition.Operator.NO_MATCH == operator) {
-                return !org.dependencytrack.policy.Matcher.matches(p, conditionValue);
+        if (PolicyCondition.Operator.MATCHES == operator) {
+            if (p != null) {
+                if ("*".equals(conditionValue)) {
+                    return true;
+                } else if (conditionValue != null && p.contains(conditionValue)) {
+                    return true;
+                }
+            }
+        } else if (PolicyCondition.Operator.NO_MATCH == operator) {
+            if (p != null) {
+                if ("*".equals(conditionValue)) {
+                    return false;
+                } else if (conditionValue != null && p.contains(conditionValue)) {
+                    return false;
+                }
+                return true;
             }
         }
         return false;
diff --git a/src/main/java/org/dependencytrack/policy/CpePolicyEvaluator.java b/src/main/java/org/dependencytrack/policy/CpePolicyEvaluator.java
index cca5c3ddb..7e6449a77 100644
--- a/src/main/java/org/dependencytrack/policy/CpePolicyEvaluator.java
+++ b/src/main/java/org/dependencytrack/policy/CpePolicyEvaluator.java
@@ -56,11 +56,11 @@ public class CpePolicyEvaluator extends AbstractPolicyEvaluator {
         for (final PolicyCondition condition: super.extractSupportedConditions(policy)) {
             LOGGER.debug("Evaluating component (" + component.getUuid() + ") against policy condition (" + condition.getUuid() + ")");
             if (PolicyCondition.Operator.MATCHES == condition.getOperator()) {
-                if (Matcher.matches(component.getCpe(), condition.getValue())) {
+                if (component.getCpe() != null && component.getCpe().contains(condition.getValue())) {
                     violations.add(new PolicyConditionViolation(condition, component));
                 }
             } else if (PolicyCondition.Operator.NO_MATCH == condition.getOperator()) {
-                if (!Matcher.matches(component.getCpe(), condition.getValue())) {
+                if (component.getCpe() != null && !component.getCpe().contains(condition.getValue())) {
                     violations.add(new PolicyConditionViolation(condition, component));
                 }
             }
diff --git a/src/main/java/org/dependencytrack/policy/Matcher.java b/src/main/java/org/dependencytrack/policy/Matcher.java
deleted file mode 100644
index e1dd9857f..000000000
--- a/src/main/java/org/dependencytrack/policy/Matcher.java
+++ /dev/null
@@ -1,58 +0,0 @@
-/*
- * 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) Steve Springett. All Rights Reserved.
- */
-package org.dependencytrack.policy;
-
-/**
- * Reusable methods that PolicyEvaluator implementations can extend.
- *
- * @author Roland Asmann
- * @since 4.8.0
- */
-public final class Matcher {
-
-    private Matcher() {
-      // Utility-class should not be instantiated
-    }
-
-    /**
-     * Check if the given value matches with the conditionString. If the
-     * conditionString is not a regular expression, turn it into one.
-     * 
-     * @param value           The value to match against
-     * @param conditionString The condition that should match -- may or may not be a
-     *                        regular expression
-     * @return <code>true</code> if the value matches the conditionString
-     */
-    static boolean matches(String value, String conditionString) {
-        if (value == null && conditionString == null) {
-            return true;
-        }
-        if (value == null ^ conditionString == null) {
-            return false;
-        }
-        conditionString = conditionString.replace("*", ".*").replace("..*", ".*");
-        if (!conditionString.startsWith("^") && !conditionString.startsWith(".*")) {
-            conditionString = ".*" + conditionString;
-        }
-        if (!conditionString.endsWith("$") && !conditionString.endsWith(".*")) {
-            conditionString += ".*";
-        }
-        return value.matches(conditionString);
-    }
-}
diff --git a/src/main/java/org/dependencytrack/policy/PackageURLPolicyEvaluator.java b/src/main/java/org/dependencytrack/policy/PackageURLPolicyEvaluator.java
index b09351408..98bfe514f 100644
--- a/src/main/java/org/dependencytrack/policy/PackageURLPolicyEvaluator.java
+++ b/src/main/java/org/dependencytrack/policy/PackageURLPolicyEvaluator.java
@@ -56,11 +56,11 @@ public class PackageURLPolicyEvaluator extends AbstractPolicyEvaluator {
         for (final PolicyCondition condition: super.extractSupportedConditions(policy)) {
             LOGGER.debug("Evaluating component (" + component.getUuid() + ") against policy condition (" + condition.getUuid() + ")");
             if (PolicyCondition.Operator.MATCHES == condition.getOperator()) {
-                if (Matcher.matches(component.getPurl().canonicalize(), condition.getValue())) {
+                if (component.getPurl().canonicalize().contains(condition.getValue())) {
                     violations.add(new PolicyConditionViolation(condition, component));
                 }
             } else if (PolicyCondition.Operator.NO_MATCH == condition.getOperator()) {
-                if (!Matcher.matches(component.getPurl().canonicalize(), condition.getValue())) {
+                if (!component.getPurl().canonicalize().contains(condition.getValue())) {
                     violations.add(new PolicyConditionViolation(condition, component));
                 }
             }
diff --git a/src/main/java/org/dependencytrack/policy/PolicyEngine.java b/src/main/java/org/dependencytrack/policy/PolicyEngine.java
index ce8821c67..a9b8e6c8f 100644
--- a/src/main/java/org/dependencytrack/policy/PolicyEngine.java
+++ b/src/main/java/org/dependencytrack/policy/PolicyEngine.java
@@ -55,6 +55,7 @@ public class PolicyEngine {
         evaluators.add(new CpePolicyEvaluator());
         evaluators.add(new SwidTagIdPolicyEvaluator());
         evaluators.add(new VersionPolicyEvaluator());
+        evaluators.add(new ComponentAgePolicyEvaluator());
         evaluators.add(new ComponentHashPolicyEvaluator());
         evaluators.add(new CwePolicyEvaluator());
     }
@@ -66,7 +67,7 @@ public class PolicyEngine {
             final List<Policy> policies = qm.getAllPolicies();
             for (final Component c: components) {
                 final Component component = qm.getObjectById(Component.class, c.getId());
-                violations.addAll(this.evaluate(qm, policies, component));
+                violations = this.evaluate(qm, policies, component);
             }
         }
         LOGGER.info("Policy analysis complete");
@@ -80,19 +81,16 @@ public class PolicyEngine {
                     || isPolicyAssignedToProjectTag(policy, component.getProject())) {
                 LOGGER.debug("Evaluating component (" + component.getUuid() +") against policy (" + policy.getUuid() + ")");
                 final List<PolicyConditionViolation> policyConditionViolations = new ArrayList<>();
-                int policyConditionsViolated = 0;
                 for (final PolicyEvaluator evaluator : evaluators) {
                     evaluator.setQueryManager(qm);
-                    if (policyConditionViolations.addAll(evaluator.evaluate(policy, component))) {
-                        policyConditionsViolated++;
-                    }
+                    policyConditionViolations.addAll(evaluator.evaluate(policy, component));
                 }
                 if (Policy.Operator.ANY == policy.getOperator()) {
-                    if (policyConditionsViolated > 0) {
+                    if (policyConditionViolations.size() > 0) {
                         policyViolations.addAll(createPolicyViolations(qm, policyConditionViolations));
                     }
                 } else if (Policy.Operator.ALL == policy.getOperator()) {
-                    if (policyConditionsViolated == policy.getPolicyConditions().size()) {
+                    if (policyConditionViolations.size() == policy.getPolicyConditions().size()) {
                         policyViolations.addAll(createPolicyViolations(qm, policyConditionViolations));
                     }
                 }
@@ -130,6 +128,7 @@ public class PolicyEngine {
             case CWE:
             case SEVERITY:
                 return PolicyViolation.Type.SECURITY;
+            case AGE:
             case COORDINATES:
             case PACKAGE_URL:
             case CPE:
diff --git a/src/main/java/org/dependencytrack/policy/SwidTagIdPolicyEvaluator.java b/src/main/java/org/dependencytrack/policy/SwidTagIdPolicyEvaluator.java
index b8c9f5374..43f735b7a 100644
--- a/src/main/java/org/dependencytrack/policy/SwidTagIdPolicyEvaluator.java
+++ b/src/main/java/org/dependencytrack/policy/SwidTagIdPolicyEvaluator.java
@@ -56,11 +56,11 @@ public class SwidTagIdPolicyEvaluator extends AbstractPolicyEvaluator {
         for (final PolicyCondition condition: super.extractSupportedConditions(policy)) {
             LOGGER.debug("Evaluating component (" + component.getUuid() + ") against policy condition (" + condition.getUuid() + ")");
             if (PolicyCondition.Operator.MATCHES == condition.getOperator()) {
-                if (Matcher.matches(component.getSwidTagId(), condition.getValue())) {
+                if (component.getSwidTagId() != null && component.getSwidTagId().contains(condition.getValue())) {
                     violations.add(new PolicyConditionViolation(condition, component));
                 }
             } else if (PolicyCondition.Operator.NO_MATCH == condition.getOperator()) {
-                if (!Matcher.matches(component.getSwidTagId(), condition.getValue())) {
+                if (component.getSwidTagId() != null && !component.getSwidTagId().contains(condition.getValue())) {
                     violations.add(new PolicyConditionViolation(condition, component));
                 }
             }
diff --git a/src/main/java/org/dependencytrack/tasks/NistMirrorTask.java b/src/main/java/org/dependencytrack/tasks/NistMirrorTask.java
index 2d8ac0d32..0c58fc628 100644
--- a/src/main/java/org/dependencytrack/tasks/NistMirrorTask.java
+++ b/src/main/java/org/dependencytrack/tasks/NistMirrorTask.java
@@ -82,7 +82,7 @@ public class NistMirrorTask implements LoggableSubscriber {
     private static final String CVE_JSON_11_MODIFIED_META = "/json/cve/1.1/nvdcve-1.1-modified.meta";
     private static final String CVE_JSON_11_BASE_META = "/json/cve/1.1/nvdcve-1.1-%d.meta";
     private static final int START_YEAR = 2002;
-    private final int endYear = Calendar.getInstance().get(Calendar.YEAR);
+    private static final int END_YEAR = Calendar.getInstance().get(Calendar.YEAR);
 
     private final boolean isEnabled;
     private String nvdFeedsUrl;
@@ -132,7 +132,7 @@ public class NistMirrorTask implements LoggableSubscriber {
         LOGGER.info("Downloading files at " + currentDate);
         doDownload(this.nvdFeedsUrl + CVE_JSON_11_MODIFIED_URL, ResourceType.CVE_MODIFIED_DATA);
         doDownload(this.nvdFeedsUrl + CVE_JSON_11_MODIFIED_META, ResourceType.CVE_META);
-        for (int i = endYear; i >= START_YEAR; i--) {
+        for (int i = END_YEAR; i >= START_YEAR; i--) {
             // Download JSON 1.1 year feeds in reverse order
             final String json11BaseUrl = this.nvdFeedsUrl + CVE_JSON_11_BASE_URL.replace("%d", String.valueOf(i));
             final String cve11BaseMetaUrl = this.nvdFeedsUrl + CVE_JSON_11_BASE_META.replace("%d", String.valueOf(i));


Test patch:
diff --git a/src/test/java/org/dependencytrack/policy/ComponentAgePolicyEvaluatorTest.java b/src/test/java/org/dependencytrack/policy/ComponentAgePolicyEvaluatorTest.java
new file mode 100644
index 000000000..d5bde3b14
--- /dev/null
+++ b/src/test/java/org/dependencytrack/policy/ComponentAgePolicyEvaluatorTest.java
@@ -0,0 +1,124 @@
+/*
+ * 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) Steve Springett. All Rights Reserved.
+ */
+package org.dependencytrack.policy;
+
+import org.dependencytrack.PersistenceCapableTest;
+import org.dependencytrack.model.Component;
+import org.dependencytrack.model.Policy;
+import org.dependencytrack.model.PolicyCondition.Operator;
+import org.dependencytrack.model.PolicyCondition.Subject;
+import org.dependencytrack.model.RepositoryMetaComponent;
+import org.dependencytrack.model.RepositoryType;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Date;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@RunWith(Parameterized.class)
+public class ComponentAgePolicyEvaluatorTest extends PersistenceCapableTest {
+
+    @Parameterized.Parameters(name = "[{index}] publishedDate={0} operator={1} ageValue={2} shouldViolate={3}")
+    public static Collection<?> testParameters() {
+        return Arrays.asList(new Object[][]{
+                // Component is older by one day.
+                {Instant.now().minus(Duration.ofDays(667)), Operator.NUMERIC_GREATER_THAN, "P666D", true},
+                {Instant.now().minus(Duration.ofDays(667)), Operator.NUMERIC_GREATER_THAN_OR_EQUAL, "P666D", true},
+                {Instant.now().minus(Duration.ofDays(667)), Operator.NUMERIC_EQUAL, "P666D", false},
+                {Instant.now().minus(Duration.ofDays(667)), Operator.NUMERIC_NOT_EQUAL, "P666D", true},
+                {Instant.now().minus(Duration.ofDays(667)), Operator.NUMERIC_LESSER_THAN_OR_EQUAL, "P666D", false},
+                {Instant.now().minus(Duration.ofDays(667)), Operator.NUMERIC_LESS_THAN, "P666D", false},
+                // Component is newer by one day.
+                {Instant.now().minus(Duration.ofDays(665)), Operator.NUMERIC_GREATER_THAN, "P666D", false},
+                {Instant.now().minus(Duration.ofDays(665)), Operator.NUMERIC_GREATER_THAN_OR_EQUAL, "P666D", false},
+                {Instant.now().minus(Duration.ofDays(665)), Operator.NUMERIC_EQUAL, "P666D", false},
+                {Instant.now().minus(Duration.ofDays(665)), Operator.NUMERIC_NOT_EQUAL, "P666D", true},
+                {Instant.now().minus(Duration.ofDays(665)), Operator.NUMERIC_LESS_THAN, "P666D", true},
+                // Component is exactly as old.
+                {Instant.now().minus(Duration.ofDays(666)), Operator.NUMERIC_GREATER_THAN, "P666D", false},
+                {Instant.now().minus(Duration.ofDays(666)), Operator.NUMERIC_GREATER_THAN_OR_EQUAL, "P666D", true},
+                {Instant.now().minus(Duration.ofDays(666)), Operator.NUMERIC_EQUAL, "P666D", true},
+                {Instant.now().minus(Duration.ofDays(666)), Operator.NUMERIC_NOT_EQUAL, "P666D", false},
+                {Instant.now().minus(Duration.ofDays(666)), Operator.NUMERIC_LESSER_THAN_OR_EQUAL, "P666D", true},
+                {Instant.now().minus(Duration.ofDays(666)), Operator.NUMERIC_LESS_THAN, "P666D", false},
+                // Unsupported operator.
+                {Instant.now().minus(Duration.ofDays(666)), Operator.MATCHES, "P666D", false},
+                // Negative age period.
+                {Instant.now().minus(Duration.ofDays(666)), Operator.NUMERIC_EQUAL, "P-666D", false},
+                // Invalid age period format.
+                {Instant.now().minus(Duration.ofDays(666)), Operator.NUMERIC_EQUAL, "foobar", false},
+                // No known publish date.
+                {null, Operator.NUMERIC_EQUAL, "P666D", false},
+        });
+    }
+
+    private final Instant publishedDate;
+    private final Operator operator;
+    private final String ageValue;
+    private final boolean shouldViolate;
+
+    public ComponentAgePolicyEvaluatorTest(final Instant publishedDate, final Operator operator,
+                                           final String ageValue, final boolean shouldViolate) {
+        this.publishedDate = publishedDate;
+        this.operator = operator;
+        this.ageValue = ageValue;
+        this.shouldViolate = shouldViolate;
+    }
+
+    @Test
+    public void evaluateTest() {
+        final var policy = qm.createPolicy("policy", Policy.Operator.ANY, Policy.ViolationState.FAIL);
+        final var condition = qm.createPolicyCondition(policy, Subject.AGE, operator, ageValue);
+
+        final var metaComponent = new RepositoryMetaComponent();
+        metaComponent.setRepositoryType(RepositoryType.MAVEN);
+        metaComponent.setNamespace("foo");
+        metaComponent.setName("bar");
+        metaComponent.setLatestVersion("6.6.6");
+        if (publishedDate != null) {
+            metaComponent.setPublished(Date.from(publishedDate));
+        }
+        metaComponent.setLastCheck(new Date());
+        qm.persist(metaComponent);
+
+        final var component = new Component();
+        component.setPurl("pkg:maven/foo/bar@1.2.3");
+
+        final var evaluator = new ComponentAgePolicyEvaluator();
+        evaluator.setQueryManager(qm);
+
+        final List<PolicyConditionViolation> violations = evaluator.evaluate(policy, component);
+        if (shouldViolate) {
+            assertThat(violations).hasSize(1);
+            final PolicyConditionViolation violation = violations.get(0);
+            assertThat(violation.getComponent()).isEqualTo(component);
+            assertThat(violation.getPolicyCondition()).isEqualTo(condition);
+        } else {
+            assertThat(violations).isEmpty();
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/src/test/java/org/dependencytrack/policy/MatcherTest.java b/src/test/java/org/dependencytrack/policy/MatcherTest.java
deleted file mode 100644
index 4af3246cf..000000000
--- a/src/test/java/org/dependencytrack/policy/MatcherTest.java
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * 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) Steve Springett. All Rights Reserved.
- */
-package org.dependencytrack.policy;
-
-import org.junit.Assert;
-import org.junit.Test;
-
-public class MatcherTest {
-
-    @Test
-    public void checkNulls() {
-        Assert.assertTrue(Matcher.matches(null, null));
-        Assert.assertFalse(Matcher.matches("", null));
-        Assert.assertFalse(Matcher.matches(null, ""));
-    }
-
-    @Test
-    public void checkExact() {
-        Assert.assertTrue(Matcher.matches("", ""));
-        Assert.assertTrue(Matcher.matches("something", "something"));
-    }
-
-    @Test
-    public void checkPartials() {
-        Assert.assertTrue(Matcher.matches("something", "some"));
-        Assert.assertTrue(Matcher.matches("something", "meth"));
-        Assert.assertTrue(Matcher.matches("something", "thing"));
-    }
-
-    @Test
-    public void checkWildcards() {
-        Assert.assertTrue(Matcher.matches("something", "some*"));
-        Assert.assertTrue(Matcher.matches("something", "*thing"));
-    }
-
-    @Test
-    public void checkRegex() {
-        Assert.assertTrue(Matcher.matches("something", "^some.*"));
-        Assert.assertTrue(Matcher.matches("something", ".*thing$"));
-    }
-}
diff --git a/src/test/java/org/dependencytrack/policy/PackageURLPolicyEvaluatorTest.java b/src/test/java/org/dependencytrack/policy/PackageURLPolicyEvaluatorTest.java
index 41e40795c..a3a098aec 100644
--- a/src/test/java/org/dependencytrack/policy/PackageURLPolicyEvaluatorTest.java
+++ b/src/test/java/org/dependencytrack/policy/PackageURLPolicyEvaluatorTest.java
@@ -103,94 +103,4 @@ public class PackageURLPolicyEvaluatorTest extends PersistenceCapableTest {
         List<PolicyConditionViolation> violations = evaluator.evaluate(policy, component);
         Assert.assertEquals(0, violations.size());
     }
-
-    @Test
-    public void issue2144_existing1() throws Exception {
-        Policy policy = qm.createPolicy("Test Policy", Policy.Operator.ANY, Policy.ViolationState.INFO);
-        PolicyCondition condition = qm.createPolicyCondition(policy, PolicyCondition.Subject.PACKAGE_URL, PolicyCondition.Operator.MATCHES, "pkg:generic/com/acme/example-component@1.0");
-        Component component = new Component();
-        component.setPurl(new PackageURL("pkg:generic/com/acme/example-component@1.0?type=jar"));
-        component.setPurlCoordinates(new PackageURL("pkg:generic/com/acme/example-component@1.0"));
-        PolicyEvaluator evaluator = new PackageURLPolicyEvaluator();
-        List<PolicyConditionViolation> violations = evaluator.evaluate(policy, component);
-        Assert.assertEquals(1, violations.size());
-        PolicyConditionViolation violation = violations.get(0);
-        Assert.assertEquals(component, violation.getComponent());
-        Assert.assertEquals(condition, violation.getPolicyCondition());
-    }
-
-    @Test
-    public void issue2144_existing2() throws Exception {
-        Policy policy = qm.createPolicy("Test Policy", Policy.Operator.ANY, Policy.ViolationState.INFO);
-        PolicyCondition condition = qm.createPolicyCondition(policy, PolicyCondition.Subject.PACKAGE_URL, PolicyCondition.Operator.MATCHES, "/com/acme/");
-        Component component = new Component();
-        component.setPurl(new PackageURL("pkg:generic/com/acme/example-component@1.0?type=jar"));
-        component.setPurlCoordinates(new PackageURL("pkg:generic/com/acme/example-component@1.0"));
-        PolicyEvaluator evaluator = new PackageURLPolicyEvaluator();
-        List<PolicyConditionViolation> violations = evaluator.evaluate(policy, component);
-        Assert.assertEquals(1, violations.size());
-        PolicyConditionViolation violation = violations.get(0);
-        Assert.assertEquals(component, violation.getComponent());
-        Assert.assertEquals(condition, violation.getPolicyCondition());
-    }
-
-    @Test
-    public void issue2144_groupIdWithDotMatchesSlash() throws Exception {
-        Policy policy = qm.createPolicy("Test Policy", Policy.Operator.ANY, Policy.ViolationState.INFO);
-        PolicyCondition condition = qm.createPolicyCondition(policy, PolicyCondition.Subject.PACKAGE_URL, PolicyCondition.Operator.MATCHES, "/com.acme/");
-        Component component = new Component();
-        component.setPurl(new PackageURL("pkg:generic/com/acme/example-component@1.0?type=jar"));
-        component.setPurlCoordinates(new PackageURL("pkg:generic/com/acme/example-component@1.0"));
-        PolicyEvaluator evaluator = new PackageURLPolicyEvaluator();
-        List<PolicyConditionViolation> violations = evaluator.evaluate(policy, component);
-        Assert.assertEquals(1, violations.size());
-        PolicyConditionViolation violation = violations.get(0);
-        Assert.assertEquals(component, violation.getComponent());
-        Assert.assertEquals(condition, violation.getPolicyCondition());
-    }
-
-    @Test
-    public void issue2144_wildcard() throws Exception {
-        Policy policy = qm.createPolicy("Test Policy", Policy.Operator.ANY, Policy.ViolationState.INFO);
-        PolicyCondition condition = qm.createPolicyCondition(policy, PolicyCondition.Subject.PACKAGE_URL, PolicyCondition.Operator.MATCHES, ".*com.acme.*");
-        Component component = new Component();
-        component.setPurl(new PackageURL("pkg:generic/com/acme/example-component@1.0?type=jar"));
-        component.setPurlCoordinates(new PackageURL("pkg:generic/com/acme/example-component@1.0"));
-        PolicyEvaluator evaluator = new PackageURLPolicyEvaluator();
-        List<PolicyConditionViolation> violations = evaluator.evaluate(policy, component);
-        Assert.assertEquals(1, violations.size());
-        PolicyConditionViolation violation = violations.get(0);
-        Assert.assertEquals(component, violation.getComponent());
-        Assert.assertEquals(condition, violation.getPolicyCondition());
-    }
-
-    @Test
-    public void issue2144_wildcard2() throws Exception {
-        Policy policy = qm.createPolicy("Test Policy", Policy.Operator.ANY, Policy.ViolationState.INFO);
-        PolicyCondition condition = qm.createPolicyCondition(policy, PolicyCondition.Subject.PACKAGE_URL, PolicyCondition.Operator.MATCHES, ".*acme.*myCompany.*");
-        Component component = new Component();
-        component.setPurl(new PackageURL("pkg:generic/com/acme/example-component@1.0-myCompanyFix-1?type=jar"));
-        component.setPurlCoordinates(new PackageURL("pkg:generic/com/acme/example-component@1.0-myCompanyFix-1"));
-        PolicyEvaluator evaluator = new PackageURLPolicyEvaluator();
-        List<PolicyConditionViolation> violations = evaluator.evaluate(policy, component);
-        Assert.assertEquals(1, violations.size());
-        PolicyConditionViolation violation = violations.get(0);
-        Assert.assertEquals(component, violation.getComponent());
-        Assert.assertEquals(condition, violation.getPolicyCondition());
-    }
-
-    @Test
-    public void issue2144_wildcard3() throws Exception {
-        Policy policy = qm.createPolicy("Test Policy", Policy.Operator.ANY, Policy.ViolationState.INFO);
-        PolicyCondition condition = qm.createPolicyCondition(policy, PolicyCondition.Subject.PACKAGE_URL, PolicyCondition.Operator.MATCHES, ".*(a|b|c)cme.*");
-        Component component = new Component();
-        component.setPurl(new PackageURL("pkg:generic/com/acme/example-component@1.0?type=jar"));
-        component.setPurlCoordinates(new PackageURL("pkg:generic/com/acme/example-component@1.0"));
-        PolicyEvaluator evaluator = new PackageURLPolicyEvaluator();
-        List<PolicyConditionViolation> violations = evaluator.evaluate(policy, component);
-        Assert.assertEquals(1, violations.size());
-        PolicyConditionViolation violation = violations.get(0);
-        Assert.assertEquals(component, violation.getComponent());
-        Assert.assertEquals(condition, violation.getPolicyCondition());
-    }
 }
diff --git a/src/test/java/org/dependencytrack/policy/PolicyEngineTest.java b/src/test/java/org/dependencytrack/policy/PolicyEngineTest.java
index 36a3e43c4..93de01d1c 100644
--- a/src/test/java/org/dependencytrack/policy/PolicyEngineTest.java
+++ b/src/test/java/org/dependencytrack/policy/PolicyEngineTest.java
@@ -24,7 +24,6 @@ import org.dependencytrack.tasks.scanners.AnalyzerIdentity;
 import org.junit.Assert;
 import org.junit.Test;
 
-import java.util.ArrayList;
 import java.util.List;
 public class PolicyEngineTest extends PersistenceCapableTest {
 
@@ -129,64 +128,4 @@ public class PolicyEngineTest extends PersistenceCapableTest {
         List<PolicyViolation> violations = policyEngine.evaluate(List.of(component));
         Assert.assertEquals(0, violations.size());
     }
-
-    @Test
-    public void issue1924() {
-        Policy policy = qm.createPolicy("Policy 1924", Policy.Operator.ALL, Policy.ViolationState.INFO);
-        qm.createPolicyCondition(policy, PolicyCondition.Subject.SEVERITY, PolicyCondition.Operator.IS, Severity.CRITICAL.name());
-        qm.createPolicyCondition(policy, PolicyCondition.Subject.PACKAGE_URL, PolicyCondition.Operator.NO_MATCH, "pkg:deb");
-        Project project = qm.createProject("My Project", null, "1", null, null, null, true, false);
-        qm.persist(project);
-        ArrayList<Component> components = new ArrayList<>();
-        Component component = new Component();
-        component.setName("OpenSSL");
-        component.setVersion("3.0.2-0ubuntu1.6");
-        component.setPurl("pkg:deb/openssl@3.0.2-0ubuntu1.6");
-        component.setProject(project);
-        components.add(component);
-        qm.persist(component);
-        Vulnerability vulnerability = new Vulnerability();
-        vulnerability.setVulnId("1");
-        vulnerability.setSource(Vulnerability.Source.INTERNAL);
-        vulnerability.setSeverity(Severity.CRITICAL);
-        qm.persist(vulnerability);
-        qm.addVulnerability(vulnerability, component, AnalyzerIdentity.INTERNAL_ANALYZER);
-        vulnerability = new Vulnerability();
-        vulnerability.setVulnId("2");
-        vulnerability.setSource(Vulnerability.Source.INTERNAL);
-        vulnerability.setSeverity(Severity.CRITICAL);
-        qm.persist(vulnerability);
-        qm.addVulnerability(vulnerability, component, AnalyzerIdentity.INTERNAL_ANALYZER);
-        component = new Component();
-        component.setName("Log4J");
-        component.setVersion("1.2.16");
-        component.setPurl("pkg:mvn/log4j/log4j@1.2.16");
-        component.setProject(project);
-        components.add(component);
-        qm.persist(component);
-        vulnerability = new Vulnerability();
-        vulnerability.setVulnId("3");
-        vulnerability.setSource(Vulnerability.Source.INTERNAL);
-        vulnerability.setSeverity(Severity.CRITICAL);
-        qm.persist(vulnerability);
-        qm.addVulnerability(vulnerability, component, AnalyzerIdentity.INTERNAL_ANALYZER);
-        vulnerability = new Vulnerability();
-        vulnerability.setVulnId("4");
-        vulnerability.setSource(Vulnerability.Source.INTERNAL);
-        vulnerability.setSeverity(Severity.CRITICAL);
-        qm.persist(vulnerability);
-        qm.addVulnerability(vulnerability, component, AnalyzerIdentity.INTERNAL_ANALYZER);
-        PolicyEngine policyEngine = new PolicyEngine();
-        List<PolicyViolation> violations = policyEngine.evaluate(components);
-        Assert.assertEquals(3, violations.size());
-        PolicyViolation policyViolation = violations.get(0);
-        Assert.assertEquals("Log4J", policyViolation.getComponent().getName());
-        Assert.assertEquals(PolicyCondition.Subject.SEVERITY, policyViolation.getPolicyCondition().getSubject());
-        policyViolation = violations.get(1);
-        Assert.assertEquals("Log4J", policyViolation.getComponent().getName());
-        Assert.assertEquals(PolicyCondition.Subject.SEVERITY, policyViolation.getPolicyCondition().getSubject());
-        policyViolation = violations.get(2);
-        Assert.assertEquals("Log4J", policyViolation.getComponent().getName());
-        Assert.assertEquals(PolicyCondition.Subject.PACKAGE_URL, policyViolation.getPolicyCondition().getSubject());
-    }
 }