Skip to content

Commit 3007f1f

Browse files
committed
feat(detectors): add string utility functions to CEL expressions
Add 8 string utility functions for YAML detectors: - split(str, delimiter) - Split string into list - join(list, delimiter) - Join list into string - trim(str) - Remove leading/trailing whitespace - replace(str, old, new) - Replace all occurrences - upper(str) - Convert to uppercase - lower(str) - Convert to lowercase - basename(path) - Get filename from path - dirname(path) - Get directory from path Functions are available in both conditions and output expressions. All functions handle CEL's various list representations ([]string, []interface{}, []ref.Val). Includes comprehensive unit tests and documentation updates.
1 parent 4bb3db5 commit 3007f1f

File tree

4 files changed

+575
-1
lines changed

4 files changed

+575
-1
lines changed

docs/docs/detectors/yaml-detectors.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,19 @@ conditions:
274274
| `getData("field")` | Extract data field | `getData("pathname")`, `getData("pid")` |
275275
| `hasData("field")` | Check if data field exists | `hasData("pathname")` |
276276

277+
**String Utility Functions:**
278+
279+
| Function | Description | Example |
280+
|----------|-------------|---------|
281+
| `split(str, delimiter)` | Split string into list | `split("a,b,c", ",")` → `["a", "b", "c"]` |
282+
| `join(list, delimiter)` | Join list into string | `join(["a", "b"], ",")` → `"a,b"` |
283+
| `trim(str)` | Remove leading/trailing whitespace | `trim(" hello ")` → `"hello"` |
284+
| `replace(str, old, new)` | Replace all occurrences | `replace("foo bar", "bar", "baz")` → `"foo baz"` |
285+
| `upper(str)` | Convert to uppercase | `upper("hello")` → `"HELLO"` |
286+
| `lower(str)` | Convert to lowercase | `lower("HELLO")` → `"hello"` |
287+
| `basename(path)` | Get filename from path | `basename("/path/to/file.txt")` → `"file.txt"` |
288+
| `dirname(path)` | Get directory from path | `dirname("/path/to/file.txt")` → `"/path/to"` |
289+
277290
**Performance:**
278291
- Conditions are evaluated with 5ms timeout by default
279292
- Failed evaluations are logged and treated as `false`
@@ -298,7 +311,12 @@ output:
298311
| Extract data field | `getData("pathname")` |
299312
| Extract workload field | `workload.container.id` |
300313
| Conditional extraction | `workload.container.id != "" ? workload.container.id : "unknown"` |
301-
| String manipulation | `getData("pathname").split("/").last()` |
314+
| Extract filename | `basename(getData("pathname"))` |
315+
| Extract directory | `dirname(getData("pathname"))` |
316+
| Split path components | `split(getData("pathname"), "/")` |
317+
| Join path components | `join(["usr", "bin", "nc"], "/")` |
318+
| Normalize case | `lower(getData("comm"))` |
319+
| Replace substring | `replace(getData("pathname"), "/tmp", "/var/tmp")` |
302320
| Combine fields | `getData("comm") + ":" + string(getData("pid"))` |
303321

304322
**Field Semantics:**

pkg/detectors/yaml/cel_env.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ func createCELEnvironment(lists map[string][]string, registry datastores.Registr
8383
datastoreOptions := registerDatastoreFunctions(registry)
8484
envOptions = append(envOptions, datastoreOptions...)
8585

86+
// Register string utility functions
87+
stringOptions := registerStringFunctions()
88+
envOptions = append(envOptions, stringOptions...)
89+
8690
return cel.NewEnv(envOptions...)
8791
}
8892

pkg/detectors/yaml/cel_strings.go

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
package yaml
2+
3+
import (
4+
"path/filepath"
5+
"strings"
6+
7+
"github.com/google/cel-go/cel"
8+
"github.com/google/cel-go/common/types"
9+
"github.com/google/cel-go/common/types/ref"
10+
)
11+
12+
// registerStringFunctions registers all string utility CEL functions
13+
// Returns CEL environment options for string utility functions
14+
func registerStringFunctions() []cel.EnvOption {
15+
return []cel.EnvOption{
16+
// split(string, delimiter) -> list<string>
17+
cel.Function("split",
18+
cel.Overload("split_string_string",
19+
[]*cel.Type{cel.StringType, cel.StringType},
20+
cel.ListType(cel.StringType),
21+
cel.BinaryBinding(createSplitBinding),
22+
),
23+
),
24+
25+
// join(list<string>, delimiter) -> string
26+
cel.Function("join",
27+
cel.Overload("join_list_string",
28+
[]*cel.Type{cel.ListType(cel.StringType), cel.StringType},
29+
cel.StringType,
30+
cel.BinaryBinding(createJoinBinding),
31+
),
32+
),
33+
34+
// trim(string) -> string (removes leading and trailing whitespace)
35+
cel.Function("trim",
36+
cel.Overload("trim_string",
37+
[]*cel.Type{cel.StringType},
38+
cel.StringType,
39+
cel.UnaryBinding(createTrimBinding),
40+
),
41+
),
42+
43+
// replace(string, old, new) -> string (replaces all occurrences)
44+
cel.Function("replace",
45+
cel.Overload("replace_string_string_string",
46+
[]*cel.Type{cel.StringType, cel.StringType, cel.StringType},
47+
cel.StringType,
48+
cel.FunctionBinding(createReplaceBinding),
49+
),
50+
),
51+
52+
// upper(string) -> string (converts to uppercase)
53+
cel.Function("upper",
54+
cel.Overload("upper_string",
55+
[]*cel.Type{cel.StringType},
56+
cel.StringType,
57+
cel.UnaryBinding(createUpperBinding),
58+
),
59+
),
60+
61+
// lower(string) -> string (converts to lowercase)
62+
cel.Function("lower",
63+
cel.Overload("lower_string",
64+
[]*cel.Type{cel.StringType},
65+
cel.StringType,
66+
cel.UnaryBinding(createLowerBinding),
67+
),
68+
),
69+
70+
// basename(string) -> string (returns last element of path)
71+
cel.Function("basename",
72+
cel.Overload("basename_string",
73+
[]*cel.Type{cel.StringType},
74+
cel.StringType,
75+
cel.UnaryBinding(createBasenameBinding),
76+
),
77+
),
78+
79+
// dirname(string) -> string (returns directory portion of path)
80+
cel.Function("dirname",
81+
cel.Overload("dirname_string",
82+
[]*cel.Type{cel.StringType},
83+
cel.StringType,
84+
cel.UnaryBinding(createDirnameBinding),
85+
),
86+
),
87+
}
88+
}
89+
90+
// createSplitBinding creates a binding for split(string, delimiter)
91+
func createSplitBinding(lhs, rhs ref.Val) ref.Val {
92+
str, ok := lhs.Value().(string)
93+
if !ok {
94+
return types.NewErr("split: first argument must be a string")
95+
}
96+
97+
delimiter, ok := rhs.Value().(string)
98+
if !ok {
99+
return types.NewErr("split: second argument must be a string")
100+
}
101+
102+
parts := strings.Split(str, delimiter)
103+
// Convert Go slice to CEL list using NativeToValue
104+
return types.DefaultTypeAdapter.NativeToValue(parts)
105+
}
106+
107+
// createJoinBinding creates a binding for join(list<string>, delimiter)
108+
func createJoinBinding(lhs, rhs ref.Val) ref.Val {
109+
// Convert CEL list value to Go slice
110+
// CEL lists can be represented in different ways, so we need to handle multiple cases
111+
var parts []string
112+
113+
// First, try to get the native value
114+
listVal := lhs.Value()
115+
116+
// Handle direct []string
117+
if strSlice, ok := listVal.([]string); ok {
118+
parts = strSlice
119+
} else if ifaceSlice, ok := listVal.([]interface{}); ok {
120+
// Handle []interface{} and extract strings
121+
for _, item := range ifaceSlice {
122+
str, ok := item.(string)
123+
if !ok {
124+
return types.NewErr("join: list must contain strings, got %T", item)
125+
}
126+
parts = append(parts, str)
127+
}
128+
} else if refValSlice, ok := listVal.([]ref.Val); ok {
129+
// Handle []ref.Val (CEL's internal list representation)
130+
for _, item := range refValSlice {
131+
if strVal, ok := item.(types.String); ok {
132+
parts = append(parts, string(strVal))
133+
continue
134+
}
135+
str, ok := item.Value().(string)
136+
if !ok {
137+
return types.NewErr("join: list must contain strings, got %T", item.Value())
138+
}
139+
parts = append(parts, str)
140+
}
141+
} else {
142+
// Try to convert using CEL's type adapter (handles CEL list types)
143+
nativeVal := types.DefaultTypeAdapter.NativeToValue(listVal)
144+
if nativeVal == nil {
145+
return types.NewErr("join: first argument must be a list")
146+
}
147+
if nativeList, ok := nativeVal.Value().([]string); ok {
148+
parts = nativeList
149+
} else if nativeList, ok := nativeVal.Value().([]interface{}); ok {
150+
for _, item := range nativeList {
151+
str, ok := item.(string)
152+
if !ok {
153+
return types.NewErr("join: list must contain strings, got %T", item)
154+
}
155+
parts = append(parts, str)
156+
}
157+
} else {
158+
// Last resort: try to iterate if it's a CEL list type
159+
// Check if it implements the list interface by trying Value() again
160+
return types.NewErr("join: first argument must be a list of strings, got %T", listVal)
161+
}
162+
}
163+
164+
delimiter, ok := rhs.Value().(string)
165+
if !ok {
166+
return types.NewErr("join: second argument must be a string")
167+
}
168+
169+
return types.String(strings.Join(parts, delimiter))
170+
}
171+
172+
// createTrimBinding creates a binding for trim(string)
173+
func createTrimBinding(arg ref.Val) ref.Val {
174+
str, ok := arg.Value().(string)
175+
if !ok {
176+
return types.NewErr("trim: argument must be a string")
177+
}
178+
179+
return types.String(strings.TrimSpace(str))
180+
}
181+
182+
// createReplaceBinding creates a binding for replace(string, old, new)
183+
func createReplaceBinding(args ...ref.Val) ref.Val {
184+
if len(args) != 3 {
185+
return types.NewErr("replace: requires 3 arguments (string, old, new)")
186+
}
187+
188+
str, ok := args[0].Value().(string)
189+
if !ok {
190+
return types.NewErr("replace: first argument must be a string")
191+
}
192+
193+
oldStr, ok := args[1].Value().(string)
194+
if !ok {
195+
return types.NewErr("replace: second argument must be a string")
196+
}
197+
198+
newStr, ok := args[2].Value().(string)
199+
if !ok {
200+
return types.NewErr("replace: third argument must be a string")
201+
}
202+
203+
return types.String(strings.ReplaceAll(str, oldStr, newStr))
204+
}
205+
206+
// createUpperBinding creates a binding for upper(string)
207+
func createUpperBinding(arg ref.Val) ref.Val {
208+
str, ok := arg.Value().(string)
209+
if !ok {
210+
return types.NewErr("upper: argument must be a string")
211+
}
212+
213+
return types.String(strings.ToUpper(str))
214+
}
215+
216+
// createLowerBinding creates a binding for lower(string)
217+
func createLowerBinding(arg ref.Val) ref.Val {
218+
str, ok := arg.Value().(string)
219+
if !ok {
220+
return types.NewErr("lower: argument must be a string")
221+
}
222+
223+
return types.String(strings.ToLower(str))
224+
}
225+
226+
// createBasenameBinding creates a binding for basename(string)
227+
func createBasenameBinding(arg ref.Val) ref.Val {
228+
str, ok := arg.Value().(string)
229+
if !ok {
230+
return types.NewErr("basename: argument must be a string")
231+
}
232+
233+
return types.String(filepath.Base(str))
234+
}
235+
236+
// createDirnameBinding creates a binding for dirname(string)
237+
func createDirnameBinding(arg ref.Val) ref.Val {
238+
str, ok := arg.Value().(string)
239+
if !ok {
240+
return types.NewErr("dirname: argument must be a string")
241+
}
242+
243+
return types.String(filepath.Dir(str))
244+
}

0 commit comments

Comments
 (0)