diff --git a/pkg/acquisition/modules/appsec/appsec_hooks_test.go b/pkg/acquisition/modules/appsec/appsec_hooks_test.go index d64b0f10090..b2af7da019a 100644 --- a/pkg/acquisition/modules/appsec/appsec_hooks_test.go +++ b/pkg/acquisition/modules/appsec/appsec_hooks_test.go @@ -1082,3 +1082,260 @@ func TestOnMatchRemediationHooks(t *testing.T) { runTests(t, tests) } + +func TestAppsecPhaseScopedHooks(t *testing.T) { + tests := []appsecRuleTest{ + { + name: "inband on_match: change return code (phase-scoped, no IsInBand filter needed)", + expected_load_ok: true, + inband_rules: []appsec_rule.CustomRule{ + { + Name: "rule1", + Zones: []string{"ARGS"}, + Variables: []string{"foo"}, + Match: appsec_rule.Match{Type: "regex", Value: "^toto"}, + Transform: []string{"lowercase"}, + }, + }, + inband_on_match: []appsec.Hook{ + {Apply: []string{"SetReturnCode(413)"}}, + }, + input_request: appsec.ParsedRequest{ + RemoteAddr: "1.2.3.4", + Method: "GET", + URI: "/urllll", + Args: url.Values{"foo": []string{"toto"}}, + HTTPRequest: &http.Request{Host: "example.com"}, + }, + output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) { + require.Len(t, events, 2) + require.Len(t, responses, 1) + require.Equal(t, 413, responses[0].UserHTTPResponseCode) + }, + }, + { + name: "inband on_match: set remediation (phase-scoped)", + expected_load_ok: true, + inband_rules: []appsec_rule.CustomRule{ + { + Name: "rule1", + Zones: []string{"ARGS"}, + Variables: []string{"foo"}, + Match: appsec_rule.Match{Type: "regex", Value: "^toto"}, + Transform: []string{"lowercase"}, + }, + }, + inband_on_match: []appsec.Hook{ + {Apply: []string{"SetRemediation('captcha')"}}, + }, + input_request: appsec.ParsedRequest{ + RemoteAddr: "1.2.3.4", + Method: "GET", + URI: "/urllll", + Args: url.Values{"foo": []string{"toto"}}, + HTTPRequest: &http.Request{Host: "example.com"}, + }, + output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) { + require.Len(t, responses, 1) + require.Equal(t, "captcha", responses[0].Action) + }, + }, + { + name: "inband on_match: cancel event (phase-scoped)", + expected_load_ok: true, + inband_rules: []appsec_rule.CustomRule{ + { + Name: "rule1", + Zones: []string{"ARGS"}, + Variables: []string{"foo"}, + Match: appsec_rule.Match{Type: "regex", Value: "^toto"}, + Transform: []string{"lowercase"}, + }, + }, + inband_on_match: []appsec.Hook{ + {Apply: []string{"CancelEvent()"}}, + }, + input_request: appsec.ParsedRequest{ + RemoteAddr: "1.2.3.4", + Method: "GET", + URI: "/urllll", + Args: url.Values{"foo": []string{"toto"}}, + HTTPRequest: &http.Request{Host: "example.com"}, + }, + output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) { + // CancelEvent() only cancels the LOG event, the APPSEC alert is still sent + require.Len(t, events, 1) + require.Equal(t, pipeline.APPSEC, events[0].Type) + require.Len(t, responses, 1) + }, + }, + { + name: "inband on_match with filter: only fires when filter matches (phase-scoped)", + expected_load_ok: true, + inband_rules: []appsec_rule.CustomRule{ + { + Name: "rule1", + Zones: []string{"ARGS"}, + Variables: []string{"foo"}, + Match: appsec_rule.Match{Type: "regex", Value: "^toto"}, + Transform: []string{"lowercase"}, + }, + }, + inband_on_match: []appsec.Hook{ + {Filter: "evt.Appsec.HasInBandMatches == true", Apply: []string{"SetReturnCode(418)"}}, + }, + input_request: appsec.ParsedRequest{ + RemoteAddr: "1.2.3.4", + Method: "GET", + URI: "/urllll", + Args: url.Values{"foo": []string{"toto"}}, + HTTPRequest: &http.Request{Host: "example.com"}, + }, + output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) { + require.Len(t, responses, 1) + require.Equal(t, 418, responses[0].UserHTTPResponseCode) + }, + }, + { + name: "shared + inband on_match: both execute (shared runs first)", + expected_load_ok: true, + inband_rules: []appsec_rule.CustomRule{ + { + Name: "rule1", + Zones: []string{"ARGS"}, + Variables: []string{"foo"}, + Match: appsec_rule.Match{Type: "regex", Value: "^toto"}, + Transform: []string{"lowercase"}, + }, + }, + on_match: []appsec.Hook{ + // Shared hook: runs for both phases, sets remediation + {Filter: "IsInBand == true", Apply: []string{"SetRemediation('captcha')"}}, + }, + inband_on_match: []appsec.Hook{ + // Phase-scoped hook: overrides the return code + {Apply: []string{"SetReturnCode(418)"}}, + }, + input_request: appsec.ParsedRequest{ + RemoteAddr: "1.2.3.4", + Method: "GET", + URI: "/urllll", + Args: url.Values{"foo": []string{"toto"}}, + HTTPRequest: &http.Request{Host: "example.com"}, + }, + output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) { + require.Len(t, responses, 1) + // Shared hook set captcha, phase-scoped hook set return code 418 + require.Equal(t, "captcha", responses[0].Action) + require.Equal(t, 418, responses[0].UserHTTPResponseCode) + }, + }, + { + name: "shared on_match break does not prevent phase-scoped hooks", + expected_load_ok: true, + DefaultRemediation: appsec.AllowRemediation, + inband_rules: []appsec_rule.CustomRule{ + { + Name: "rule1", + Zones: []string{"ARGS"}, + Variables: []string{"foo"}, + Match: appsec_rule.Match{Type: "regex", Value: "^toto"}, + Transform: []string{"lowercase"}, + }, + }, + on_match: []appsec.Hook{ + {Filter: "IsInBand == true", Apply: []string{"CancelEvent()"}, OnSuccess: "break"}, + }, + inband_on_match: []appsec.Hook{ + {Apply: []string{"SetRemediation('captcha')", "SetReturnCode(418)"}}, + }, + input_request: appsec.ParsedRequest{ + RemoteAddr: "1.2.3.4", + Method: "GET", + URI: "/urllll", + Args: url.Values{"foo": []string{"toto"}}, + HTTPRequest: &http.Request{Host: "example.com"}, + }, + output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) { + require.Len(t, responses, 1) + // Shared hook canceled LOG event with break, APPSEC alert still sent + // Phase-scoped hooks still run (break only stops shared hook list) + require.Len(t, events, 1) + require.Equal(t, pipeline.APPSEC, events[0].Type) + require.Equal(t, "captcha", responses[0].Action) + require.Equal(t, 418, responses[0].UserHTTPResponseCode) + }, + }, + { + name: "outofband on_match: send alert (phase-scoped)", + expected_load_ok: true, + DefaultRemediation: appsec.AllowRemediation, + outofband_rules: []appsec_rule.CustomRule{ + { + Name: "rule1", + Zones: []string{"ARGS"}, + Variables: []string{"foo"}, + Match: appsec_rule.Match{Type: "regex", Value: "^toto"}, + Transform: []string{"lowercase"}, + }, + }, + outofband_on_match: []appsec.Hook{ + {Apply: []string{"SendAlert()"}}, + }, + input_request: appsec.ParsedRequest{ + RemoteAddr: "1.2.3.4", + Method: "GET", + URI: "/urllll", + Args: url.Values{"foo": []string{"toto"}}, + HTTPRequest: &http.Request{Host: "example.com"}, + }, + output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) { + require.Len(t, responses, 1) + require.Equal(t, appsec.AllowRemediation, appsecResponse.Action) + // outofband matched and sent an alert event + require.NotEmpty(t, events) + foundAppsecEvt := false + for _, evt := range events { + if evt.Type == pipeline.APPSEC { + foundAppsecEvt = true + } + } + require.True(t, foundAppsecEvt, "expected an APPSEC event from outofband match") + }, + }, + { + name: "inband on_match with break+continue: break stops inband hooks", + expected_load_ok: true, + DefaultRemediation: appsec.AllowRemediation, + inband_rules: []appsec_rule.CustomRule{ + { + Name: "rule1", + Zones: []string{"ARGS"}, + Variables: []string{"foo"}, + Match: appsec_rule.Match{Type: "regex", Value: "^toto"}, + Transform: []string{"lowercase"}, + }, + }, + inband_on_match: []appsec.Hook{ + // break requires a filter to be present and match (sets has_match flag) + {Filter: "evt.Appsec.HasInBandMatches == true", Apply: []string{"SetRemediation('captcha')", "SetReturnCode(418)"}, OnSuccess: "break"}, + {Filter: "evt.Appsec.HasInBandMatches == true", Apply: []string{"SetRemediation('ban')"}}, // should not execute due to break + }, + input_request: appsec.ParsedRequest{ + RemoteAddr: "1.2.3.4", + Method: "GET", + URI: "/urllll", + Args: url.Values{"foo": []string{"toto"}}, + HTTPRequest: &http.Request{Host: "example.com"}, + }, + output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) { + require.Len(t, responses, 1) + // First hook ran (captcha + 418), second was skipped due to break + require.Equal(t, "captcha", responses[0].Action) + require.Equal(t, 418, responses[0].UserHTTPResponseCode) + }, + }, + } + + runTests(t, tests) +} diff --git a/pkg/acquisition/modules/appsec/appsec_runner.go b/pkg/acquisition/modules/appsec/appsec_runner.go index 8ce99a3e753..8cf7bda4126 100644 --- a/pkg/acquisition/modules/appsec/appsec_runner.go +++ b/pkg/acquisition/modules/appsec/appsec_runner.go @@ -220,8 +220,12 @@ func (r *AppsecRunner) processRequest(state *appsec.AppsecRequestState, request func (r *AppsecRunner) ProcessInBandRules(state *appsec.AppsecRequestState, request *appsec.ParsedRequest) error { tx := appsec.NewExtendedTransaction(r.AppsecInbandEngine, request.UUID) state.Tx = tx - // Even if we have no inband rules, we might have pre-eval rules to process - if len(r.AppsecRuntime.InBandRules) == 0 && len(r.AppsecRuntime.CompiledPreEval) == 0 { + // Even if we have no inband rules, we might have pre-eval or post-eval rules to process + if len(r.AppsecRuntime.InBandRules) == 0 && + len(r.AppsecRuntime.CommonHooks.PreEval) == 0 && + len(r.AppsecRuntime.InBandHooks.PreEval) == 0 && + len(r.AppsecRuntime.CommonHooks.PostEval) == 0 && + len(r.AppsecRuntime.InBandHooks.PostEval) == 0 { return nil } err := r.processRequest(state, request) @@ -231,7 +235,11 @@ func (r *AppsecRunner) ProcessInBandRules(state *appsec.AppsecRequestState, requ func (r *AppsecRunner) ProcessOutOfBandRules(state *appsec.AppsecRequestState, request *appsec.ParsedRequest) error { tx := appsec.NewExtendedTransaction(r.AppsecOutbandEngine, request.UUID) state.Tx = tx - if len(r.AppsecRuntime.OutOfBandRules) == 0 && len(r.AppsecRuntime.CompiledPreEval) == 0 { + if len(r.AppsecRuntime.OutOfBandRules) == 0 && + len(r.AppsecRuntime.CommonHooks.PreEval) == 0 && + len(r.AppsecRuntime.OutOfBandHooks.PreEval) == 0 && + len(r.AppsecRuntime.CommonHooks.PostEval) == 0 && + len(r.AppsecRuntime.OutOfBandHooks.PostEval) == 0 { return nil } err := r.processRequest(state, request) diff --git a/pkg/acquisition/modules/appsec/appsec_test.go b/pkg/acquisition/modules/appsec/appsec_test.go index 70e013fe012..6cf824e8649 100644 --- a/pkg/acquisition/modules/appsec/appsec_test.go +++ b/pkg/acquisition/modules/appsec/appsec_test.go @@ -32,6 +32,13 @@ type appsecRuleTest struct { pre_eval []appsec.Hook post_eval []appsec.Hook on_match []appsec.Hook + // Phase-scoped hooks (dispatched only during the matching phase) + inband_on_match []appsec.Hook + inband_pre_eval []appsec.Hook + inband_post_eval []appsec.Hook + outofband_on_match []appsec.Hook + outofband_pre_eval []appsec.Hook + outofband_post_eval []appsec.Hook BouncerBlockedHTTPCode int UserBlockedHTTPCode int UserPassedHTTPCode int @@ -115,6 +122,23 @@ func testAppSecEngine(t *testing.T, test appsecRuleTest) { DefaultPassAction: test.DefaultPassAction, } + // Set phase-scoped hooks if any are provided + if len(test.inband_on_match) > 0 || len(test.inband_pre_eval) > 0 || len(test.inband_post_eval) > 0 { + appsecCfg.InBand = &appsec.AppsecPhaseConfig{ + OnMatch: test.inband_on_match, + PreEval: test.inband_pre_eval, + PostEval: test.inband_post_eval, + } + } + + if len(test.outofband_on_match) > 0 || len(test.outofband_pre_eval) > 0 || len(test.outofband_post_eval) > 0 { + appsecCfg.OutOfBand = &appsec.AppsecPhaseConfig{ + OnMatch: test.outofband_on_match, + PreEval: test.outofband_pre_eval, + PostEval: test.outofband_post_eval, + } + } + hub := cwhub.Hub{} AppsecRuntime, err := appsecCfg.Build(&hub) if err != nil { diff --git a/pkg/appsec/appsec.go b/pkg/appsec/appsec.go index acb2d9ed65c..b873b6dbffa 100644 --- a/pkg/appsec/appsec.go +++ b/pkg/appsec/appsec.go @@ -28,13 +28,54 @@ type Hook struct { ApplyExpr []*vm.Program `yaml:"-"` } +type hookStage int + const ( - hookOnLoad = iota + hookOnLoad hookStage = iota hookPreEval hookPostEval hookOnMatch ) +func (s hookStage) String() string { + switch s { + case hookOnLoad: + return "on_load" + case hookPreEval: + return "pre_eval" + case hookPostEval: + return "post_eval" + case hookOnMatch: + return "on_match" + default: + return "unknown" + } +} + +// PhaseHooks bundles the three phase-scoped hook lists (pre_eval, post_eval, +// on_match) that run during request evaluation. OnLoad is excluded because it +// runs once at startup and is not phase-scoped. +type PhaseHooks struct { + PreEval []Hook + PostEval []Hook + OnMatch []Hook +} + +// get returns the hook list for a given stage, or nil for stages that are not +// phase-scoped (hookOnLoad or unknown). +func (p *PhaseHooks) get(stage hookStage) []Hook { + switch stage { + case hookPreEval: + return p.PreEval + case hookPostEval: + return p.PostEval + case hookOnMatch: + return p.OnMatch + default: + return nil + } +} + const ( BanRemediation = "ban" CaptchaRemediation = "captcha" @@ -48,10 +89,10 @@ const ( PhaseOutOfBand ) -func (h *Hook) Build(hookStage int) error { +func (h *Hook) Build(stage hookStage) error { ctx := map[string]any{} - switch hookStage { + switch stage { case hookOnLoad: ctx = GetOnLoadEnv(&AppsecRuntimeConfig{}) case hookPreEval: @@ -155,6 +196,17 @@ type AppsecSubEngineOpts struct { RequestBodyInMemoryLimit *int `yaml:"request_body_in_memory_limit"` } +// AppsecPhaseConfig holds configuration scoped to a specific phase (inband or outofband). +// Hooks defined here are automatically dispatched only during the corresponding phase. +type AppsecPhaseConfig struct { + Rules []string `yaml:"rules"` + OnMatch []Hook `yaml:"on_match"` + PreEval []Hook `yaml:"pre_eval"` + PostEval []Hook `yaml:"post_eval"` + Options AppsecSubEngineOpts `yaml:"options"` + VariablesTracking []string `yaml:"variables_tracking"` +} + // runtime version of AppsecConfig type AppsecRuntimeConfig struct { Name string @@ -162,13 +214,15 @@ type AppsecRuntimeConfig struct { InBandRules []AppsecCollection - DefaultRemediation string - RemediationByTag map[string]string // Also used for ByName, as the name (for modsec rules) is a tag crowdsec-NAME - RemediationById map[int]string - CompiledOnLoad []Hook - CompiledPreEval []Hook - CompiledPostEval []Hook - CompiledOnMatch []Hook + DefaultRemediation string + RemediationByTag map[string]string // Also used for ByName, as the name (for modsec rules) is a tag crowdsec-NAME + RemediationById map[int]string + + CompiledOnLoad []Hook // runs once at startup, not phase-scoped + CommonHooks PhaseHooks // apply to both phases + InBandHooks PhaseHooks // only run during in-band + OutOfBandHooks PhaseHooks // only run during out-of-band + CompiledVariablesTracking []*regexp.Regexp Config *AppsecConfig // CorazaLogger debuglog.Logger @@ -202,6 +256,9 @@ type AppsecConfig struct { InbandOptions AppsecSubEngineOpts `yaml:"inband_options"` OutOfBandOptions AppsecSubEngineOpts `yaml:"outofband_options"` + InBand *AppsecPhaseConfig `yaml:"inband"` + OutOfBand *AppsecPhaseConfig `yaml:"outofband"` + LogLevel *log.Level `yaml:"log_level"` Logger *log.Entry `yaml:"-"` } @@ -286,6 +343,10 @@ func (wc *AppsecConfig) LoadByPath(file string) error { return fmt.Errorf("unable to parse yaml file %s : %w", file, err) } + // Normalize phase-scoped sections: merge rules, options, and variables_tracking + // into flat fields. Hooks stay in the phase sections for Build() to compile separately. + tmp.normalizePhaseScoped() + if wc.Name == "" && tmp.Name != "" { wc.Name = tmp.Name } @@ -319,6 +380,27 @@ func (wc *AppsecConfig) LoadByPath(file string) error { wc.VariablesTracking = append(wc.VariablesTracking, tmp.VariablesTracking...) } + // Append phase-scoped hooks + if tmp.InBand != nil { + if wc.InBand == nil { + wc.InBand = &AppsecPhaseConfig{} + } + + wc.InBand.OnMatch = append(wc.InBand.OnMatch, tmp.InBand.OnMatch...) + wc.InBand.PreEval = append(wc.InBand.PreEval, tmp.InBand.PreEval...) + wc.InBand.PostEval = append(wc.InBand.PostEval, tmp.InBand.PostEval...) + } + + if tmp.OutOfBand != nil { + if wc.OutOfBand == nil { + wc.OutOfBand = &AppsecPhaseConfig{} + } + + wc.OutOfBand.OnMatch = append(wc.OutOfBand.OnMatch, tmp.OutOfBand.OnMatch...) + wc.OutOfBand.PreEval = append(wc.OutOfBand.PreEval, tmp.OutOfBand.PreEval...) + wc.OutOfBand.PostEval = append(wc.OutOfBand.PostEval, tmp.OutOfBand.PostEval...) + } + // override other options wc.LogLevel = tmp.LogLevel @@ -348,6 +430,92 @@ func (wc *AppsecConfig) LoadByPath(file string) error { return nil } +// normalizePhaseScoped merges rules, options, and variables_tracking from +// phase-scoped sections (inband/outofband) into the flat top-level fields. +// Hooks are left in the phase sections for Build() to compile separately. +func (wc *AppsecConfig) normalizePhaseScoped() { + if wc.InBand != nil { + wc.InBandRules = append(wc.InBandRules, wc.InBand.Rules...) + wc.InBand.Rules = nil + + if wc.InBand.Options.DisableBodyInspection { + wc.InbandOptions.DisableBodyInspection = true + } + + if wc.InBand.Options.RequestBodyInMemoryLimit != nil { + wc.InbandOptions.RequestBodyInMemoryLimit = wc.InBand.Options.RequestBodyInMemoryLimit + } + + wc.VariablesTracking = append(wc.VariablesTracking, wc.InBand.VariablesTracking...) + wc.InBand.VariablesTracking = nil + } + + if wc.OutOfBand != nil { + wc.OutOfBandRules = append(wc.OutOfBandRules, wc.OutOfBand.Rules...) + wc.OutOfBand.Rules = nil + + if wc.OutOfBand.Options.DisableBodyInspection { + wc.OutOfBandOptions.DisableBodyInspection = true + } + + if wc.OutOfBand.Options.RequestBodyInMemoryLimit != nil { + wc.OutOfBandOptions.RequestBodyInMemoryLimit = wc.OutOfBand.Options.RequestBodyInMemoryLimit + } + + wc.VariablesTracking = append(wc.VariablesTracking, wc.OutOfBand.VariablesTracking...) + wc.OutOfBand.VariablesTracking = nil + } +} + +// buildHookList validates and compiles a list of hooks of the given stage. +func buildHookList(hooks []Hook, stage hookStage) ([]Hook, error) { + var compiled []Hook + + for _, hook := range hooks { + if hook.OnSuccess != "" && hook.OnSuccess != "continue" && hook.OnSuccess != "break" { + return nil, fmt.Errorf("invalid 'on_success' for %s hook : %s", stage, hook.OnSuccess) + } + + if err := hook.Build(stage); err != nil { + return nil, fmt.Errorf("unable to build %s hook : %w", stage, err) + } + + compiled = append(compiled, hook) + } + + return compiled, nil +} + +// buildPhaseHooks compiles pre_eval / post_eval / on_match hook lists into a +// PhaseHooks. phaseName is only used to wrap errors ("" for the shared section). +func buildPhaseHooks(phaseName string, pre, post, onMatch []Hook) (PhaseHooks, error) { + var ( + out PhaseHooks + err error + ) + + wrap := func(e error) error { + if phaseName == "" || e == nil { + return e + } + return fmt.Errorf("%s: %w", phaseName, e) + } + + if out.PreEval, err = buildHookList(pre, hookPreEval); err != nil { + return PhaseHooks{}, wrap(err) + } + + if out.PostEval, err = buildHookList(post, hookPostEval); err != nil { + return PhaseHooks{}, wrap(err) + } + + if out.OnMatch, err = buildHookList(onMatch, hookOnMatch); err != nil { + return PhaseHooks{}, wrap(err) + } + + return out, nil +} + func (wc *AppsecConfig) Load(configName string, hub *cwhub.Hub) error { item := hub.GetItem(cwhub.APPSEC_CONFIGS, configName) @@ -433,56 +601,28 @@ func (wc *AppsecConfig) Build(hub *cwhub.Hub) (*AppsecRuntimeConfig, error) { wc.Logger.Infof("Loaded %d inband rules", len(ret.InBandRules)) // load hooks - for _, hook := range wc.OnLoad { - if hook.OnSuccess != "" && hook.OnSuccess != "continue" && hook.OnSuccess != "break" { - return nil, fmt.Errorf("invalid 'on_success' for on_load hook : %s", hook.OnSuccess) - } - - err := hook.Build(hookOnLoad) - if err != nil { - return nil, fmt.Errorf("unable to build on_load hook : %s", err) - } + var err error - ret.CompiledOnLoad = append(ret.CompiledOnLoad, hook) + if ret.CompiledOnLoad, err = buildHookList(wc.OnLoad, hookOnLoad); err != nil { + return nil, err } - for _, hook := range wc.PreEval { - if hook.OnSuccess != "" && hook.OnSuccess != "continue" && hook.OnSuccess != "break" { - return nil, fmt.Errorf("invalid 'on_success' for pre_eval hook : %s", hook.OnSuccess) - } - - err := hook.Build(hookPreEval) - if err != nil { - return nil, fmt.Errorf("unable to build pre_eval hook : %s", err) - } - - ret.CompiledPreEval = append(ret.CompiledPreEval, hook) + if ret.CommonHooks, err = buildPhaseHooks("", wc.PreEval, wc.PostEval, wc.OnMatch); err != nil { + return nil, err } - for _, hook := range wc.PostEval { - if hook.OnSuccess != "" && hook.OnSuccess != "continue" && hook.OnSuccess != "break" { - return nil, fmt.Errorf("invalid 'on_success' for post_eval hook : %s", hook.OnSuccess) + if wc.InBand != nil { + if ret.InBandHooks, err = buildPhaseHooks("inband", + wc.InBand.PreEval, wc.InBand.PostEval, wc.InBand.OnMatch); err != nil { + return nil, err } - - err := hook.Build(hookPostEval) - if err != nil { - return nil, fmt.Errorf("unable to build post_eval hook : %s", err) - } - - ret.CompiledPostEval = append(ret.CompiledPostEval, hook) } - for _, hook := range wc.OnMatch { - if hook.OnSuccess != "" && hook.OnSuccess != "continue" && hook.OnSuccess != "break" { - return nil, fmt.Errorf("invalid 'on_success' for on_match hook : %s", hook.OnSuccess) - } - - err := hook.Build(hookOnMatch) - if err != nil { - return nil, fmt.Errorf("unable to build on_match hook : %s", err) + if wc.OutOfBand != nil { + if ret.OutOfBandHooks, err = buildPhaseHooks("outofband", + wc.OutOfBand.PreEval, wc.OutOfBand.PostEval, wc.OutOfBand.OnMatch); err != nil { + return nil, err } - - ret.CompiledOnMatch = append(ret.CompiledOnMatch, hook) } // variable tracking @@ -498,14 +638,15 @@ func (wc *AppsecConfig) Build(hub *cwhub.Hub) (*AppsecRuntimeConfig, error) { return ret, nil } -func (w *AppsecRuntimeConfig) ProcessOnLoadRules() error { +// processHooks runs a list of compiled hooks with the given environment. +func (w *AppsecRuntimeConfig) processHooks(hooks []Hook, env map[string]interface{}, hookType string) error { has_match := false - for _, rule := range w.CompiledOnLoad { + for _, rule := range hooks { if rule.FilterExpr != nil { - output, err := exprhelpers.Run(rule.FilterExpr, GetOnLoadEnv(w), w.Logger, w.Logger.Level >= log.DebugLevel) + output, err := exprhelpers.Run(rule.FilterExpr, env, w.Logger, w.Logger.Level >= log.DebugLevel) if err != nil { - return fmt.Errorf("unable to run appsec on_load filter %s : %w", rule.Filter, err) + return fmt.Errorf("unable to run appsec %s filter %s : %w", hookType, rule.Filter, err) } switch t := output.(type) { @@ -523,15 +664,15 @@ func (w *AppsecRuntimeConfig) ProcessOnLoadRules() error { } for _, applyExpr := range rule.ApplyExpr { - o, err := exprhelpers.Run(applyExpr, GetOnLoadEnv(w), w.Logger, w.Logger.Level >= log.DebugLevel) + o, err := exprhelpers.Run(applyExpr, env, w.Logger, w.Logger.Level >= log.DebugLevel) if err != nil { - w.Logger.Errorf("unable to apply appsec on_load expr: %s", err) + w.Logger.Errorf("unable to apply appsec %s expr: %s", hookType, err) continue } switch t := o.(type) { case error: - w.Logger.Errorf("unable to apply appsec on_load expr: %s", t) + w.Logger.Errorf("unable to apply appsec %s expr: %s", hookType, t) continue default: } @@ -545,145 +686,39 @@ func (w *AppsecRuntimeConfig) ProcessOnLoadRules() error { return nil } -func (w *AppsecRuntimeConfig) ProcessOnMatchRules(state *AppsecRequestState, request *ParsedRequest, evt pipeline.Event) error { - has_match := false - - for _, rule := range w.CompiledOnMatch { - if rule.FilterExpr != nil { - output, err := exprhelpers.Run(rule.FilterExpr, GetOnMatchEnv(w, state, request, evt), w.Logger, w.Logger.Level >= log.DebugLevel) - if err != nil { - return fmt.Errorf("unable to run appsec on_match filter %s : %w", rule.Filter, err) - } - - switch t := output.(type) { - case bool: - if !t { - w.Logger.Debugf("filter didnt match") - continue - } - default: - w.Logger.Errorf("Filter must return a boolean, can't filter") - continue - } - - has_match = true - } +func (w *AppsecRuntimeConfig) ProcessOnLoadRules() error { + return w.processHooks(w.CompiledOnLoad, GetOnLoadEnv(w), "on_load") +} - for _, applyExpr := range rule.ApplyExpr { - o, err := exprhelpers.Run(applyExpr, GetOnMatchEnv(w, state, request, evt), w.Logger, w.Logger.Level >= log.DebugLevel) - if err != nil { - w.Logger.Errorf("unable to apply appsec on_match expr: %s", err) - continue - } +// runPhaseHooks runs the common hooks for the given stage, then dispatches to +// the in-band or out-of-band phase hooks depending on the request band. +func (w *AppsecRuntimeConfig) runPhaseHooks(stage hookStage, env map[string]interface{}, request *ParsedRequest) error { + label := stage.String() - switch t := o.(type) { - case error: - w.Logger.Errorf("unable to apply appsec on_match expr: %s", t) - continue - default: - } - } + if err := w.processHooks(w.CommonHooks.get(stage), env, label); err != nil { + return err + } - if has_match && rule.OnSuccess == "break" { - break - } + switch { + case request.IsInBand: + return w.processHooks(w.InBandHooks.get(stage), env, label+"[inband]") + case request.IsOutBand: + return w.processHooks(w.OutOfBandHooks.get(stage), env, label+"[outofband]") } return nil } -func (w *AppsecRuntimeConfig) ProcessPreEvalRules(state *AppsecRequestState, request *ParsedRequest) error { - has_match := false - - for _, rule := range w.CompiledPreEval { - if rule.FilterExpr != nil { - output, err := exprhelpers.Run(rule.FilterExpr, GetPreEvalEnv(w, state, request), w.Logger, w.Logger.Level >= log.DebugLevel) - if err != nil { - return fmt.Errorf("unable to run appsec pre_eval filter %s : %w", rule.Filter, err) - } - - switch t := output.(type) { - case bool: - if !t { - w.Logger.Debugf("filter didnt match") - continue - } - default: - w.Logger.Errorf("Filter must return a boolean, can't filter") - continue - } - - has_match = true - } - // here means there is no filter or the filter matched - for _, applyExpr := range rule.ApplyExpr { - o, err := exprhelpers.Run(applyExpr, GetPreEvalEnv(w, state, request), w.Logger, w.Logger.Level >= log.DebugLevel) - if err != nil { - w.Logger.Errorf("unable to apply appsec pre_eval expr: %s", err) - continue - } - - switch t := o.(type) { - case error: - w.Logger.Errorf("unable to apply appsec pre_eval expr: %s", t) - continue - default: - } - } - - if has_match && rule.OnSuccess == "break" { - break - } - } +func (w *AppsecRuntimeConfig) ProcessOnMatchRules(state *AppsecRequestState, request *ParsedRequest, evt pipeline.Event) error { + return w.runPhaseHooks(hookOnMatch, GetOnMatchEnv(w, state, request, evt), request) +} - return nil +func (w *AppsecRuntimeConfig) ProcessPreEvalRules(state *AppsecRequestState, request *ParsedRequest) error { + return w.runPhaseHooks(hookPreEval, GetPreEvalEnv(w, state, request), request) } func (w *AppsecRuntimeConfig) ProcessPostEvalRules(state *AppsecRequestState, request *ParsedRequest) error { - has_match := false - - for _, rule := range w.CompiledPostEval { - if rule.FilterExpr != nil { - output, err := exprhelpers.Run(rule.FilterExpr, GetPostEvalEnv(w, state, request), w.Logger, w.Logger.Level >= log.DebugLevel) - if err != nil { - return fmt.Errorf("unable to run appsec post_eval filter %s : %w", rule.Filter, err) - } - - switch t := output.(type) { - case bool: - if !t { - w.Logger.Debugf("filter didnt match") - continue - } - default: - w.Logger.Errorf("Filter must return a boolean, can't filter") - continue - } - - has_match = true - } - // here means there is no filter or the filter matched - for _, applyExpr := range rule.ApplyExpr { - o, err := exprhelpers.Run(applyExpr, GetPostEvalEnv(w, state, request), w.Logger, w.Logger.Level >= log.DebugLevel) - if err != nil { - w.Logger.Errorf("unable to apply appsec post_eval expr: %s", err) - continue - } - - switch t := o.(type) { - case error: - w.Logger.Errorf("unable to apply appsec post_eval expr: %s", t) - continue - default: - } - } - - if has_match && rule.OnSuccess == "break" { - break - } - } - - return nil + return w.runPhaseHooks(hookPostEval, GetPostEvalEnv(w, state, request), request) } func (w *AppsecRuntimeConfig) RemoveInbandRuleByID(state *AppsecRequestState, id int) error { diff --git a/pkg/appsec/appsec_config_test.go b/pkg/appsec/appsec_config_test.go new file mode 100644 index 00000000000..da4fb35f038 --- /dev/null +++ b/pkg/appsec/appsec_config_test.go @@ -0,0 +1,347 @@ +package appsec + +import ( + "os" + "path/filepath" + "testing" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/crowdsecurity/crowdsec/pkg/cwhub" +) + +func newTestConfig() AppsecConfig { + return AppsecConfig{ + Logger: log.NewEntry(log.StandardLogger()), + } +} + +func writeTempYAML(t *testing.T, content string) string { + t.Helper() + + f := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(f, []byte(content), 0o644)) + + return f +} + +func TestLoadByPathNewFormatInband(t *testing.T) { + cfg := newTestConfig() + f := writeTempYAML(t, ` +name: test-config +inband: + rules: + - crowdsecurity/vpatch-* + on_match: + - apply: + - SetReturnCode(413) + pre_eval: + - filter: "1==1" + apply: + - RemoveInBandRuleByID(123) + post_eval: + - apply: + - DumpRequest() + options: + disable_body_inspection: true + variables_tracking: + - tx.anomaly_score +`) + + require.NoError(t, cfg.LoadByPath(f)) + assert.Equal(t, "test-config", cfg.Name) + assert.Equal(t, []string{"crowdsecurity/vpatch-*"}, cfg.InBandRules) + assert.True(t, cfg.InbandOptions.DisableBodyInspection) + assert.Contains(t, cfg.VariablesTracking, "tx.anomaly_score") + + // Hooks should be in the InBand phase config + require.NotNil(t, cfg.InBand) + assert.Len(t, cfg.InBand.OnMatch, 1) + assert.Len(t, cfg.InBand.PreEval, 1) + assert.Len(t, cfg.InBand.PostEval, 1) + + // Top-level hook lists should be empty (hooks are phase-scoped) + assert.Empty(t, cfg.OnMatch) + assert.Empty(t, cfg.PreEval) + assert.Empty(t, cfg.PostEval) +} + +func TestLoadByPathNewFormatOutofband(t *testing.T) { + cfg := newTestConfig() + f := writeTempYAML(t, ` +name: test-outofband +outofband: + rules: + - crowdsecurity/experimental-* + on_match: + - apply: + - CancelAlert() + options: + request_body_in_memory_limit: 1048576 +`) + + require.NoError(t, cfg.LoadByPath(f)) + assert.Equal(t, []string{"crowdsecurity/experimental-*"}, cfg.OutOfBandRules) + require.NotNil(t, cfg.OutOfBandOptions.RequestBodyInMemoryLimit) + assert.Equal(t, 1048576, *cfg.OutOfBandOptions.RequestBodyInMemoryLimit) + + require.NotNil(t, cfg.OutOfBand) + assert.Len(t, cfg.OutOfBand.OnMatch, 1) +} + +func TestLoadByPathOldFormat(t *testing.T) { + cfg := newTestConfig() + f := writeTempYAML(t, ` +name: test-old +inband_rules: + - crowdsecurity/vpatch-* +outofband_rules: + - crowdsecurity/experimental-* +on_match: + - filter: "IsInBand == true" + apply: + - SetReturnCode(413) +`) + + require.NoError(t, cfg.LoadByPath(f)) + assert.Equal(t, []string{"crowdsecurity/vpatch-*"}, cfg.InBandRules) + assert.Equal(t, []string{"crowdsecurity/experimental-*"}, cfg.OutOfBandRules) + assert.Len(t, cfg.OnMatch, 1) + assert.Nil(t, cfg.InBand) + assert.Nil(t, cfg.OutOfBand) +} + +func TestLoadByPathMixedFormat(t *testing.T) { + cfg := newTestConfig() + f := writeTempYAML(t, ` +name: test-mixed +inband_rules: + - crowdsecurity/base-config +on_match: + - filter: "IsInBand == true" + apply: + - SendAlert() +inband: + rules: + - crowdsecurity/vpatch-* + on_match: + - apply: + - SetReturnCode(413) +`) + + require.NoError(t, cfg.LoadByPath(f)) + // Rules should be merged + assert.Equal(t, []string{"crowdsecurity/base-config", "crowdsecurity/vpatch-*"}, cfg.InBandRules) + // Shared hook stays in shared list + assert.Len(t, cfg.OnMatch, 1) + // Phase-scoped hook in phase config + require.NotNil(t, cfg.InBand) + assert.Len(t, cfg.InBand.OnMatch, 1) +} + +func TestLoadByPathOnLoadUnderPhaseRejected(t *testing.T) { + cfg := newTestConfig() + f := writeTempYAML(t, ` +name: test-bad +inband: + on_load: + - apply: + - RemoveInBandRuleByID(123) +`) + + err := cfg.LoadByPath(f) + require.Error(t, err) + assert.Contains(t, err.Error(), "on_load") +} + +func TestLoadByPathMultiFileLoading(t *testing.T) { + cfg := newTestConfig() + + // First file: old format + f1 := writeTempYAML(t, ` +name: test-multi +inband_rules: + - crowdsecurity/base-config +on_match: + - filter: "IsInBand == true" + apply: + - SendAlert() +`) + + // Second file: new format + dir := t.TempDir() + f2 := filepath.Join(dir, "config2.yaml") + require.NoError(t, os.WriteFile(f2, []byte(` +inband: + rules: + - crowdsecurity/vpatch-* + on_match: + - apply: + - SetReturnCode(413) +`), 0o644)) + + require.NoError(t, cfg.LoadByPath(f1)) + require.NoError(t, cfg.LoadByPath(f2)) + + // Rules merged from both files + assert.Equal(t, []string{"crowdsecurity/base-config", "crowdsecurity/vpatch-*"}, cfg.InBandRules) + // Shared hook from file 1 + assert.Len(t, cfg.OnMatch, 1) + // Phase-scoped hook from file 2 + require.NotNil(t, cfg.InBand) + assert.Len(t, cfg.InBand.OnMatch, 1) +} + +func TestLoadByPathPhaseOptionsOverride(t *testing.T) { + cfg := newTestConfig() + + // Phase section options should override top-level options + f := writeTempYAML(t, ` +name: test-opts +inband_options: + disable_body_inspection: false +inband: + options: + disable_body_inspection: true +`) + + require.NoError(t, cfg.LoadByPath(f)) + assert.True(t, cfg.InbandOptions.DisableBodyInspection) +} + +func TestLoadByPathPhaseRulesNormalized(t *testing.T) { + // Verify that rules and variables_tracking from phase sections are moved + // to flat fields, and the phase section's fields are cleared afterward. + cfg := newTestConfig() + f := writeTempYAML(t, ` +name: test-normalize +inband: + rules: + - ruleA + - ruleB + variables_tracking: + - tx.anomaly_score +outofband: + rules: + - ruleC +`) + + require.NoError(t, cfg.LoadByPath(f)) + + // Rules moved to flat fields + assert.Equal(t, []string{"ruleA", "ruleB"}, cfg.InBandRules) + assert.Equal(t, []string{"ruleC"}, cfg.OutOfBandRules) + + // Phase config objects survive (they may still hold hooks) + require.NotNil(t, cfg.InBand) + require.NotNil(t, cfg.OutOfBand) + + // Rules cleared from phase sections after normalization + assert.Nil(t, cfg.InBand.Rules) + assert.Nil(t, cfg.OutOfBand.Rules) + + // variables_tracking also normalized + assert.Contains(t, cfg.VariablesTracking, "tx.anomaly_score") + assert.Nil(t, cfg.InBand.VariablesTracking) +} + +func TestLoadByPathEmptyPhaseSection(t *testing.T) { + cfg := newTestConfig() + f := writeTempYAML(t, ` +name: test-empty-phase +inband: + rules: [] +`) + + require.NoError(t, cfg.LoadByPath(f)) + assert.Empty(t, cfg.InBandRules) +} + +func TestBuildPopulatesPhaseHooks(t *testing.T) { + cfg := AppsecConfig{ + Logger: log.NewEntry(log.StandardLogger()), + DefaultRemediation: "ban", + // Shared hooks + PreEval: []Hook{{Apply: []string{"SetRemediationByTag('foo', 'captcha')"}}}, + PostEval: []Hook{{Apply: []string{"DumpRequest()"}}}, + OnMatch: []Hook{{Apply: []string{"SetReturnCode(418)"}}}, + // InBand hooks + InBand: &AppsecPhaseConfig{ + PreEval: []Hook{{Apply: []string{"SetRemediationByTag('bar', 'ban')"}}}, + OnMatch: []Hook{{Apply: []string{"SetReturnCode(413)"}}}, + }, + // OutOfBand hooks + OutOfBand: &AppsecPhaseConfig{ + PostEval: []Hook{{Apply: []string{"DumpRequest()"}}}, + }, + } + + hub := &cwhub.Hub{} + rt, err := cfg.Build(hub) + require.NoError(t, err) + + // Common hooks populated + assert.Len(t, rt.CommonHooks.PreEval, 1) + assert.Len(t, rt.CommonHooks.PostEval, 1) + assert.Len(t, rt.CommonHooks.OnMatch, 1) + + // InBand hooks populated + assert.Len(t, rt.InBandHooks.PreEval, 1) + assert.Empty(t, rt.InBandHooks.PostEval) + assert.Len(t, rt.InBandHooks.OnMatch, 1) + + // OutOfBand hooks populated + assert.Empty(t, rt.OutOfBandHooks.PreEval) + assert.Len(t, rt.OutOfBandHooks.PostEval, 1) + assert.Empty(t, rt.OutOfBandHooks.OnMatch) + + // Expressions are actually compiled + assert.NotNil(t, rt.CommonHooks.PreEval[0].ApplyExpr) + assert.NotNil(t, rt.InBandHooks.OnMatch[0].ApplyExpr) +} + +func TestBuildNilPhaseConfig(t *testing.T) { + cfg := AppsecConfig{ + Logger: log.NewEntry(log.StandardLogger()), + DefaultRemediation: "ban", + OnMatch: []Hook{{Apply: []string{"SetReturnCode(418)"}}}, + // InBand and OutOfBand left nil + } + + hub := &cwhub.Hub{} + rt, err := cfg.Build(hub) + require.NoError(t, err) + + // Common hooks populated + assert.Len(t, rt.CommonHooks.OnMatch, 1) + + // Phase-specific hooks empty (zero-value PhaseHooks) + assert.Empty(t, rt.InBandHooks.PreEval) + assert.Empty(t, rt.InBandHooks.PostEval) + assert.Empty(t, rt.InBandHooks.OnMatch) + assert.Empty(t, rt.OutOfBandHooks.PreEval) + assert.Empty(t, rt.OutOfBandHooks.PostEval) + assert.Empty(t, rt.OutOfBandHooks.OnMatch) +} + +func TestBuildOnLoadStaysOutOfPhaseHooks(t *testing.T) { + cfg := AppsecConfig{ + Logger: log.NewEntry(log.StandardLogger()), + DefaultRemediation: "ban", + OnLoad: []Hook{{Apply: []string{"RemoveInBandRuleByID(123)"}}}, + } + + hub := &cwhub.Hub{} + rt, err := cfg.Build(hub) + require.NoError(t, err) + + assert.Len(t, rt.CompiledOnLoad, 1) + assert.NotNil(t, rt.CompiledOnLoad[0].ApplyExpr) + + // PhaseHooks should have no on_load contamination + assert.Empty(t, rt.CommonHooks.PreEval) + assert.Empty(t, rt.CommonHooks.PostEval) + assert.Empty(t, rt.CommonHooks.OnMatch) +}