From 676352b5b118d6c95ce4f9563fc387279fcf8586 Mon Sep 17 00:00:00 2001 From: Sebastien Blot Date: Wed, 25 Oct 2023 18:45:49 +0200 Subject: [PATCH] new custom rule format --- pkg/acquisition/modules/waap/utils.go | 3 + pkg/waf/waap_rule.go | 100 ----------------- pkg/waf/waap_rule/modsec_rule_test.go | 67 ++++++++++++ pkg/waf/waap_rule/modsecurity.go | 152 ++++++++++++++++++++++++++ pkg/waf/waap_rule/types.go | 5 + pkg/waf/waap_rule/waap_rule.go | 65 +++++++++++ pkg/waf/waap_rule_test.go | 126 --------------------- pkg/waf/waap_rules_collection.go | 24 ++-- 8 files changed, 307 insertions(+), 235 deletions(-) delete mode 100644 pkg/waf/waap_rule.go create mode 100644 pkg/waf/waap_rule/modsec_rule_test.go create mode 100644 pkg/waf/waap_rule/modsecurity.go create mode 100644 pkg/waf/waap_rule/types.go create mode 100644 pkg/waf/waap_rule/waap_rule.go delete mode 100644 pkg/waf/waap_rule_test.go diff --git a/pkg/acquisition/modules/waap/utils.go b/pkg/acquisition/modules/waap/utils.go index 95b653fa1..9b8e1d892 100644 --- a/pkg/acquisition/modules/waap/utils.go +++ b/pkg/acquisition/modules/waap/utils.go @@ -202,6 +202,9 @@ func (r *WaapRunner) AccumulateTxToEvent(evt *types.Event, req waf.ParsedRequest "accuracy": rule.Rule().Accuracy(), "msg": rule.Message(), "severity": rule.Rule().Severity().String(), + "name": "FIXFIXFIXFIXFIX", + "hash": "FIXIFIX", + "version": "FIXFIXFIX", } evt.Waap.MatchedRules = append(evt.Waap.MatchedRules, corazaRule) } diff --git a/pkg/waf/waap_rule.go b/pkg/waf/waap_rule.go deleted file mode 100644 index eabee5866..000000000 --- a/pkg/waf/waap_rule.go +++ /dev/null @@ -1,100 +0,0 @@ -package waf - -import ( - "fmt" - "strings" - "time" -) - -type VPatchRule struct { - //Those 2 together represent something like ARGS.foo - //If only target is set, it's used for variables that are not a collection (REQUEST_METHOD, etc) - Target string `yaml:"target"` - Variable string `yaml:"var"` - - Match string `yaml:"match"` //@rx - Equals string `yaml:"equals"` //@eq - Transform string `yaml:"transform"` //t:lowercase, t:uppercase, etc - Detect string `yaml:"detect"` //@detectXSS, @detectSQLi, etc - Logic string `yaml:"logic,omitempty"` // "AND", "OR", or empty if not applicable - SubRules []VPatchRule `yaml:"sub_rules,omitempty"` -} - -func (v *VPatchRule) String() string { - return strings.Trim(v.constructRule(0), "\n") -} - -func countTotalRules(rules []VPatchRule) int { - count := 0 - for _, rule := range rules { - count++ - if rule.Logic == "AND" { - count += countTotalRules(rule.SubRules) - } - } - return count -} - -func (v *VPatchRule) constructRule(depth int) string { - var result string - result = v.singleRuleString() - - if len(v.SubRules) == 0 { - return result + "\n" - } - - switch v.Logic { - case "AND": - result = strings.TrimSuffix(result, `"`) + `,chain"` + "\n" - for _, subRule := range v.SubRules { - result += subRule.constructRule(depth + 1) - } - case "OR": - skips := countTotalRules(v.SubRules) - 1 - if depth == 0 { - skips++ - } - result = strings.TrimSuffix(result, `"`) + fmt.Sprintf(`,skip:%d"`+"\n", skips) - for _, subRule := range v.SubRules { - skips-- - if skips > 0 { - result += strings.TrimSuffix(subRule.singleRuleString(), `"`) + fmt.Sprintf(`,skip:%d"`+"\n", skips) - } else { - result += subRule.singleRuleString() + "\n" - } - } - } - return result -} - -func (v *VPatchRule) singleRuleString() string { - var operator string - var ruleStr string - - if v.Match != "" { - operator = fmt.Sprintf("@rx %s", v.Match) - } else if v.Equals != "" { - operator = fmt.Sprintf("@eq %s", v.Equals) - } else { - return "" - } - - if v.Variable != "" { - ruleStr = fmt.Sprintf(`SecRule %s:%s "%s"`, v.Target, v.Variable, operator) - } else { - ruleStr = fmt.Sprintf(`SecRule %s "%s"`, v.Target, operator) - } - - //FIXME: phase2 should probably not be hardcoded - //Find a better way than using time.Now().UnixMilli() to generate a unique ID - actions := fmt.Sprintf(` "id:%d,deny,log,phase:2`, time.Now().UnixNano()) - - // Handle transformation - if v.Transform != "" { - actions = actions + fmt.Sprintf(",t:%s", v.Transform) - } - actions = actions + `"` - ruleStr = ruleStr + actions - - return ruleStr -} diff --git a/pkg/waf/waap_rule/modsec_rule_test.go b/pkg/waf/waap_rule/modsec_rule_test.go new file mode 100644 index 000000000..3f40eff69 --- /dev/null +++ b/pkg/waf/waap_rule/modsec_rule_test.go @@ -0,0 +1,67 @@ +package waap_rule + +import "testing" + +func TestVPatchRuleString(t *testing.T) { + tests := []struct { + name string + rule CustomRule + expected string + }{ + { + name: "Base Rule", + rule: CustomRule{ + Zones: []string{"ARGS"}, + Variables: []string{"foo"}, + Match: match{Type: "regex", Value: "[^a-zA-Z]"}, + Transform: []string{"lowercase"}, + }, + expected: `SecRule ARGS_GET:foo "@rx [^a-zA-Z]" "id:1136235475,phase:2,deny,log,msg:'Base Rule',t:lowercase"`, + }, + { + name: "Multiple Zones", + rule: CustomRule{ + Zones: []string{"ARGS", "BODY_ARGS"}, + Variables: []string{"foo"}, + Match: match{Type: "regex", Value: "[^a-zA-Z]"}, + Transform: []string{"lowercase"}, + }, + expected: `SecRule ARGS_GET:foo|ARGS_POST:foo "@rx [^a-zA-Z]" "id:2088895799,phase:2,deny,log,msg:'Multiple Zones',t:lowercase"`, + }, + { + name: "Basic AND", + rule: CustomRule{ + And: []CustomRule{ + { + + Zones: []string{"ARGS"}, + Variables: []string{"foo"}, + Match: match{Type: "regex", Value: "[^a-zA-Z]"}, + Transform: []string{"lowercase"}, + }, + { + Zones: []string{"ARGS"}, + Variables: []string{"bar"}, + Match: match{Type: "regex", Value: "[^a-zA-Z]"}, + Transform: []string{"lowercase"}, + }, + }, + }, + expected: `SecRule ARGS_GET:foo "@rx [^a-zA-Z]" "id:2323451654,phase:2,deny,log,msg:'Basic AND_and_0',t:lowercase,chain" +SecRule ARGS_GET:bar "@rx [^a-zA-Z]" "id:2075918819,phase:2,deny,log,msg:'Basic AND_and_1',t:lowercase"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual, err := tt.rule.Convert(ModsecurityRuleType, tt.name) + + if err != nil { + t.Errorf("Error converting rule: %s", err) + } + if actual != tt.expected { + t.Errorf("Expected:\n%s\nGot:\n%s", tt.expected, actual) + } + }) + } +} diff --git a/pkg/waf/waap_rule/modsecurity.go b/pkg/waf/waap_rule/modsecurity.go new file mode 100644 index 000000000..438cea2ef --- /dev/null +++ b/pkg/waf/waap_rule/modsecurity.go @@ -0,0 +1,152 @@ +package waap_rule + +import ( + "fmt" + "hash/fnv" + "strings" +) + +type ModsecurityRule struct { +} + +var zonesMap map[string]string = map[string]string{ + "ARGS": "ARGS_GET", + "ARGS_NAMES": "ARGS_GET_NAMES", + "BODY_ARGS": "ARGS_POST", + "BODY_ARGS_NAMES": "ARGS_POST_NAMES", + "HEADERS": "REQUEST_HEADERS", + "METHOD": "REQUEST_METHOD", + "PROTOCOL": "REQUEST_PROTOCOL", + "URI": "REQUEST_URI", +} + +var transformMap map[string]string = map[string]string{ + "lowercase": "t:lowercase", + "uppercase": "t:uppercase", + "b64decode": "t:base64Decode", + "hexdecode": "t:hexDecode", + "length": "t:length", +} + +var matchMap map[string]string = map[string]string{ + "regex": "@rx", + "equal": "@streq", + "startsWith": "@beginsWith", + "endsWith": "@endsWith", + "contains": "@contains", + "libinjectionSQL": "@detectSQLi", + "libinjectionXSS": "@detectXSS", + "gt": "@gt", + "lt": "@lt", + "ge": "@ge", + "le": "@le", +} + +func (m ModsecurityRule) Build(rule *CustomRule, waapRuleName string) (string, error) { + + rules, err := m.buildRules(rule, waapRuleName, false) + + if err != nil { + return "", err + } + + return strings.Join(rules, "\n"), nil +} + +func (m ModsecurityRule) generateRuleID(rule *CustomRule, waapRuleName string) uint32 { + h := fnv.New32a() + h.Write([]byte(waapRuleName)) + h.Write([]byte(rule.Match.Type)) + h.Write([]byte(rule.Match.Value)) + for _, zone := range rule.Zones { + h.Write([]byte(zone)) + } + for _, transform := range rule.Transform { + h.Write([]byte(transform)) + } + return h.Sum32() +} + +func (m ModsecurityRule) buildRules(rule *CustomRule, waapRuleName string, and bool) ([]string, error) { + ret := make([]string, 0) + + if rule.And != nil { + for c, andRule := range rule.And { + subName := fmt.Sprintf("%s_and_%d", waapRuleName, c) + lastRule := c == len(rule.And)-1 + rules, err := m.buildRules(&andRule, subName, !lastRule) + if err != nil { + return nil, err + } + ret = append(ret, rules...) + } + } + + if rule.Or != nil { + for c, orRule := range rule.Or { + subName := fmt.Sprintf("%s_or_%d", waapRuleName, c) + rules, err := m.buildRules(&orRule, subName, false) + if err != nil { + return nil, err + } + ret = append(ret, rules...) + } + } + + r := strings.Builder{} + + r.WriteString("SecRule ") + + if rule.Zones == nil { + return ret, nil + } + + for idx, zone := range rule.Zones { + mappedZone, ok := zonesMap[zone] + if !ok { + return nil, fmt.Errorf("unknown zone '%s'", zone) + } + if len(rule.Variables) == 0 { + r.WriteString(mappedZone) + } else { + for j, variable := range rule.Variables { + if idx > 0 || j > 0 { + r.WriteByte('|') + } + r.WriteString(fmt.Sprintf("%s:%s", mappedZone, variable)) + } + } + } + r.WriteByte(' ') + + if rule.Match.Type != "" { + if match, ok := matchMap[rule.Match.Type]; ok { + r.WriteString(fmt.Sprintf(`"%s %s"`, match, rule.Match.Value)) + } else { + return nil, fmt.Errorf("unknown match type '%s'", rule.Match.Type) + } + } + + //Should phase:2 be configurable? + r.WriteString(fmt.Sprintf(` "id:%d,phase:2,deny,log,msg:'%s'`, m.generateRuleID(rule, waapRuleName), waapRuleName)) + + if rule.Transform != nil { + for _, transform := range rule.Transform { + r.WriteByte(',') + if mappedTransform, ok := transformMap[transform]; ok { + r.WriteString(mappedTransform) + } else { + return nil, fmt.Errorf("unknown transform '%s'", transform) + } + } + } + + if and { + r.WriteString(",chain") + } + + r.WriteByte('"') + + ret = append(ret, r.String()) + return ret, nil +} diff --git a/pkg/waf/waap_rule/types.go b/pkg/waf/waap_rule/types.go new file mode 100644 index 000000000..80ac560a0 --- /dev/null +++ b/pkg/waf/waap_rule/types.go @@ -0,0 +1,5 @@ +package waap_rule + +const ( + ModsecurityRuleType = "modsecurity" +) diff --git a/pkg/waf/waap_rule/waap_rule.go b/pkg/waf/waap_rule/waap_rule.go new file mode 100644 index 000000000..00d3210d1 --- /dev/null +++ b/pkg/waf/waap_rule/waap_rule.go @@ -0,0 +1,65 @@ +package waap_rule + +import ( + "fmt" +) + +/* +rules: + - name: "test" + and: + - zones: + - BODY_ARGS + variables: + - foo + - bar + transform: + - lowercase|uppercase|b64decode|... + match: + type: regex + value: "[^a-zA-Z]" + - zones: + - ARGS + variables: + - bla + +*/ + +type match struct { + Type string `yaml:"type"` + Value string `yaml:"value"` +} + +type CustomRule struct { + Name string `yaml:"name"` + + Zones []string `yaml:"zones"` + Variables []string `yaml:"variables"` + + Match match `yaml:"match"` + Transform []string `yaml:"transform"` //t:lowercase, t:uppercase, etc + And []CustomRule `yaml:"and,omitempty"` + Or []CustomRule `yaml:"or,omitempty"` +} + +func (v *CustomRule) Convert(ruleType string, waapRuleName string) (string, error) { + + if v.Zones == nil && v.And == nil && v.Or == nil { + return "", fmt.Errorf("no zones defined") + } + + if v.Match.Type == "" && v.And == nil && v.Or == nil { + return "", fmt.Errorf("no match type defined") + } + + if v.Match.Value == "" && v.And == nil && v.Or == nil { + return "", fmt.Errorf("no match value defined") + } + + switch ruleType { + case ModsecurityRuleType: + return ModsecurityRule{}.Build(v, waapRuleName) + default: + return "", fmt.Errorf("unknown rule format '%s'", ruleType) + } +} diff --git a/pkg/waf/waap_rule_test.go b/pkg/waf/waap_rule_test.go deleted file mode 100644 index fcb45a2d3..000000000 --- a/pkg/waf/waap_rule_test.go +++ /dev/null @@ -1,126 +0,0 @@ -package waf - -import "testing" - -func TestVPatchRuleString(t *testing.T) { - tests := []struct { - name string - rule VPatchRule - expected string - }{ - { - name: "Base Rule", - rule: VPatchRule{ - Target: "ARGS", - Variable: "foo", - Match: "[^a-zA-Z]", - Transform: "lowercase", - }, - expected: `SecRule ARGS:foo "@rx [^a-zA-Z]" "id:0,deny,log,t:lowercase"`, - }, - { - name: "AND Logic Rule", - rule: VPatchRule{ - Target: "ARGS", - Variable: "bar", - Match: "[0-9]", - Logic: "AND", - SubRules: []VPatchRule{ - { - Target: "REQUEST_URI", - Match: "/joomla/index.php/component/users/", - }, - }, - }, - expected: `SecRule ARGS:bar "@rx [0-9]" "id:0,deny,log,chain" -SecRule REQUEST_URI "@rx /joomla/index.php/component/users/" "id:0,deny,log"`, - }, - { - name: "AND Logic Rule", - rule: VPatchRule{ - Logic: "AND", - SubRules: []VPatchRule{ - { - Target: "REQUEST_URI", - Match: "/joomla/index.php/component/users/", - }, - { - Target: "ARGS", - Variable: "bar", - Match: "[0-9]", - }, - }, - }, - expected: `SecRule ARGS:bar "@rx [0-9]" "id:0,deny,log,chain" -SecRule REQUEST_URI "@rx /joomla/index.php/component/users/" "id:0,deny,log"`, - }, - { - name: "OR Logic Rule", - rule: VPatchRule{ - Target: "REQUEST_HEADERS", - Variable: "User-Agent", - Match: "BadBot", - Logic: "OR", - SubRules: []VPatchRule{ - { - Target: "REQUEST_HEADERS", - Variable: "Referer", - Match: "EvilReferer", - }, - { - Target: "REQUEST_METHOD", - Equals: "POST", - }, - }, - }, - expected: `SecRule REQUEST_HEADERS:User-Agent "@rx BadBot" "id:0,deny,log,skip:2" -SecRule REQUEST_HEADERS:Referer "@rx EvilReferer" "id:0,deny,log,skip:1" -SecRule REQUEST_METHOD "@eq POST" "id:0,deny,log"`, - }, - { - name: "AND-OR Logic Mix", - rule: VPatchRule{ - Target: "REQUEST_URI", - Match: "/api/", - Logic: "AND", - SubRules: []VPatchRule{ - { - Target: "ARGS", - Variable: "username", - Match: "admin", - Logic: "OR", - SubRules: []VPatchRule{ - { - Target: "REQUEST_METHOD", - Equals: "POST", - Logic: "AND", - SubRules: []VPatchRule{ - { - Target: "ARGS", - Variable: "action", - Match: "delete", - }, - }, - }, - }, - }, - }, - }, - expected: `SecRule REQUEST_URI "@rx /api/" "id:0,deny,log,chain" -SecRule ARGS:username "@rx admin" "id:0,deny,log,skip:2" -SecRule REQUEST_METHOD "@eq POST" "id:0,deny,log,chain" -SecRule ARGS:action "@rx delete" "id:0,deny,log"`, - }, - // Additional OR test case would be here, but note that the OR logic representation with `skip` is very simplistic. - // It may not be robust enough for complex OR rules in a real-world ModSecurity setup. - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - actual := tt.rule.String() - if actual != tt.expected { - t.Errorf("Expected:\n%s\nGot:\n%s", tt.expected, actual) - } - }) - } -} diff --git a/pkg/waf/waap_rules_collection.go b/pkg/waf/waap_rules_collection.go index 6d05245db..02cd12e87 100644 --- a/pkg/waf/waap_rules_collection.go +++ b/pkg/waf/waap_rules_collection.go @@ -8,6 +8,7 @@ import ( corazatypes "github.com/crowdsecurity/coraza/v3/types" "github.com/crowdsecurity/crowdsec/pkg/cwhub" + "github.com/crowdsecurity/crowdsec/pkg/waf/waap_rule" "gopkg.in/yaml.v2" log "github.com/sirupsen/logrus" @@ -23,13 +24,13 @@ var WAAP_RULE = "waap-rule" // to be filled w/ seb update type WaapCollectionConfig struct { - Type string `yaml:"type"` - Name string `yaml:"name"` - Description string `yaml:"description"` - SecLangFilesRules []string `yaml:"seclang_files_rules"` - SecLangRules []string `yaml:"seclang_rules"` - Rules []VPatchRule `yaml:"rules"` - Data interface{} `yaml:"data"` //Ignore it + Type string `yaml:"type"` + Name string `yaml:"name"` + Description string `yaml:"description"` + SecLangFilesRules []string `yaml:"seclang_files_rules"` + SecLangRules []string `yaml:"seclang_rules"` + Rules []waap_rule.CustomRule `yaml:"rules"` + Data interface{} `yaml:"data"` //Ignore it } func LoadCollection(collection string) (WaapCollection, error) { @@ -115,8 +116,13 @@ func LoadCollection(collection string) (WaapCollection, error) { if loadedRule.Rules != nil { for _, rule := range loadedRule.Rules { - log.Infof("Adding rule %s", rule.String()) - waapCol.Rules = append(waapCol.Rules, rule.String()) + strRule, err := rule.Convert(waap_rule.ModsecurityRuleType, loadedRule.Name) + if err != nil { + log.Errorf("unable to convert rule %s : %s", rule.Name, err) + return WaapCollection{}, err + } + log.Infof("Adding rule %s", strRule) + waapCol.Rules = append(waapCol.Rules, strRule) } }