Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions internal/cel/cel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,21 @@ func TestCELFilterCompilation(t *testing.T) {
filter: "user.startsWith('system:')",
wantErr: true,
},
{
name: "invalid field - objectRef.resources (should be resource singular)",
filter: `objectRef.resources == "domains"`,
wantErr: true,
},
{
name: "invalid field - user.name (should be username)",
filter: `user.name == "admin"`,
wantErr: true,
},
{
name: "invalid field - responseStatus.status (should be code)",
filter: `responseStatus.status == 200`,
wantErr: true,
},
}

for _, tt := range tests {
Expand Down
118 changes: 115 additions & 3 deletions internal/cel/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ var tracer = otel.Tracer("activity-cel-filter")
// Environment creates a CEL environment for audit event filtering.
//
// Available fields: auditID, verb, requestReceivedTimestamp,
// objectRef.{namespace,resource,name}, user.username, user.uid, responseStatus.code
// objectRef.{namespace,resource,name,apiGroup}, user.{username,uid}, responseStatus.code
//
// Note: stageTimestamp is intentionally NOT available for filtering as it should
// only be used for internal pipeline delay calculations, not for querying events.
Expand All @@ -44,6 +44,23 @@ func Environment() (*cel.Env, error) {
)
}

// validFields defines the allowed fields for each structured type
var validFields = map[string]map[string]bool{
"objectRef": {
"apiGroup": true,
"namespace": true,
"resource": true,
"name": true,
},
"user": {
"username": true,
"uid": true,
},
"responseStatus": {
"code": true,
},
}

// CompileFilter compiles and validates a CEL filter expression, ensuring it returns a boolean.
// Returns user-friendly error messages with helpful context (available fields, documentation links).
func CompileFilter(filterExpr string) (*cel.Ast, error) {
Expand Down Expand Up @@ -76,10 +93,105 @@ func CompileFilter(filterExpr string) (*cel.Ast, error) {
return nil, fmt.Errorf("%s", formatFilterError(typeErr))
}

// Validate that only allowed fields are accessed on structured types
if err := validateFieldAccess(ast.Expr()); err != nil {
metrics.CELFilterErrors.WithLabelValues("invalid_field").Inc()
metrics.CELFilterParseDuration.Observe(time.Since(startTime).Seconds())
return nil, fmt.Errorf("%s", formatFilterError(err))
}

metrics.CELFilterParseDuration.Observe(time.Since(startTime).Seconds())
return ast, nil
}

// validateFieldAccess recursively validates that only allowed fields are accessed
func validateFieldAccess(e *expr.Expr) error {
if e == nil {
return nil
}

switch exprKind := e.ExprKind.(type) {
case *expr.Expr_SelectExpr:
sel := exprKind.SelectExpr
// Get the base object (e.g., "user", "objectRef", "responseStatus")
if operand := sel.GetOperand(); operand != nil {
if identExpr := operand.GetIdentExpr(); identExpr != nil {
baseObject := identExpr.GetName()
field := sel.GetField()

// Check if this is a structured type we validate
if allowedFields, ok := validFields[baseObject]; ok {
// Check if the field is allowed
if !allowedFields[field] {
// Build a helpful error message with available fields
availableFields := make([]string, 0, len(allowedFields))
for f := range allowedFields {
availableFields = append(availableFields, baseObject+"."+f)
}
return fmt.Errorf("field '%s.%s' is not available for filtering. Available fields for %s: %v",
baseObject, field, baseObject, availableFields)
}
}
}
// Recursively validate the operand
if err := validateFieldAccess(operand); err != nil {
return err
}
}

case *expr.Expr_CallExpr:
call := exprKind.CallExpr
// Validate the target if present
if call.Target != nil {
if err := validateFieldAccess(call.Target); err != nil {
return err
}
}
// Validate all arguments
for _, arg := range call.Args {
if err := validateFieldAccess(arg); err != nil {
return err
}
}

case *expr.Expr_ListExpr:
list := exprKind.ListExpr
for _, elem := range list.Elements {
if err := validateFieldAccess(elem); err != nil {
return err
}
}

case *expr.Expr_StructExpr:
structExpr := exprKind.StructExpr
for _, entry := range structExpr.Entries {
if err := validateFieldAccess(entry.GetValue()); err != nil {
return err
}
}

case *expr.Expr_ComprehensionExpr:
comp := exprKind.ComprehensionExpr
if err := validateFieldAccess(comp.IterRange); err != nil {
return err
}
if err := validateFieldAccess(comp.AccuInit); err != nil {
return err
}
if err := validateFieldAccess(comp.LoopCondition); err != nil {
return err
}
if err := validateFieldAccess(comp.LoopStep); err != nil {
return err
}
if err := validateFieldAccess(comp.Result); err != nil {
return err
}
}

return nil
}

// ConvertToClickHouseSQL converts a CEL expression to a ClickHouse WHERE clause with tracing.
func ConvertToClickHouseSQL(ctx context.Context, filterExpr string) (string, []interface{}, error) {
ctx, span := tracer.Start(ctx, "cel.filter.convert",
Expand Down Expand Up @@ -391,8 +503,8 @@ func (c *sqlConverter) convertSelectExpr(sel *expr.Expr_Select) (string, error)
return "status_code", nil

default:
// Provide helpful suggestions for common fields that aren't filterable
return "", fmt.Errorf("field '%s.%s' is not available for filtering. Available fields: auditID, verb, requestReceivedTimestamp, objectRef.apiGroup, objectRef.namespace, objectRef.resource, objectRef.name, user.username, user.uid, user.groups, responseStatus.code", baseObject, field)
// Defensive check - validation should catch invalid fields at compile time
return "", fmt.Errorf("field '%s.%s' is not available for filtering", baseObject, field)
}
}

Expand Down
36 changes: 36 additions & 0 deletions internal/registry/activity/auditlog/storage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,42 @@ func TestQueryStorage_Create_ValidationErrors(t *testing.T) {
},
wantError: "filter expression must return a boolean",
},
{
name: "invalid field name on objectRef (plural instead of singular)",
query: &v1alpha1.AuditLogQuery{
ObjectMeta: metav1.ObjectMeta{Name: "test"},
Spec: v1alpha1.AuditLogQuerySpec{
StartTime: "now-1h",
EndTime: "now",
Filter: `objectRef.resources == "domains"`, // should be objectRef.resource (singular)
},
},
wantError: "field 'objectRef.resources' is not available for filtering",
},
{
name: "invalid field name on user",
query: &v1alpha1.AuditLogQuery{
ObjectMeta: metav1.ObjectMeta{Name: "test"},
Spec: v1alpha1.AuditLogQuerySpec{
StartTime: "now-1h",
EndTime: "now",
Filter: `user.name == "admin"`, // should be user.username
},
},
wantError: "field 'user.name' is not available for filtering",
},
{
name: "invalid field name on responseStatus",
query: &v1alpha1.AuditLogQuery{
ObjectMeta: metav1.ObjectMeta{Name: "test"},
Spec: v1alpha1.AuditLogQuerySpec{
StartTime: "now-1h",
EndTime: "now",
Filter: `responseStatus.status == 200`, // should be responseStatus.code
},
},
wantError: "field 'responseStatus.status' is not available for filtering",
},
}

for _, tt := range tests {
Expand Down