From 7ca4b97bec65cae81bf07af27af81e0ce14323ff Mon Sep 17 00:00:00 2001 From: nscuro Date: Sun, 6 Jul 2025 02:26:43 +0200 Subject: [PATCH] CEL search experiment Signed-off-by: nscuro --- .../search/CelExpressionVisitor.java | 62 +++++++ .../search/SearchExpressionEvaluator.java | 68 +++++++ .../search/SearchExpressionVisitor.java | 170 ++++++++++++++++++ .../search/SearchExpressionEvaluatorTest.java | 100 +++++++++++ 4 files changed, 400 insertions(+) create mode 100644 apiserver/src/main/java/org/dependencytrack/search/CelExpressionVisitor.java create mode 100644 apiserver/src/main/java/org/dependencytrack/search/SearchExpressionEvaluator.java create mode 100644 apiserver/src/main/java/org/dependencytrack/search/SearchExpressionVisitor.java create mode 100644 apiserver/src/test/java/org/dependencytrack/search/SearchExpressionEvaluatorTest.java diff --git a/apiserver/src/main/java/org/dependencytrack/search/CelExpressionVisitor.java b/apiserver/src/main/java/org/dependencytrack/search/CelExpressionVisitor.java new file mode 100644 index 0000000000..429bbeab1b --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/search/CelExpressionVisitor.java @@ -0,0 +1,62 @@ +/* + * 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.search; + +import com.google.api.expr.v1alpha1.Expr; + +public interface CelExpressionVisitor { + + default void visit(Expr expr) { + switch (expr.getExprKindCase()) { + case CALL_EXPR -> visitCall(expr); + case COMPREHENSION_EXPR -> visitComprehension(expr); + case CONST_EXPR -> visitConst(expr); + case IDENT_EXPR -> visitIdent(expr); + case LIST_EXPR -> visitList(expr); + case SELECT_EXPR -> visitSelect(expr); + case STRUCT_EXPR -> visitStruct(expr); + case EXPRKIND_NOT_SET -> visitUnknown(expr); + } + } + + default void visitCall(final Expr expr) { + } + + default void visitComprehension(final Expr expr) { + } + + default void visitConst(final Expr expr) { + } + + default void visitIdent(final Expr expr) { + } + + default void visitList(final Expr expr) { + } + + default void visitSelect(final Expr expr) { + } + + default void visitStruct(final Expr expr) { + } + + default void visitUnknown(final Expr expr) { + } + +} diff --git a/apiserver/src/main/java/org/dependencytrack/search/SearchExpressionEvaluator.java b/apiserver/src/main/java/org/dependencytrack/search/SearchExpressionEvaluator.java new file mode 100644 index 0000000000..de17b0fee3 --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/search/SearchExpressionEvaluator.java @@ -0,0 +1,68 @@ +/* + * 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.search; + +import com.google.api.expr.v1alpha1.CheckedExpr; +import org.projectnessie.cel.Ast; +import org.projectnessie.cel.CEL; +import org.projectnessie.cel.Env; +import org.projectnessie.cel.common.types.Err; + +import java.util.Map; + +public class SearchExpressionEvaluator { + + private final Env env; + private final Map fieldMappings; + + public SearchExpressionEvaluator(final Env env, final Map fieldMappings) { + this.env = env; + this.fieldMappings = fieldMappings; + } + + public record EvaluationResult(String sqlCondition, Map queryParams) { + } + + public EvaluationResult evaluate(final String expression) { + Env.AstIssuesTuple astIssuesTuple = env.parse(expression); + if (astIssuesTuple.hasIssues()) { + throw new IllegalStateException("Failed to parse expression: " + astIssuesTuple.getIssues()); + } + + try { + astIssuesTuple = env.check(astIssuesTuple.getAst()); + } catch (Err.ErrException e) { + throw new IllegalStateException("Failed to check expression", e); + } + if (astIssuesTuple.hasIssues()) { + throw new IllegalArgumentException("Failed to check expression: " + astIssuesTuple.getIssues()); + } + + final Ast ast = astIssuesTuple.getAst(); + final CheckedExpr checkedExpr = CEL.astToCheckedExpr(ast); + + final var visitor = new SearchExpressionVisitor(fieldMappings, checkedExpr); + visitor.visit(checkedExpr.getExpr()); + + return new EvaluationResult( + visitor.getQuery(), + visitor.getQueryParams()); + } + +} diff --git a/apiserver/src/main/java/org/dependencytrack/search/SearchExpressionVisitor.java b/apiserver/src/main/java/org/dependencytrack/search/SearchExpressionVisitor.java new file mode 100644 index 0000000000..798b83c346 --- /dev/null +++ b/apiserver/src/main/java/org/dependencytrack/search/SearchExpressionVisitor.java @@ -0,0 +1,170 @@ +/* + * 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.search; + +import com.google.api.expr.v1alpha1.CheckedExpr; +import com.google.api.expr.v1alpha1.Constant; +import com.google.api.expr.v1alpha1.Expr; +import com.google.api.expr.v1alpha1.Type; +import org.projectnessie.cel.common.operators.Operator; + +import java.util.HashMap; +import java.util.Map; + +public class SearchExpressionVisitor implements CelExpressionVisitor { + + private static final Map SQL_BINARY_OPERATORS = + Map.ofEntries( + Map.entry(Operator.LogicalAnd, "AND"), + Map.entry(Operator.LogicalOr, "OR"), + Map.entry(Operator.Equals, "="), + Map.entry(Operator.In, "IN")); + private static final Map SQL_UNARY_OPERATORS = + Map.ofEntries( + Map.entry(Operator.LogicalNot, "NOT")); + + private final Map fieldMappings; + private final Map typeByNodeId; + private final Map queryParams = new HashMap<>(); + private final StringBuilder queryBuilder = new StringBuilder(); + private int queryParamIndex = 0; + + public SearchExpressionVisitor( + final Map fieldMappings, + final CheckedExpr checkedExpr) { + this.fieldMappings = fieldMappings; + this.typeByNodeId = checkedExpr.getTypeMapMap(); + } + + public String getQuery() { + return queryBuilder.toString(); + } + + public Map getQueryParams() { + return Map.copyOf(queryParams); + } + + @Override + public void visitCall(final Expr expr) { + final Expr.Call callExpr = expr.getCallExpr(); + + final var operator = Operator.byId(callExpr.getFunction()); + switch (operator) { + case Add, + Divide, + Equals, + Greater, + GreaterEquals, + In, + Less, + LessEquals, + LogicalAnd, + LogicalOr, + Multiply, + NotEquals, + OldIn, + Subtract -> visitCallBinary(callExpr, operator); + case Conditional -> visitCallConditional(expr); + case Index -> visitCallIndex(expr); + case LogicalNot, Negate -> visitCallUnary(callExpr, operator); + default -> visitCallFunction(expr); + } + } + + private void visitCallBinary(final Expr.Call callExpr, final Operator operator) { + final Type lhsType = typeByNodeId.get(callExpr.getArgs(0).getId()); + final Type rhsType = typeByNodeId.get(callExpr.getArgs(1).getId()); + + visit(callExpr.getArgs(0)); + + final String sqlOperator; + if (operator == Operator.Add + && lhsType.getPrimitive() == Type.PrimitiveType.STRING + && rhsType.getPrimitive() == Type.PrimitiveType.STRING) { + sqlOperator = "||"; + } else if (operator == Operator.Equals + && (rhsType.hasNull() || rhsType.getPrimitive() == Type.PrimitiveType.BOOL)) { + sqlOperator = "IS"; + } else if (operator == Operator.NotEquals + && (rhsType.hasNull() || rhsType.getPrimitive() == Type.PrimitiveType.BOOL)) { + sqlOperator = "IS NOT"; + } else if (SQL_BINARY_OPERATORS.containsKey(operator)) { + sqlOperator = SQL_BINARY_OPERATORS.get(operator); + } else { + throw new IllegalStateException("Unknown binary operator: " + operator); + } + + queryBuilder.append(" ").append(sqlOperator).append(" "); + + visit(callExpr.getArgs(1)); + } + + private void visitCallConditional(final Expr expr) { + } + + private void visitCallFunction(final Expr expr) { + } + + private void visitCallIndex(final Expr expr) { + + } + + private void visitCallUnary(final Expr.Call callExpr, final Operator operator) { + final String sqlOperator = SQL_UNARY_OPERATORS.get(operator); + if (sqlOperator == null) { + throw new IllegalStateException("Unknown unary operator: " + operator); + } + + queryBuilder.append(sqlOperator).append(" "); + visit(callExpr.getArgs(0)); + } + + @Override + public void visitConst(final Expr expr) { + final Constant constExpr = expr.getConstExpr(); + + if (constExpr.hasNullValue()) { + queryBuilder.append("NULL"); + return; + } + + final String paramName = "param" + queryParamIndex++; + queryBuilder.append(":").append(paramName); + + switch (constExpr.getConstantKindCase()) { + case BOOL_VALUE -> queryParams.put(paramName, constExpr.getBoolValue()); + case INT64_VALUE -> queryParams.put(paramName, constExpr.getInt64Value()); + case STRING_VALUE -> queryParams.put(paramName, constExpr.getStringValue()); + default -> throw new IllegalStateException("Unexpected constant kind: " + constExpr.getConstantKindCase()); + } + } + + @Override + public void visitIdent(final Expr expr) { + final Expr.Ident identExpr = expr.getIdentExpr(); + + final String fieldExpression = fieldMappings.get(identExpr.getName()); + if (fieldExpression == null) { + throw new IllegalStateException("No expression found for field: " + identExpr.getName()); + } + + queryBuilder.append(fieldExpression); + } + +} diff --git a/apiserver/src/test/java/org/dependencytrack/search/SearchExpressionEvaluatorTest.java b/apiserver/src/test/java/org/dependencytrack/search/SearchExpressionEvaluatorTest.java new file mode 100644 index 0000000000..b465ed2d61 --- /dev/null +++ b/apiserver/src/test/java/org/dependencytrack/search/SearchExpressionEvaluatorTest.java @@ -0,0 +1,100 @@ +/* + * 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.search; + +import org.dependencytrack.PersistenceCapableTest; +import org.dependencytrack.model.Project; +import org.dependencytrack.proto.policy.v1.Component; +import org.dependencytrack.search.SearchExpressionEvaluator.EvaluationResult; +import org.junit.Before; +import org.junit.Test; +import org.projectnessie.cel.Env; +import org.projectnessie.cel.EnvOption; +import org.projectnessie.cel.checker.Checker; +import org.projectnessie.cel.checker.Decls; +import org.projectnessie.cel.common.types.pb.ProtoTypeRegistry; + +import javax.jdo.Query; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +public class SearchExpressionEvaluatorTest extends PersistenceCapableTest { + + private SearchExpressionEvaluator evaluator; + + @Before + public void before() throws Exception { + super.before(); + + final Env env = Env.newCustomEnv( + ProtoTypeRegistry.newRegistry(), + List.of( + EnvOption.declarations( + Decls.newVar("group", Decls.String), + Decls.newVar("name", Decls.String), + Decls.newVar("version", Decls.String), + Decls.newVar("internal", Decls.Bool), + Decls.newVar("cpe", Decls.String), + Decls.newVar("purl", Decls.String)), + EnvOption.declarations(Checker.StandardDeclarations), + EnvOption.types(Component.getDefaultInstance()))); + + final Map fieldMappings = Map.ofEntries( + Map.entry("group", "\"GROUP\""), + Map.entry("name", "\"NAME\""), + Map.entry("version", "\"VERSION\""), + Map.entry("internal", "\"INTERNAL\""), + Map.entry("cpe", "\"CPE\""), + Map.entry("purl", "\"PURL\"")); + + evaluator = new SearchExpressionEvaluator(env, fieldMappings); + } + + @Test + public void test() { + final EvaluationResult result = evaluator.evaluate(""" + name == "foo" && version == "2.0" || !internal && cpe == null + """); + + assertThat(result.sqlCondition()).isEqualToIgnoringWhitespace(""" + "NAME" = :param0 AND "VERSION" = :param1 OR NOT "INTERNAL" AND "CPE" IS NULL + """); + + final var project = new Project(); + project.setName("acme-app"); + qm.persist(project); + + final var component = new org.dependencytrack.model.Component(); + component.setProject(project); + component.setName("foo"); + component.setVersion("2.0"); + qm.persist(component); + + final Query query = qm.getPersistenceManager().newQuery(Query.SQL, """ + SELECT "ID" FROM "COMPONENT" WHERE %s + """.formatted(result.sqlCondition())); + query.setNamedParameters(result.queryParams()); + + final long componentId = query.executeResultUnique(Long.class); + assertThat(componentId).isEqualTo(component.getId()); + } + +} \ No newline at end of file