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
257 changes: 257 additions & 0 deletions pkg/acquisition/modules/appsec/appsec_hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
14 changes: 11 additions & 3 deletions pkg/acquisition/modules/appsec/appsec_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
buixor marked this conversation as resolved.
}
err := r.processRequest(state, request)
Expand All @@ -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
Comment thread
buixor marked this conversation as resolved.
}
err := r.processRequest(state, request)
Expand Down
24 changes: 24 additions & 0 deletions pkg/acquisition/modules/appsec/appsec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading