diff --git a/pkg/waf/waap_rule.go b/pkg/waf/waap_rule.go new file mode 100644 index 000000000..5cc934a45 --- /dev/null +++ b/pkg/waf/waap_rule.go @@ -0,0 +1,104 @@ +package waf + +import ( + "fmt" + "strings" +) + +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"` + + id int +} + +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": + // Add "chain" to the current rule + result = strings.TrimSuffix(result, `"`) + `,chain"` + "\n" + for _, subRule := range v.SubRules { + result += subRule.constructRule(depth + 1) + } + case "OR": + skips := countTotalRules(v.SubRules) - 1 + // If the "OR" rule is at the top level and is followed by any rule, we need to count that too + if depth == 0 { + skips++ // For the current rule + } + // Add the skip directive to the current rule too + result = strings.TrimSuffix(result, `"`) + fmt.Sprintf(`,skip:%d"`+"\n", skips) + for _, subRule := range v.SubRules { + skips-- + if skips > 0 { + // Append skip directive and decrease the skip count + result += strings.TrimSuffix(subRule.singleRuleString(), `"`) + fmt.Sprintf(`,skip:%d"`+"\n", skips) + } else { + // If no skip is required, append only a newline + 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) + } + + actions := fmt.Sprintf(` "id:%d,deny,log`, v.id) + + // 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_test.go b/pkg/waf/waap_rule_test.go new file mode 100644 index 000000000..59cc7f897 --- /dev/null +++ b/pkg/waf/waap_rule_test.go @@ -0,0 +1,107 @@ +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: "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 f43564b31..0e3cc9edd 100644 --- a/pkg/waf/waap_rules_collection.go +++ b/pkg/waf/waap_rules_collection.go @@ -22,10 +22,11 @@ type WaapCollection struct { // to be filled w/ seb update type WaapCollectionConfig struct { - Type string `yaml:"type"` - Name string `yaml:"name"` - SecLangFilesRules []string `yaml:"seclang_files_rules"` - SecLangRules []string `yaml:"seclang_rules"` + Type string `yaml:"type"` + Name string `yaml:"name"` + SecLangFilesRules []string `yaml:"seclang_files_rules"` + SecLangRules []string `yaml:"seclang_rules"` + Rules []VPatchRule `yaml:"rules"` } func LoadCollection(collection string) (WaapCollection, error) { @@ -48,7 +49,7 @@ func LoadCollection(collection string) (WaapCollection, error) { var rule WaapCollectionConfig - err = yaml.Unmarshal(content, &rule) + err = yaml.UnmarshalStrict(content, &rule) if err != nil { log.Warnf("unable to unmarshal file %s : %s", hubWafRuleItem.LocalPath, err) @@ -74,6 +75,8 @@ func LoadCollection(collection string) (WaapCollection, error) { return WaapCollection{}, fmt.Errorf("no waap rules found for collection %s", collection) } + log.Infof("Found rule collection %s with %+v", loadedRule.Name, loadedRule) + waapCol := WaapCollection{ collectionName: loadedRule.Name, } @@ -102,6 +105,13 @@ func LoadCollection(collection string) (WaapCollection, error) { waapCol.Rules = append(waapCol.Rules, loadedRule.SecLangRules...) } + if loadedRule.Rules != nil { + for _, rule := range loadedRule.Rules { + log.Infof("Adding rule %s", rule.String()) + waapCol.Rules = append(waapCol.Rules, rule.String()) + } + } + return waapCol, nil } diff --git a/pkg/waf/waf_rule.go b/pkg/waf/waf_rule.go deleted file mode 100644 index 7a3ad40ad..000000000 --- a/pkg/waf/waf_rule.go +++ /dev/null @@ -1,25 +0,0 @@ -package waf - -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"` - - //Operations - 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 - - RulesOr []VPatchRule `yaml:"rules_or"` - RulesAnd []VPatchRule `yaml:"rules_and"` -} - -func (v *VPatchRule) String() string { - //ret := "SecRule " - - if v.Target != "" { - } - return "" -}