Skip to content

Commit 511dd04

Browse files
authored
feat(secl): add a capture field to the set action (#54165)
### What does this PR do? Extends the SECL `set` action with a new `capture` field: a regular expression with a single capture group, applied to the value of `field`, whose first group becomes the variable value. ```yaml - id: ssm_command_tracking expression: open.file.path =~ "/var/lib/amazon/ssm/*/document/orchestration/**" silent: true actions: - set: name: ssm_command_id scope: process inherited: true field: open.file.path capture: "/orchestration/([^/]+)/" ``` - `eval.CaptureStringMatcher` joins the existing `StringMatcher` family in `secl/compiler/eval/strings.go`. `Compile` rejects patterns that don't compile and patterns with no capture group; `Capture` uses `FindStringSubmatchIndex` to avoid allocating a slice of strings for every group. - The compiled pattern lives on the `*Action` (next to `ScopeFieldEvaluator`), compiled once at policy load by `CompileCaptureMatcher`. It deliberately does **not** live in `RuleSet.fieldEvaluators`, which is keyed by field name and shared across rules — two rules capturing different patterns out of the same field would otherwise collide. - Validation is all at load time: `capture` requires `field` (which transitively makes it exclusive with `value` and `expression`), and is rejected on non-string and array fields. At runtime the only branch is match / no match. - A value that doesn't match is a silent no-op leaving the variable at its previous value. Capture rules can be attached to high frequency events, so a miss must not log or clear anything. - `policy.schema.json` is regenerated. Because it declares `additionalProperties: false`, skipping this would make every policy using `capture` fail schema validation. Only fields holding a single string are supported. Array fields would need per-element extraction, which is out of scope here. ### Motivation Workload Protection events are rich at the kernel layer but disconnected from cloud-layer activity. The identifiers needed to join them frequently already exist inside WP event fields — an SSM `CommandId` inside a filesystem path, an IAM role inside an IMDS url — but a rule could match those fields without being able to decompose them. Storing the whole field value doesn't help: CloudTrail has the bare `a1b2…`, so the strings aren't equal and there is nothing to join on. Capturing the id and attaching it to the process with `inherited: true` puts the join key on every descendant event, so the backend join becomes an exact equality instead of command-line parsing inside a time window. Implements the approved RFC "Capturing Correlation Artifact: Structured Join Keys from SECL Set Actions". ### Describe how you validated your changes Unit tests (`secl/compiler/eval`, `secl/rules`) cover extraction, the first-group-only rule, non-participating optional groups, load-time rejection of malformed patterns and of non-string/array fields, and the two-rules-same-field case that guards the per-action matcher. Functional test `TestActionCaptureInherited` on a real kernel covers the end-to-end scenario: a silent rule captures a UUID-shaped SSM command id out of an orchestration path, and a **grandchild** shell two levels down fires a rule asserting `${process.ssm_command_id}` equals the bare id. It also asserts the artifact reaches the serialized event intact at `$.process.variables.ssm_command_id` — variable values pass through `scrubber.ScrubString`, so this confirms the scrubber doesn't mangle the join key — and that no event is sent for the extraction rule itself. Benchmark added for the set action, since the review raised the cost of evaluating the pattern on frequently matched rules (linux/arm64): | Case | ns/op | B/op | allocs/op | |---|---|---|---| | set without capture | 106 | 48 | 3 | | set with capture, match | 335 | 104 | 6 | | set with capture, no match | 122 | 48 | 3 | Matching costs ~+230ns over a plain set action; a miss costs ~+16ns and no extra allocations. Go's `regexp` is RE2, so a pattern is linear in the input length and cannot backtrack catastrophically regardless of how it is written. ### Additional Notes Captured values are cloned rather than sliced out of the field value: a Go substring shares its backing array, so a 36-byte id would otherwise keep the whole path alive for as long as the variable lived — and these variables are inherited across process trees and can carry a TTL. That is the `+1 alloc` in the table above. Note for reviewers: `secl/rules/policy_test.go` and `ruleset_test.go` are `//go:build linux`, so the package reports 52 passing tests on darwin against 190 on linux. Changes here need a linux run to be meaningfully verified. Follow-ups, both out of scope: - Capture on array fields, which is what env var artifacts (`GITHUB_RUN_ID`, `ECS_TASK_ARN`) would need; `exec.envp` currently fails at load. - The ECS task id example from the RFC, which depends on `process.cgroup.id` exposing the relative cgroup path rather than just the leaf container id on the EC2 launch type. That still needs to be checked on a real ECS host. Co-authored-by: lorenzo.susini <lorenzo.susini@datadoghq.com>
1 parent 64d2167 commit 511dd04

15 files changed

Lines changed: 862 additions & 0 deletions

pkg/security/rules/monitor/policy_monitor.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ type RuleSetAction struct {
231231
Value interface{} `json:"value,omitempty"`
232232
DefaultValue interface{} `json:"default_value,omitempty"`
233233
Field string `json:"field,omitempty"`
234+
Capture string `json:"capture,omitempty"`
234235
Expression string `json:"expression,omitempty"`
235236
Append bool `json:"append,omitempty"`
236237
Scope string `json:"scope,omitempty"`
@@ -347,6 +348,7 @@ func RuleStateFromRule(rule *rules.PolicyRule, policy *rules.PolicyInfo, status
347348
Value: action.Def.Set.Value,
348349
DefaultValue: action.Def.Set.DefaultValue,
349350
Field: action.Def.Set.Field,
351+
Capture: action.Def.Set.Capture,
350352
Expression: action.Def.Set.Expression,
351353
Append: action.Def.Set.Append,
352354
Scope: string(action.Def.Set.Scope),

pkg/security/rules/monitor/policy_monitor_easyjson.go

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/security/rules/monitor/policy_monitor_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,63 @@ func TestPolicyMonitorPolicyState(t *testing.T) {
7979
},
8080
},
8181
},
82+
{
83+
// the capture pattern is what distinguishes an action storing part of a
84+
// field from one storing the whole of it, so it has to be reported
85+
name: "rule with a capture set action",
86+
policies: []*testPolicy{
87+
{
88+
info: rules.PolicyInfo{
89+
Name: "Policy A",
90+
Source: "test",
91+
},
92+
def: rules.PolicyDef{
93+
Rules: []*rules.RuleDefinition{
94+
{
95+
ID: "rule_a",
96+
Expression: `exec.file.path == "/etc/foo/bar"`,
97+
Actions: []*rules.ActionDefinition{
98+
{
99+
Set: &rules.SetDefinition{
100+
Name: "artifact",
101+
Field: "process.file.path", // use field available for both Linux and Windows
102+
Capture: "/orchestration/([^/]+)/",
103+
Scope: "process",
104+
},
105+
},
106+
},
107+
},
108+
},
109+
},
110+
},
111+
},
112+
expectedPolicyStates: []*PolicyState{
113+
{
114+
PolicyMetadata: PolicyMetadata{
115+
Name: "Policy A",
116+
Source: "test",
117+
},
118+
Status: PolicyStatusLoaded,
119+
Rules: []*RuleState{
120+
{
121+
ID: "rule_a",
122+
Expression: `exec.file.path == "/etc/foo/bar"`,
123+
Status: "loaded",
124+
Actions: []RuleAction{
125+
{
126+
Set: &RuleSetAction{
127+
Name: "artifact",
128+
Field: "process.file.path",
129+
Capture: "/orchestration/([^/]+)/",
130+
Scope: "process",
131+
},
132+
},
133+
},
134+
},
135+
},
136+
},
137+
},
138+
},
82139
{
83140
name: "rule with no expression",
84141
policies: []*testPolicy{

pkg/security/secl/compiler/eval/strings.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,56 @@ func (s *ScalarStringMatcher) Matches(value string) bool {
268268
return s.value == value
269269
}
270270

271+
// CaptureStringMatcher extracts a single capture group out of a value. Unlike the
272+
// StringMatcher implementations it is not used to match values, but to pull a
273+
// substring out of them, and is therefore compiled from a regexp with at least one
274+
// capture group.
275+
type CaptureStringMatcher struct {
276+
pattern string
277+
re *regexp.Regexp
278+
}
279+
280+
// Compile a capture pattern. The pattern must be a valid regular expression holding
281+
// at least one capture group, as only the first one is ever extracted.
282+
func (c *CaptureStringMatcher) Compile(pattern string) error {
283+
re, err := regexp.Compile(pattern)
284+
if err != nil {
285+
return err
286+
}
287+
288+
if re.NumSubexp() < 1 {
289+
return errors.New("no capture group")
290+
}
291+
292+
c.pattern = pattern
293+
c.re = re
294+
295+
return nil
296+
}
297+
298+
// String implements the stringer interface
299+
func (c *CaptureStringMatcher) String() string {
300+
return c.pattern
301+
}
302+
303+
// Capture returns the content of the first capture group, and whether the pattern
304+
// matched the value at all. A value that doesn't match, or that matches without the
305+
// first group participating, returns false.
306+
func (c *CaptureStringMatcher) Capture(value string) (string, bool) {
307+
// FindStringSubmatchIndex is used over FindStringSubmatch to avoid allocating a
308+
// slice of strings for every group of the pattern
309+
indexes := c.re.FindStringSubmatchIndex(value)
310+
if indexes == nil || indexes[2] < 0 {
311+
return "", false
312+
}
313+
314+
// slicing would share the backing array of the whole field value, keeping it alive
315+
// for as long as the captured value is stored. Captures end up in variables that
316+
// can be inherited across a process tree and outlive the event by a long time, so
317+
// a small artifact must not pin the path it was extracted from.
318+
return strings.Clone(value[indexes[2]:indexes[3]]), true
319+
}
320+
271321
// NewStringMatcher returns a new string matcher
272322
func NewStringMatcher(kind FieldValueType, pattern string, opts StringCmpOpts) (StringMatcher, error) {
273323
if opts.Sanitize != nil {

pkg/security/secl/compiler/eval/strings_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,118 @@ func TestRegexp(t *testing.T) {
259259
})
260260
}
261261

262+
func TestCaptureStringMatcher(t *testing.T) {
263+
t.Run("ssm-command-id", func(t *testing.T) {
264+
var matcher CaptureStringMatcher
265+
if err := matcher.Compile("/orchestration/([^/]+)/"); err != nil {
266+
t.Fatal(err)
267+
}
268+
269+
value, found := matcher.Capture("/var/lib/amazon/ssm/i-0abc/document/orchestration/a1b2c3d4/awsrunShellScript")
270+
if !found {
271+
t.Fatal("should have captured")
272+
}
273+
274+
if value != "a1b2c3d4" {
275+
t.Errorf("should have captured the command id, got `%s`", value)
276+
}
277+
})
278+
279+
t.Run("imds-role", func(t *testing.T) {
280+
var matcher CaptureStringMatcher
281+
if err := matcher.Compile("/security-credentials/([^/?]+)"); err != nil {
282+
t.Fatal(err)
283+
}
284+
285+
value, found := matcher.Capture("/latest/meta-data/iam/security-credentials/my-role?x=1")
286+
if !found {
287+
t.Fatal("should have captured")
288+
}
289+
290+
if value != "my-role" {
291+
t.Errorf("should have captured the role name, got `%s`", value)
292+
}
293+
})
294+
295+
t.Run("no-match", func(t *testing.T) {
296+
var matcher CaptureStringMatcher
297+
if err := matcher.Compile("/orchestration/([^/]+)/"); err != nil {
298+
t.Fatal(err)
299+
}
300+
301+
if value, found := matcher.Capture("/etc/passwd"); found {
302+
t.Errorf("shouldn't have captured, got `%s`", value)
303+
}
304+
})
305+
306+
t.Run("no-capture-group", func(t *testing.T) {
307+
var matcher CaptureStringMatcher
308+
if err := matcher.Compile("/orchestration/[^/]+/"); err == nil {
309+
t.Error("should have failed to compile a pattern without a capture group")
310+
}
311+
})
312+
313+
t.Run("non-capturing-group-only", func(t *testing.T) {
314+
var matcher CaptureStringMatcher
315+
if err := matcher.Compile("/orchestration/(?:[^/]+)/"); err == nil {
316+
t.Error("should have failed to compile a pattern with only a non-capturing group")
317+
}
318+
})
319+
320+
t.Run("malformed-pattern", func(t *testing.T) {
321+
var matcher CaptureStringMatcher
322+
if err := matcher.Compile("/orchestration/([^/]+"); err == nil {
323+
t.Error("should have failed to compile a malformed pattern")
324+
}
325+
})
326+
327+
t.Run("non-participating-group", func(t *testing.T) {
328+
var matcher CaptureStringMatcher
329+
if err := matcher.Compile("abc(x)?"); err != nil {
330+
t.Fatal(err)
331+
}
332+
333+
// the pattern matches, but the optional first group took no part in it
334+
if value, found := matcher.Capture("abc"); found {
335+
t.Errorf("shouldn't have captured, got `%s`", value)
336+
}
337+
})
338+
339+
t.Run("first-group-only", func(t *testing.T) {
340+
var matcher CaptureStringMatcher
341+
if err := matcher.Compile("/ecs/([0-9a-f-]+)/([0-9a-f-]+)"); err != nil {
342+
t.Fatal(err)
343+
}
344+
345+
value, found := matcher.Capture("/ecs/a1b2c3d4-1111/e5f6a7b8-2222")
346+
if !found {
347+
t.Fatal("should have captured")
348+
}
349+
350+
if value != "a1b2c3d4-1111" {
351+
t.Errorf("should have captured the first group only, got `%s`", value)
352+
}
353+
})
354+
355+
t.Run("big-or-pattern", func(t *testing.T) {
356+
// RegexpStringMatcher takes a fast path for this shape and leaves its compiled
357+
// regexp nil, which is why capture cannot reuse it. Make sure we still extract.
358+
var matcher CaptureStringMatcher
359+
if err := matcher.Compile(".*(restore|recovery|ransom).*"); err != nil {
360+
t.Fatal(err)
361+
}
362+
363+
value, found := matcher.Capture("123ransom456.txt")
364+
if !found {
365+
t.Fatal("should have captured")
366+
}
367+
368+
if value != "ransom" {
369+
t.Errorf("should have captured the alternation, got `%s`", value)
370+
}
371+
})
372+
}
373+
262374
func BenchmarkRegexpEvaluator(b *testing.B) {
263375
b.Run("with stars", func(b *testing.B) {
264376
pattern := ".*(restore|recovery|readme|instruction|how_to|ransom).*"

pkg/security/secl/rules/actions.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type Action struct {
1818
InternalCallback *InternalCallbackDefinition
1919
FilterEvaluator *eval.RuleEvaluator
2020
ScopeFieldEvaluator eval.Evaluator
21+
CaptureMatcher *eval.CaptureStringMatcher
2122
}
2223

2324
// CompileFilter compiles the filter expression
@@ -57,6 +58,21 @@ func (a *Action) CompileScopeField(model eval.Model) error {
5758
return nil
5859
}
5960

61+
// CompileCaptureMatcher compiles the capture pattern
62+
func (a *Action) CompileCaptureMatcher() error {
63+
if a.Def.Set == nil || len(a.Def.Set.Capture) == 0 {
64+
return nil
65+
}
66+
67+
var matcher eval.CaptureStringMatcher
68+
if err := matcher.Compile(a.Def.Set.Capture); err != nil {
69+
return &ErrCapture{Pattern: a.Def.Set.Capture, Err: err}
70+
}
71+
72+
a.CaptureMatcher = &matcher
73+
return nil
74+
}
75+
6076
// IsAccepted returns whether a filter is accepted and has to be executed
6177
func (a *Action) IsAccepted(ctx *eval.Context) bool {
6278
return a.FilterEvaluator == nil || a.FilterEvaluator.Eval(ctx)

pkg/security/secl/rules/errors.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,20 @@ func (e *ErrScopeField) Unwrap() error {
184184
return e.Err
185185
}
186186

187+
// ErrCapture is returned on capture definition error
188+
type ErrCapture struct {
189+
Pattern string
190+
Err error
191+
}
192+
193+
func (e *ErrCapture) Error() string {
194+
return fmt.Sprintf("capture `%s` error: %s", e.Pattern, e.Err)
195+
}
196+
197+
func (e *ErrCapture) Unwrap() error {
198+
return e.Err
199+
}
200+
187201
// ErrFieldNotAvailable is returned when a field is not available
188202
type ErrFieldNotAvailable struct {
189203
Field eval.Field

pkg/security/secl/rules/model.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ type SetDefinition struct {
226226
Value interface{} `yaml:"value,omitempty" json:"value,omitempty" jsonschema:"oneof_required=SetWithValue,oneof_type=string;integer;boolean;array"`
227227
DefaultValue interface{} `yaml:"default_value,omitempty" json:"default_value,omitempty" jsonschema:"oneof_type=string;integer;boolean;array"`
228228
Field string `yaml:"field,omitempty" json:"field,omitempty" jsonschema:"oneof_required=SetWithField"`
229+
Capture string `yaml:"capture,omitempty" json:"capture,omitempty" jsonschema:"description=A regular expression with a single capture group applied to 'field' to extract the value to store"`
229230
Expression string `yaml:"expression,omitempty" json:"expression,omitempty" jsonschema:"oneof_required=SetWithExpression"`
230231
Append bool `yaml:"append,omitempty" json:"append,omitempty"`
231232
Scope Scope `yaml:"scope,omitempty" json:"scope,omitempty" jsonschema:"enum=process,enum=container,enum=cgroup"`
@@ -259,6 +260,13 @@ func (s *SetDefinition) PreCheck(_ PolicyLoaderOpts) error {
259260
return fmt.Errorf("failed to infer type for variable '%s', please set 'default_value'", s.Name)
260261
}
261262

263+
// 'capture' extracts a substring out of the value of 'field', so it is meaningless
264+
// without it. Combined with the check above, this also makes 'capture' mutually
265+
// exclusive with 'value' and 'expression'.
266+
if s.Capture != "" && s.Field == "" {
267+
return errors.New("'capture' can only be used along with 'field'")
268+
}
269+
262270
if s.Inherited && s.Scope != ScopeProcess {
263271
return errors.New("only variables scoped to process can be marked as inherited")
264272
}

0 commit comments

Comments
 (0)