From d07219cd3ba9c8d4a94d89f5610f82dce9866bc4 Mon Sep 17 00:00:00 2001 From: Agustindeleon Date: Sat, 29 Nov 2025 12:12:00 +0100 Subject: [PATCH 01/17] feat: directory structure --- listener/configuration_directive.go | 6 +- listener/extended_seclang_parser_listener.go | 8 +- listener_test.go | 48 +++++------ main.go | 84 +++++++++++++++----- types/condition_directives.go | 30 +++---- types/configuration.go | 70 +++++++++++++--- 6 files changed, 170 insertions(+), 76 deletions(-) diff --git a/listener/configuration_directive.go b/listener/configuration_directive.go index d69687c..3937ace 100644 --- a/listener/configuration_directive.go +++ b/listener/configuration_directive.go @@ -1,8 +1,8 @@ package listener import ( - "github.com/coreruleset/seclang_parser/parser" "github.com/coreruleset/crslang/types" + "github.com/coreruleset/seclang_parser/parser" ) func (l *ExtendedSeclangParserListener) EnterEngine_config_directive_with_param(ctx *parser.Engine_config_directive_with_paramContext) { @@ -62,7 +62,7 @@ func (l *ExtendedSeclangParserListener) EnterSec_marker_directive(ctx *parser.Se } l.appendDirective = func() { l.DirectiveList.Marker = *l.configurationDirective - l.ConfigurationList.DirectiveList = append(l.ConfigurationList.DirectiveList, *l.DirectiveList) - l.DirectiveList = new(types.DirectiveList) + l.ConfigurationList.Groups = append(l.ConfigurationList.Groups, *l.DirectiveList) + l.DirectiveList = new(types.Group) } } diff --git a/listener/extended_seclang_parser_listener.go b/listener/extended_seclang_parser_listener.go index 2356a0f..62c5c38 100644 --- a/listener/extended_seclang_parser_listener.go +++ b/listener/extended_seclang_parser_listener.go @@ -43,8 +43,8 @@ type ExtendedSeclangParserListener struct { varExcluded bool varCount bool parameter string - DirectiveList *types.DirectiveList - ConfigurationList types.ConfigurationList + DirectiveList *types.Group + ConfigurationList types.Ruleset } func doNothingFunc() {} @@ -52,7 +52,7 @@ func doNothingFunc() {} func doNothingFuncString(value string) {} func (l *ExtendedSeclangParserListener) EnterConfiguration(ctx *parser.ConfigurationContext) { - l.DirectiveList = new(types.DirectiveList) + l.DirectiveList = new(types.Group) l.setParam = doNothingFuncString l.appendDirective = doNothingFunc l.appendComment = func(value string) { @@ -63,7 +63,7 @@ func (l *ExtendedSeclangParserListener) EnterConfiguration(ctx *parser.Configura func (l *ExtendedSeclangParserListener) ExitConfiguration(ctx *parser.ConfigurationContext) { if l.DirectiveList != nil && (len(l.DirectiveList.Directives) > 0 || l.DirectiveList.Marker.Name != "") { - l.ConfigurationList.DirectiveList = append(l.ConfigurationList.DirectiveList, *l.DirectiveList) + l.ConfigurationList.Groups = append(l.ConfigurationList.Groups, *l.DirectiveList) } } diff --git a/listener_test.go b/listener_test.go index 1d2bc66..e6f1e88 100644 --- a/listener_test.go +++ b/listener_test.go @@ -38,7 +38,7 @@ func mustNewSetvarAction(collection types.CollectionName, operation types.VarOpe type testCase struct { name string payload string - expected types.ConfigurationList + expected types.Ruleset } var ( @@ -59,8 +59,8 @@ var ( # https://owasp.org/www-project-modsecurity-core-rule-set/ # `, - expected: types.ConfigurationList{ - DirectiveList: []types.DirectiveList{ + expected: types.Ruleset{ + Groups: []types.Group{ { Directives: []types.SeclangDirective{ types.CommentMetadata{ @@ -88,8 +88,8 @@ https://owasp.org/www-project-modsecurity-core-rule-set/ payload: ` SecRuleEngine On `, - expected: types.ConfigurationList{ - DirectiveList: []types.DirectiveList{ + expected: types.Ruleset{ + Groups: []types.Group{ { Directives: []types.SeclangDirective{ types.ConfigurationDirective{ @@ -139,8 +139,8 @@ SecAction \ setvar:'tx.outbound_anomaly_score_pl3=0',\ setvar:'tx.outbound_anomaly_score_pl4=0',\ setvar:'tx.anomaly_score=0'"`, - expected: types.ConfigurationList{ - DirectiveList: []types.DirectiveList{ + expected: types.Ruleset{ + Groups: []types.Group{ { Directives: []types.SeclangDirective{ &types.SecAction{ @@ -239,8 +239,8 @@ SecRule REQUEST_LINE "@rx (?i)^(?:get /[^#\?]*(?:\?[^\s\v#]*)?(?:#[^\s\v]*)?|(?: ver:'OWASP_CRS/4.0.1-dev',\ severity:'WARNING',\ setvar:'tx.inbound_anomaly_score_pl1=+%{tx.warning_anomaly_score}'"`, - expected: types.ConfigurationList{ - DirectiveList: []types.DirectiveList{ + expected: types.Ruleset{ + Groups: []types.Group{ { Directives: []types.SeclangDirective{ &types.SecRule{ @@ -319,8 +319,8 @@ SecRule ARGS_GET:fbclid "@rx [a-zA-Z0-9_-]{61,61}" \ tag:'OWASP_CRS',\ ctl:ruleRemoveTargetById=942440;ARGS:fbclid,\ ver:'OWASP_CRS/4.0.1-dev'"`, - expected: types.ConfigurationList{ - DirectiveList: []types.DirectiveList{ + expected: types.Ruleset{ + Groups: []types.Group{ { Directives: []types.SeclangDirective{ &types.SecRule{ @@ -376,8 +376,8 @@ SecRule ARGS_GET:fbclid|!ARGS_GET|ARGS_GET:fbclid|!ARGS_GET:fbclid|ARGS_GET:test "id:942441,\ phase:2,\ pass"`, - expected: types.ConfigurationList{ - DirectiveList: []types.DirectiveList{ + expected: types.Ruleset{ + Groups: []types.Group{ { Directives: []types.SeclangDirective{ &types.SecRule{ @@ -415,8 +415,8 @@ SecRule ARGS:/^id_/|!ARGS:id_1 "@rx test" \ "id:942441,\ phase:2,\ pass"`, - expected: types.ConfigurationList{ - DirectiveList: []types.DirectiveList{ + expected: types.Ruleset{ + Groups: []types.Group{ { Directives: []types.SeclangDirective{ &types.SecRule{ @@ -472,8 +472,8 @@ SecRule REQUEST_LINE "@streq GET /" \ "t:none,\ ctl:ruleRemoveByTag=OWASP_CRS,\ ctl:auditEngine=Off"`, - expected: types.ConfigurationList{ - DirectiveList: []types.DirectiveList{ + expected: types.Ruleset{ + Groups: []types.Group{ { Directives: []types.SeclangDirective{ &types.SecRule{ @@ -550,8 +550,8 @@ SecRuleRemoveByTag "attack-sqli" SecRuleRemoveByMsg FAIL `, - expected: types.ConfigurationList{ - DirectiveList: []types.DirectiveList{ + expected: types.Ruleset{ + Groups: []types.Group{ { Directives: []types.SeclangDirective{ types.RemoveRuleDirective{ @@ -590,8 +590,8 @@ SecRule REQBODY_PROCESSOR "!@rx (?:URLENCODED|MULTIPART|XML|JSON)" \ msg:'Enabling body inspection',\ ctl:forceRequestBodyVariable=On,\ ver:'OWASP_CRS/4.0.0-rc1'"`, - expected: types.ConfigurationList{ - DirectiveList: []types.DirectiveList{ + expected: types.Ruleset{ + Groups: []types.Group{ { Directives: []types.SeclangDirective{ &types.SecRule{ @@ -637,8 +637,8 @@ SecCollectionTimeout 600 SecMarker "END-TEST" SecComponentSignature "OWASP_CRS/4.0.1-dev"`, - expected: types.ConfigurationList{ - DirectiveList: []types.DirectiveList{ + expected: types.Ruleset{ + Groups: []types.Group{ { Directives: []types.SeclangDirective{ types.ConfigurationDirective{ @@ -674,7 +674,7 @@ SecComponentSignature "OWASP_CRS/4.0.1-dev"`, func TestLoadSecLang(t *testing.T) { for _, test := range listenerTestCases { t.Run(test.name, func(t *testing.T) { - got := types.ConfigurationList{} + got := types.Ruleset{} input := antlr.NewInputStream(test.payload) lexer := parser.NewSecLangLexer(input) stream := antlr.NewCommonTokenStream(lexer, 0) diff --git a/main.go b/main.go index 4b4ab21..d6efbac 100644 --- a/main.go +++ b/main.go @@ -23,6 +23,8 @@ var ( func main() { toSeclang := flag.Bool("s", false, "Transalates the specified CRSLang file to Seclang files, instead of the default Seclang to CRSLang.") + // Experimental flag + dirMode := flag.Bool("d", false, "If set, the script output will be divided into multiple files when translating from Seclang to CRSLang.") output := flag.String("o", "", "Output file name used in translation from Seclang to CRSLang. Output folder used in translation from CRSLang to Seclang.") flag.Usage = func() { @@ -50,14 +52,56 @@ Flags: configList := LoadSeclang(pathArg) configList = *ToCRSLang(configList) + if !*dirMode { + if *output == "" { + *output = "crslang" + } - if *output == "" { - *output = "crslang" - } - - err := printYAML(configList, *output+".yaml") - if err != nil { - log.Fatal(err.Error()) + err := printYAML(configList, *output+".yaml") + if err != nil { + log.Fatal(err.Error()) + } + } else { + // EXPERIMENTAL: output each group and rule in separate files + for _, dirList := range configList.Groups { + groupFolder := *output + "/" + dirList.Id + "/" + ruleFolder := groupFolder + "/rules/" + err := os.MkdirAll(ruleFolder, os.ModePerm) + if err != nil { + log.Fatal(err.Error()) + } + listWithoutRules := []types.SeclangDirective{} + for _, directive := range dirList.Directives { + if directive.GetKind() == types.RuleKind { + rule, ok := directive.(*types.RuleWithCondition) + lastDigits := rule.Metadata.Id % 1000 + if lastDigits/100 != 0 { + if !ok { + log.Fatal("Error casting to RuleDirective") + } + fileName := ruleFolder + strconv.Itoa(rule.Metadata.Id) + ".yaml" + err := printYAML(directive, fileName) + if err != nil { + log.Fatal(err.Error()) + } + } else { + listWithoutRules = append(listWithoutRules, directive) + } + } else { + listWithoutRules = append(listWithoutRules, directive) + } + } + group := types.Group{ + Id: dirList.Id, + Tags: dirList.Tags, + Directives: listWithoutRules, + Marker: dirList.Marker, + } + err = printYAML(group, groupFolder+"group.yaml") + if err != nil { + log.Fatal(err.Error()) + } + } } } else { if filepath.Ext(pathArg) != ".yaml" { @@ -75,8 +119,8 @@ Flags: // LoadSeclang loads seclang directives from an input file or folder and returns a ConfigurationList // if a folder is specified it loads all .conf files in the folder -func LoadSeclang(input string) types.ConfigurationList { - resultConfigs := []types.DirectiveList{} +func LoadSeclang(input string) types.Ruleset { + resultConfigs := []types.Group{} filepath.Walk(input, func(path string, info os.FileInfo, err error) error { if err != nil { return err @@ -92,25 +136,25 @@ func LoadSeclang(input string) types.ConfigurationList { start := p.Configuration() var seclangListener listener.ExtendedSeclangParserListener antlr.ParseTreeWalkerDefault.Walk(&seclangListener, start) - for i := range seclangListener.ConfigurationList.DirectiveList { - seclangListener.ConfigurationList.DirectiveList[i].Id = strings.TrimSuffix(filepath.Base(info.Name()), filepath.Ext(info.Name())) - if len(seclangListener.ConfigurationList.DirectiveList) > 1 { - seclangListener.ConfigurationList.DirectiveList[i].Id += "_" + strconv.Itoa(i+1) + for i := range seclangListener.ConfigurationList.Groups { + seclangListener.ConfigurationList.Groups[i].Id = strings.TrimSuffix(filepath.Base(info.Name()), filepath.Ext(info.Name())) + if len(seclangListener.ConfigurationList.Groups) > 1 { + seclangListener.ConfigurationList.Groups[i].Id += "_" + strconv.Itoa(i+1) } } - resultConfigs = append(resultConfigs, seclangListener.ConfigurationList.DirectiveList...) + resultConfigs = append(resultConfigs, seclangListener.ConfigurationList.Groups...) } return nil }) - configList := types.ConfigurationList{DirectiveList: resultConfigs} + configList := types.Ruleset{Groups: resultConfigs} return configList } // PrintSeclang writes seclang directives to files specified in directive list ids. -func PrintSeclang(configList types.ConfigurationList, dir string) error { +func PrintSeclang(configList types.Ruleset, dir string) error { unfDirs := types.FromCRSLangToUnformattedDirectives(configList) - for _, dirList := range unfDirs.DirectiveList { + for _, dirList := range unfDirs.Groups { seclangDirectives := dirList.ToSeclang() err := writeToFile([]byte(seclangDirectives), dir+dirList.Id+".conf") if err != nil { @@ -122,10 +166,14 @@ func PrintSeclang(configList types.ConfigurationList, dir string) error { } // ToCRSLang process previously loaded seclang directives to CRSLang schema directives -func ToCRSLang(configList types.ConfigurationList) *types.ConfigurationList { +func ToCRSLang(configList types.Ruleset) *types.Ruleset { configListWithConditions := types.ToDirectiveWithConditions(configList) configListWithConditions.ExtractDefaultValues() + // EXPERIMENTAL: extract default values for each group + for i := range configListWithConditions.Groups { + configListWithConditions.Groups[i].ExtractDefaultValues() + } return configListWithConditions } diff --git a/types/condition_directives.go b/types/condition_directives.go index d5123c1..188e07d 100644 --- a/types/condition_directives.go +++ b/types/condition_directives.go @@ -37,10 +37,10 @@ func (s *RuleWithCondition) GetKind() Kind { return s.Kind } -func ToDirectiveWithConditions(configList ConfigurationList) *ConfigurationList { - result := new(ConfigurationList) - for _, config := range configList.DirectiveList { - configWrapper := new(DirectiveList) +func ToDirectiveWithConditions(configList Ruleset) *Ruleset { + result := new(Ruleset) + for _, config := range configList.Groups { + configWrapper := new(Group) configWrapper.Id = config.Id configWrapper.Marker = config.Marker for _, directive := range config.Directives { @@ -62,7 +62,7 @@ func ToDirectiveWithConditions(configList ConfigurationList) *ConfigurationList } configWrapper.Directives = append(configWrapper.Directives, directiveWrapper) } - result.DirectiveList = append(result.DirectiveList, *configWrapper) + result.Groups = append(result.Groups, *configWrapper) } return result } @@ -272,7 +272,7 @@ func (s *SeclangActions) UnmarshalYAML(value *yaml.Node) error { } // LoadDirectivesWithConditionsFromFile loads condition format directives from a yaml file -func LoadDirectivesWithConditionsFromFile(filename string) ConfigurationList { +func LoadDirectivesWithConditionsFromFile(filename string) Ruleset { yamlFile, err := os.ReadFile(filename) if err != nil { panic(err) @@ -282,14 +282,14 @@ func LoadDirectivesWithConditionsFromFile(filename string) ConfigurationList { } // LoadDirectivesWithConditions loads condition format directives from a yaml file -func LoadDirectivesWithConditions(yamlFile []byte) ConfigurationList { +func LoadDirectivesWithConditions(yamlFile []byte) Ruleset { var config configurationYamlLoader err := yaml.Unmarshal(yamlFile, &config) configs := config.DirectiveList if err != nil { panic(err) } - var resultConfigs []DirectiveList + var resultConfigs []Group for _, config := range configs { var directives []SeclangDirective for _, yamlDirective := range config.Directives { @@ -300,9 +300,9 @@ func LoadDirectivesWithConditions(yamlFile []byte) ConfigurationList { directives = append(directives, directive) } } - resultConfigs = append(resultConfigs, DirectiveList{Id: config.Id, Directives: directives, Marker: config.Marker}) + resultConfigs = append(resultConfigs, Group{Id: config.Id, Directives: directives, Marker: config.Marker}) } - return ConfigurationList{Global: config.Global, DirectiveList: resultConfigs} + return Ruleset{Global: config.Global, Groups: resultConfigs} } // loadConditionDirective loads the different kind of directives @@ -436,10 +436,10 @@ func castConditions(condition *yaml.Node) (Condition, error) { return ruleCondition, nil } -func FromCRSLangToUnformattedDirectives(configListWrapped ConfigurationList) *ConfigurationList { - result := new(ConfigurationList) - for _, config := range configListWrapped.DirectiveList { - configList := new(DirectiveList) +func FromCRSLangToUnformattedDirectives(configListWrapped Ruleset) *Ruleset { + result := new(Ruleset) + for _, config := range configListWrapped.Groups { + configList := new(Group) configList.Id = config.Id configList.Marker = config.Marker for _, directiveWrapped := range config.Directives { @@ -467,7 +467,7 @@ func FromCRSLangToUnformattedDirectives(configListWrapped ConfigurationList) *Co } configList.Directives = append(configList.Directives, directive) } - result.DirectiveList = append(result.DirectiveList, *configList) + result.Groups = append(result.Groups, *configList) } return result } diff --git a/types/configuration.go b/types/configuration.go index bf54fb9..de51e82 100644 --- a/types/configuration.go +++ b/types/configuration.go @@ -9,18 +9,19 @@ type DefaultConfigs struct { Tags []string `yaml:"tags,omitempty"` } -type ConfigurationList struct { - Global DefaultConfigs `yaml:"global,omitempty"` - DirectiveList []DirectiveList `yaml:"directivelist,omitempty"` +type Ruleset struct { + Global DefaultConfigs `yaml:"global,omitempty"` + Groups []Group `yaml:"groups,omitempty"` } -type DirectiveList struct { +type Group struct { Id string `yaml:"id"` + Tags []string `yaml:"tags,omitempty"` Directives []SeclangDirective `yaml:"directives,omitempty"` Marker ConfigurationDirective `yaml:"marker,omitempty"` } -func (d DirectiveList) ToSeclang() string { +func (d Group) ToSeclang() string { result := "" for _, directive := range d.Directives { result += directive.ToSeclang() + "\n" @@ -31,9 +32,9 @@ func (d DirectiveList) ToSeclang() string { return result } -func ToSeclang(configList ConfigurationList) string { +func ToSeclang(configList Ruleset) string { result := "" - for _, config := range configList.DirectiveList { + for _, config := range configList.Groups { for _, directive := range config.Directives { result += directive.ToSeclang() + "\n" } @@ -45,17 +46,17 @@ func ToSeclang(configList ConfigurationList) string { } // ExtractDefaultValues extracts default values for version and tags from the rules in the configuration list -func (c *ConfigurationList) ExtractDefaultValues() { +func (c *Ruleset) ExtractDefaultValues() { directiveFound := false version := "" tags := []string{} rules := []*RuleWithCondition{} - for i := range c.DirectiveList { - for j := range c.DirectiveList[i].Directives { + for i := range c.Groups { + for j := range c.Groups[i].Directives { // Only consider Rule directives - if c.DirectiveList[i].Directives[j].GetKind() == RuleKind { - rule := c.DirectiveList[i].Directives[j].(*RuleWithCondition) + if c.Groups[i].Directives[j].GetKind() == RuleKind { + rule := c.Groups[i].Directives[j].(*RuleWithCondition) rules = append(rules, rule) if !directiveFound { directiveFound = true @@ -93,3 +94,48 @@ func (c *ConfigurationList) ExtractDefaultValues() { c.Global.Version = version c.Global.Tags = tags } + +// TODO: merge this method with the one in Ruleset +// ExtractDefaultValues extracts default values for version and tags from the rules in the configuration list +func (g *Group) ExtractDefaultValues() { + directiveFound := false + tags := []string{} + rules := []*RuleWithCondition{} + + for j := range g.Directives { + // Only consider Rule directives + if g.Directives[j].GetKind() == RuleKind { + lastDigits := g.Directives[j].(*RuleWithCondition).Metadata.Id % 1000 + if lastDigits/100 != 0 { + rule := g.Directives[j].(*RuleWithCondition) + rules = append(rules, rule) + if !directiveFound { + directiveFound = true + tags = rule.Metadata.Tags + } else { + auxTags := []string{} + for _, tag := range tags { + if slices.Contains(rule.Metadata.Tags, tag) { + auxTags = append(auxTags, tag) + } + } + tags = auxTags + } + // If tags are empty after found a rule it means there is no common value + // so we can stop the search + if len(tags) == 0 { + return + } + } + } + } + + // Clear version and tags in rules since they are now in the global section + for _, rule := range rules { + rule.Metadata.Tags = slices.DeleteFunc(rule.Metadata.Tags, func(s string) bool { + return slices.Contains(tags, s) + }) + } + + g.Tags = tags +} From 018d54c7eaa20294494d32ec2d8d588c0a72b04b Mon Sep 17 00:00:00 2001 From: Agustindeleon Date: Mon, 9 Feb 2026 08:13:47 -0300 Subject: [PATCH 02/17] chore: move dir structure logic to a function --- main.go | 100 +++++++++++++++++++++++++---------------- types/configuration.go | 16 ++++--- 2 files changed, 71 insertions(+), 45 deletions(-) diff --git a/main.go b/main.go index d6efbac..21ccc2a 100644 --- a/main.go +++ b/main.go @@ -62,45 +62,9 @@ Flags: log.Fatal(err.Error()) } } else { - // EXPERIMENTAL: output each group and rule in separate files - for _, dirList := range configList.Groups { - groupFolder := *output + "/" + dirList.Id + "/" - ruleFolder := groupFolder + "/rules/" - err := os.MkdirAll(ruleFolder, os.ModePerm) - if err != nil { - log.Fatal(err.Error()) - } - listWithoutRules := []types.SeclangDirective{} - for _, directive := range dirList.Directives { - if directive.GetKind() == types.RuleKind { - rule, ok := directive.(*types.RuleWithCondition) - lastDigits := rule.Metadata.Id % 1000 - if lastDigits/100 != 0 { - if !ok { - log.Fatal("Error casting to RuleDirective") - } - fileName := ruleFolder + strconv.Itoa(rule.Metadata.Id) + ".yaml" - err := printYAML(directive, fileName) - if err != nil { - log.Fatal(err.Error()) - } - } else { - listWithoutRules = append(listWithoutRules, directive) - } - } else { - listWithoutRules = append(listWithoutRules, directive) - } - } - group := types.Group{ - Id: dirList.Id, - Tags: dirList.Tags, - Directives: listWithoutRules, - Marker: dirList.Marker, - } - err = printYAML(group, groupFolder+"group.yaml") - if err != nil { - log.Fatal(err.Error()) - } + err := writeRuleSeparately(configList, *output) + if err != nil { + log.Fatal(err.Error()) } } } else { @@ -177,6 +141,64 @@ func ToCRSLang(configList types.Ruleset) *types.Ruleset { return configListWithConditions } +func writeRuleSeparately(rulset types.Ruleset, output string) error { + // EXPERIMENTAL: output each group and rule in separate files + for _, group := range rulset.Groups { + groupFolder := output + "/" + group.Id + "/" + ruleFolder := groupFolder + "/rules/" + err := os.MkdirAll(ruleFolder, os.ModePerm) + if err != nil { + return err + } + ruleIds := []string{} + comments := []string{} + configs := []types.ConfigurationDirective{} + for _, directive := range group.Directives { + if directive.GetKind() == types.RuleKind { + rule, ok := directive.(*types.RuleWithCondition) + if !ok { + return fmt.Errorf("Error casting to RuleWithCondition") + } + // Ignore paranoia level check rules + lastDigits := rule.Metadata.Id % 1000 + if lastDigits/100 != 0 { + fileName := ruleFolder + strconv.Itoa(rule.Metadata.Id) + ".yaml" + err := printYAML(directive, fileName) + if err != nil { + return err + } + ruleIds = append(ruleIds, strconv.Itoa(rule.Metadata.Id)) + } + } else if directive.GetKind() == types.CommentKind { + comment, ok := directive.(types.CommentDirective) + if !ok { + return fmt.Errorf("Error casting to Comment %T", directive) + } + comments = append(comments, comment.Metadata.Comment) + } else if directive.GetKind() == types.ConfigurationKind { + config, ok := directive.(types.ConfigurationDirective) + if !ok { + return fmt.Errorf("Error casting to Configuration %T", directive) + } + configs = append(configs, config) + } + } + newGroup := types.Group{ + Id: group.Id, + Tags: group.Tags, + Comments: comments, + Rules: ruleIds, + Configurations: configs, + Marker: group.Marker, + } + err = printYAML(newGroup, groupFolder+"group.yaml") + if err != nil { + return err + } + } + return nil +} + // printYAML marshal and write structures to a yaml file func printYAML(input any, filename string) error { yamlFile, err := yaml.Marshal(input) diff --git a/types/configuration.go b/types/configuration.go index de51e82..49e023b 100644 --- a/types/configuration.go +++ b/types/configuration.go @@ -15,10 +15,13 @@ type Ruleset struct { } type Group struct { - Id string `yaml:"id"` - Tags []string `yaml:"tags,omitempty"` - Directives []SeclangDirective `yaml:"directives,omitempty"` - Marker ConfigurationDirective `yaml:"marker,omitempty"` + Id string `yaml:"id"` + Tags []string `yaml:"tags,omitempty"` + Comments []string `yaml:"comments,omitempty"` + Configurations []ConfigurationDirective `yaml:"configurations,omitempty"` + Directives []SeclangDirective `yaml:"directives,omitempty"` + Rules []string `yaml:"rules,omitempty"` + Marker ConfigurationDirective `yaml:"marker,omitempty"` } func (d Group) ToSeclang() string { @@ -130,12 +133,13 @@ func (g *Group) ExtractDefaultValues() { } } - // Clear version and tags in rules since they are now in the global section + // Clear tags in rules since they are now in the global section for _, rule := range rules { rule.Metadata.Tags = slices.DeleteFunc(rule.Metadata.Tags, func(s string) bool { return slices.Contains(tags, s) }) } - g.Tags = tags + g.Tags = append([]string{}, tags...) + } From 80a9c72af61c5d15ed86e44d9beb39ca55a4ee7d Mon Sep 17 00:00:00 2001 From: Agustindeleon Date: Fri, 13 Feb 2026 08:15:14 -0300 Subject: [PATCH 03/17] fix: tag extraction when there is only one rule --- types/configuration.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/types/configuration.go b/types/configuration.go index 49e023b..17d8590 100644 --- a/types/configuration.go +++ b/types/configuration.go @@ -112,18 +112,18 @@ func (g *Group) ExtractDefaultValues() { if lastDigits/100 != 0 { rule := g.Directives[j].(*RuleWithCondition) rules = append(rules, rule) + auxTags := []string{} if !directiveFound { directiveFound = true - tags = rule.Metadata.Tags + auxTags = append(auxTags, rule.Metadata.Tags...) } else { - auxTags := []string{} for _, tag := range tags { if slices.Contains(rule.Metadata.Tags, tag) { auxTags = append(auxTags, tag) } } - tags = auxTags } + tags = auxTags // If tags are empty after found a rule it means there is no common value // so we can stop the search if len(tags) == 0 { From 6289766dc0466f7789c7fbadc437f64a213e6554 Mon Sep 17 00:00:00 2001 From: Agustindeleon Date: Fri, 13 Feb 2026 09:31:41 -0300 Subject: [PATCH 04/17] feat: write ruleset to yaml --- main.go | 12 ++++++++++++ types/configuration.go | 5 +++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index 21ccc2a..9c7e0e6 100644 --- a/main.go +++ b/main.go @@ -142,8 +142,11 @@ func ToCRSLang(configList types.Ruleset) *types.Ruleset { } func writeRuleSeparately(rulset types.Ruleset, output string) error { + groups := []string{} + // EXPERIMENTAL: output each group and rule in separate files for _, group := range rulset.Groups { + groups = append(groups, group.Id) groupFolder := output + "/" + group.Id + "/" ruleFolder := groupFolder + "/rules/" err := os.MkdirAll(ruleFolder, os.ModePerm) @@ -196,6 +199,15 @@ func writeRuleSeparately(rulset types.Ruleset, output string) error { return err } } + + newRuleset := types.Ruleset{ + Global: rulset.Global, + GroupsIds: groups, + } + err := printYAML(newRuleset, output+"/ruleset.yaml") + if err != nil { + return err + } return nil } diff --git a/types/configuration.go b/types/configuration.go index 17d8590..c2863ac 100644 --- a/types/configuration.go +++ b/types/configuration.go @@ -10,8 +10,9 @@ type DefaultConfigs struct { } type Ruleset struct { - Global DefaultConfigs `yaml:"global,omitempty"` - Groups []Group `yaml:"groups,omitempty"` + Global DefaultConfigs `yaml:"global,omitempty"` + GroupsIds []string `yaml:"groups_ids,omitempty"` + Groups []Group `yaml:"groups,omitempty"` } type Group struct { From 0d81fec66d0289446b0ea17b6af5a1d2c9cc864d Mon Sep 17 00:00:00 2001 From: Agustindeleon Date: Fri, 13 Feb 2026 10:38:07 -0300 Subject: [PATCH 05/17] feat: load rule from directory --- main.go | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 69 insertions(+), 7 deletions(-) diff --git a/main.go b/main.go index 9c7e0e6..a58b741 100644 --- a/main.go +++ b/main.go @@ -68,15 +68,26 @@ Flags: } } } else { - if filepath.Ext(pathArg) != ".yaml" { - log.Fatal("Only .yaml files are allowed") - } + if !*dirMode { + if filepath.Ext(pathArg) != ".yaml" { + log.Fatal("Only .yaml files are allowed") + } - configList := types.LoadDirectivesWithConditionsFromFile(pathArg) + configList := types.LoadDirectivesWithConditionsFromFile(pathArg) - err := PrintSeclang(configList, *output) - if err != nil { - log.Fatal(err.Error()) + err := PrintSeclang(configList, *output) + if err != nil { + log.Fatal(err.Error()) + } + } else { + /* Expiremental load rule from dir */ + _, err := loadRulesFromDirectory(pathArg) + if err != nil { + log.Fatal(err.Error()) + } + if err != nil { + log.Fatal(err.Error()) + } } } } @@ -211,6 +222,57 @@ func writeRuleSeparately(rulset types.Ruleset, output string) error { return nil } +func loadRulesFromDirectory(dir string) (types.Ruleset, error) { + info, err := os.Stat(dir) + + if err != nil { + return types.Ruleset{}, err + } else if !info.IsDir() { + return types.Ruleset{}, fmt.Errorf("path is not a directory: %s", dir) + } + + rFile, err := os.ReadFile(dir + "/ruleset.yaml") + + if err != nil { + return types.Ruleset{}, err + } + + ruleset := types.Ruleset{} + err = yaml.Unmarshal([]byte(rFile), &ruleset) + + if err != nil { + return types.Ruleset{}, err + } + + for _, groupId := range ruleset.GroupsIds { + groupFile, err := os.ReadFile(dir + "/" + groupId + "/group.yaml") + if err != nil { + return types.Ruleset{}, err + } + group := types.Group{} + err = yaml.Unmarshal([]byte(groupFile), &group) + if err != nil { + return types.Ruleset{}, err + } + for _, ruleId := range group.Rules { + ruleFile, err := os.ReadFile(dir + "/" + groupId + "/rules/" + ruleId + ".yaml") + if err != nil { + return types.Ruleset{}, err + } + rule := types.RuleWithCondition{} + err = yaml.Unmarshal([]byte(ruleFile), &rule) + if err != nil { + return types.Ruleset{}, err + } + group.Directives = append(group.Directives, &rule) + } + group.Rules = nil + ruleset.Groups = append(ruleset.Groups, group) + } + ruleset.GroupsIds = nil + return ruleset, nil +} + // printYAML marshal and write structures to a yaml file func printYAML(input any, filename string) error { yamlFile, err := yaml.Marshal(input) From ce8c5d0bada4b3a45dc01df293290d74270262e4 Mon Sep 17 00:00:00 2001 From: Agustindeleon Date: Sun, 22 Feb 2026 11:44:53 -0300 Subject: [PATCH 06/17] chore: improve filepath handling --- translator/crslang.go | 27 ++++++++++++++++++--------- types/configuration.go | 2 +- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/translator/crslang.go b/translator/crslang.go index f0e9113..55080b6 100644 --- a/translator/crslang.go +++ b/translator/crslang.go @@ -3,6 +3,7 @@ package translator import ( "fmt" "os" + "path/filepath" "strconv" "github.com/coreruleset/crslang/types" @@ -18,17 +19,24 @@ func ToCRSLang(configList types.Ruleset) *types.Ruleset { } func WriteRuleSeparately(rulset types.Ruleset, output string) error { + output = filepath.Clean(output) + if err := os.MkdirAll(output, 0755); err != nil { + return err + } + groups := []string{} // EXPERIMENTAL: output each group and rule in separate files for _, group := range rulset.Groups { groups = append(groups, group.Id) - groupFolder := output + "/" + group.Id + "/" - ruleFolder := groupFolder + "/rules/" - err := os.MkdirAll(ruleFolder, os.ModePerm) + + groupFolder := filepath.Join(output, group.Id) + ruleFolder := filepath.Join(groupFolder, "rules") + err := os.MkdirAll(ruleFolder, 0755) if err != nil { return err } + ruleIds := []string{} comments := []string{} configs := []types.ConfigurationDirective{} @@ -41,7 +49,7 @@ func WriteRuleSeparately(rulset types.Ruleset, output string) error { // Ignore paranoia level check rules lastDigits := rule.Metadata.Id % 1000 if lastDigits/100 != 0 { - fileName := ruleFolder + strconv.Itoa(rule.Metadata.Id) + ".yaml" + fileName := filepath.Join(ruleFolder, strconv.Itoa(rule.Metadata.Id)+".yaml") err := PrintYAML(directive, fileName) if err != nil { return err @@ -70,7 +78,7 @@ func WriteRuleSeparately(rulset types.Ruleset, output string) error { Configurations: configs, Marker: group.Marker, } - err = PrintYAML(newGroup, groupFolder+"group.yaml") + err = PrintYAML(newGroup, filepath.Join(groupFolder, "group.yaml")) if err != nil { return err } @@ -80,7 +88,7 @@ func WriteRuleSeparately(rulset types.Ruleset, output string) error { Global: rulset.Global, GroupsIds: groups, } - err := PrintYAML(newRuleset, output+"/ruleset.yaml") + err := PrintYAML(newRuleset, filepath.Join(output, "ruleset.yaml")) if err != nil { return err } @@ -95,8 +103,9 @@ func LoadRulesFromDirectory(dir string) (types.Ruleset, error) { } else if !info.IsDir() { return types.Ruleset{}, fmt.Errorf("path is not a directory: %s", dir) } + dir = filepath.Clean(dir) - rFile, err := os.ReadFile(dir + "/ruleset.yaml") + rFile, err := os.ReadFile(filepath.Join(dir, "ruleset.yaml")) if err != nil { return types.Ruleset{}, err @@ -110,7 +119,7 @@ func LoadRulesFromDirectory(dir string) (types.Ruleset, error) { } for _, groupId := range ruleset.GroupsIds { - groupFile, err := os.ReadFile(dir + "/" + groupId + "/group.yaml") + groupFile, err := os.ReadFile(filepath.Join(dir, groupId, "group.yaml")) if err != nil { return types.Ruleset{}, err } @@ -120,7 +129,7 @@ func LoadRulesFromDirectory(dir string) (types.Ruleset, error) { return types.Ruleset{}, err } for _, ruleId := range group.Rules { - ruleFile, err := os.ReadFile(dir + "/" + groupId + "/rules/" + ruleId + ".yaml") + ruleFile, err := os.ReadFile(filepath.Join(dir, groupId, "rules", ruleId+".yaml")) if err != nil { return types.Ruleset{}, err } diff --git a/types/configuration.go b/types/configuration.go index c2863ac..271aa80 100644 --- a/types/configuration.go +++ b/types/configuration.go @@ -11,7 +11,7 @@ type DefaultConfigs struct { type Ruleset struct { Global DefaultConfigs `yaml:"global,omitempty"` - GroupsIds []string `yaml:"groups_ids,omitempty"` + GroupsIds []string `yaml:"group_ids,omitempty"` Groups []Group `yaml:"groups,omitempty"` } From 87fe946d908c40240626c082bfa58e5eee698923 Mon Sep 17 00:00:00 2001 From: Agustindeleon Date: Sun, 22 Feb 2026 11:46:34 -0300 Subject: [PATCH 07/17] chore: update var names --- listener/configuration_directive.go | 2 +- listener/extended_seclang_parser_listener.go | 4 ++-- listener_test.go | 2 +- types/configuration.go | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/listener/configuration_directive.go b/listener/configuration_directive.go index 3937ace..b554233 100644 --- a/listener/configuration_directive.go +++ b/listener/configuration_directive.go @@ -62,7 +62,7 @@ func (l *ExtendedSeclangParserListener) EnterSec_marker_directive(ctx *parser.Se } l.appendDirective = func() { l.DirectiveList.Marker = *l.configurationDirective - l.ConfigurationList.Groups = append(l.ConfigurationList.Groups, *l.DirectiveList) + l.Ruleset.Groups = append(l.Ruleset.Groups, *l.DirectiveList) l.DirectiveList = new(types.Group) } } diff --git a/listener/extended_seclang_parser_listener.go b/listener/extended_seclang_parser_listener.go index 62c5c38..e46f889 100644 --- a/listener/extended_seclang_parser_listener.go +++ b/listener/extended_seclang_parser_listener.go @@ -44,7 +44,7 @@ type ExtendedSeclangParserListener struct { varCount bool parameter string DirectiveList *types.Group - ConfigurationList types.Ruleset + Ruleset types.Ruleset } func doNothingFunc() {} @@ -63,7 +63,7 @@ func (l *ExtendedSeclangParserListener) EnterConfiguration(ctx *parser.Configura func (l *ExtendedSeclangParserListener) ExitConfiguration(ctx *parser.ConfigurationContext) { if l.DirectiveList != nil && (len(l.DirectiveList.Directives) > 0 || l.DirectiveList.Marker.Name != "") { - l.ConfigurationList.Groups = append(l.ConfigurationList.Groups, *l.DirectiveList) + l.Ruleset.Groups = append(l.Ruleset.Groups, *l.DirectiveList) } } diff --git a/listener_test.go b/listener_test.go index e6f1e88..0497f35 100644 --- a/listener_test.go +++ b/listener_test.go @@ -682,7 +682,7 @@ func TestLoadSecLang(t *testing.T) { start := p.Configuration() var seclangListener listener.ExtendedSeclangParserListener antlr.ParseTreeWalkerDefault.Walk(&seclangListener, start) - got = seclangListener.ConfigurationList + got = seclangListener.Ruleset require.Equalf(t, test.expected, got, test.name) }) diff --git a/types/configuration.go b/types/configuration.go index 271aa80..dabb3dc 100644 --- a/types/configuration.go +++ b/types/configuration.go @@ -11,7 +11,7 @@ type DefaultConfigs struct { type Ruleset struct { Global DefaultConfigs `yaml:"global,omitempty"` - GroupsIds []string `yaml:"group_ids,omitempty"` + GroupsIds []string `yaml:"rule_groups,omitempty"` Groups []Group `yaml:"groups,omitempty"` } From 3aec51a23e3055618204088f50b1ab243aa39a68 Mon Sep 17 00:00:00 2001 From: Agustindeleon Date: Sun, 22 Feb 2026 12:32:16 -0300 Subject: [PATCH 08/17] feat: update tags in translations --- main.go | 5 +++-- translator/crslang.go | 31 ++++++++++++++++++++++--------- translator/seclang.go | 8 ++++---- types/condition_directives.go | 7 +++++++ 4 files changed, 36 insertions(+), 15 deletions(-) diff --git a/main.go b/main.go index 1ae9e2d..9aad0a8 100644 --- a/main.go +++ b/main.go @@ -77,11 +77,12 @@ Flags: log.Fatal(err.Error()) } } else { - /* Expiremental load rule from dir */ - _, err := translator.LoadRulesFromDirectory(pathArg) + /* Load rule from dir */ + configList, err := translator.LoadRulesFromDirectory(pathArg) if err != nil { log.Fatal(err.Error()) } + err = translator.PrintSeclang(configList, *output) if err != nil { log.Fatal(err.Error()) } diff --git a/translator/crslang.go b/translator/crslang.go index 55080b6..e30c16f 100644 --- a/translator/crslang.go +++ b/translator/crslang.go @@ -15,6 +15,11 @@ func ToCRSLang(configList types.Ruleset) *types.Ruleset { configListWithConditions := types.ToDirectiveWithConditions(configList) configListWithConditions.ExtractDefaultValues() + + for i := range configListWithConditions.Groups { + configListWithConditions.Groups[i].ExtractDefaultValues() + } + return configListWithConditions } @@ -46,16 +51,14 @@ func WriteRuleSeparately(rulset types.Ruleset, output string) error { if !ok { return fmt.Errorf("Error casting to RuleWithCondition") } - // Ignore paranoia level check rules - lastDigits := rule.Metadata.Id % 1000 - if lastDigits/100 != 0 { - fileName := filepath.Join(ruleFolder, strconv.Itoa(rule.Metadata.Id)+".yaml") - err := PrintYAML(directive, fileName) - if err != nil { - return err - } - ruleIds = append(ruleIds, strconv.Itoa(rule.Metadata.Id)) + + fileName := filepath.Join(ruleFolder, strconv.Itoa(rule.Metadata.Id)+".yaml") + err := PrintYAML(directive, fileName) + if err != nil { + return err } + ruleIds = append(ruleIds, strconv.Itoa(rule.Metadata.Id)) + } else if directive.GetKind() == types.CommentKind { comment, ok := directive.(types.CommentDirective) if !ok { @@ -128,6 +131,16 @@ func LoadRulesFromDirectory(dir string) (types.Ruleset, error) { if err != nil { return types.Ruleset{}, err } + for _, comment := range group.Comments { + group.Directives = append(group.Directives, types.CommentDirective{ + Metadata: types.CommentMetadata{ + Comment: comment, + }, + }) + } + for _, config := range group.Configurations { + group.Directives = append(group.Directives, config) + } for _, ruleId := range group.Rules { ruleFile, err := os.ReadFile(filepath.Join(dir, groupId, "rules", ruleId+".yaml")) if err != nil { diff --git a/translator/seclang.go b/translator/seclang.go index 9c853b4..8c5da9d 100644 --- a/translator/seclang.go +++ b/translator/seclang.go @@ -33,8 +33,8 @@ func LoadSeclangFromString(content string, id string) (types.Ruleset, error) { start := p.Configuration() var seclangListener listener.ExtendedSeclangParserListener antlr.ParseTreeWalkerDefault.Walk(&seclangListener, start) - assignDirectiveIDs(seclangListener.ConfigurationList.Groups, id) - return seclangListener.ConfigurationList, nil + assignDirectiveIDs(seclangListener.Ruleset.Groups, id) + return seclangListener.Ruleset, nil } // LoadSeclang loads seclang directives from an input file or folder and returns a Ruleset @@ -57,8 +57,8 @@ func LoadSeclang(input string) (types.Ruleset, error) { var seclangListener listener.ExtendedSeclangParserListener antlr.ParseTreeWalkerDefault.Walk(&seclangListener, start) id := strings.TrimSuffix(filepath.Base(info.Name()), filepath.Ext(info.Name())) - assignDirectiveIDs(seclangListener.ConfigurationList.Groups, id) - resultConfigs = append(resultConfigs, seclangListener.ConfigurationList.Groups...) + assignDirectiveIDs(seclangListener.Ruleset.Groups, id) + resultConfigs = append(resultConfigs, seclangListener.Ruleset.Groups...) } return nil }) diff --git a/types/condition_directives.go b/types/condition_directives.go index 7f1b5ad..bf8fdb9 100644 --- a/types/condition_directives.go +++ b/types/condition_directives.go @@ -415,6 +415,13 @@ func FromCRSLangToUnformattedDirectives(configListWrapped Ruleset) *Ruleset { for _, tag := range configListWrapped.Global.Tags { chainableDir.GetMetadata().AddTag(tag) } + // Ignore paranoia level check rules when adding group tags + lastDigits := *&directiveWrapped.(*RuleWithCondition).Metadata.Id % 1000 + if lastDigits/100 != 0 { + for _, tag := range config.Tags { + chainableDir.GetMetadata().AddTag(tag) + } + } directive = chainableDir case ConfigurationDirective: directive = ConfigurationDirective{ From 0b738565fe609d0d771854e8e4b3fcee347d268f Mon Sep 17 00:00:00 2001 From: Agustindeleon Date: Sun, 22 Feb 2026 13:09:31 -0300 Subject: [PATCH 09/17] chore: propagate groups tags to rules --- types/condition_directives.go | 14 ++++++++------ types/configuration.go | 3 ++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/types/condition_directives.go b/types/condition_directives.go index bf8fdb9..067629f 100644 --- a/types/condition_directives.go +++ b/types/condition_directives.go @@ -131,13 +131,14 @@ func RuleToCondition(directive ChainableDirective) *RuleWithCondition { // configurationYamlLoader is a auxiliary struct to load the whole yaml file type configurationYamlLoader struct { - Global DefaultConfigs `yaml:"global,omitempty"` - DirectiveList []yamlLoaderConditionRules `yaml:"groups,omitempty"` + Global DefaultConfigs `yaml:"global,omitempty"` + Groups []yamlLoaderConditionRules `yaml:"groups,omitempty"` } // yamlLoaderConditionRules is a auxiliary struct to load and iterate over the yaml file type yamlLoaderConditionRules struct { Id string `yaml:"id"` + Tags []string `yaml:"tags,omitempty"` Directives []yaml.Node `yaml:"directives,omitempty"` Marker ConfigurationDirective `yaml:"marker,omitempty"` } @@ -276,12 +277,12 @@ func LoadDirectivesWithConditionsFromFile(filename string) Ruleset { func LoadDirectivesWithConditions(yamlFile []byte) Ruleset { var config configurationYamlLoader err := yaml.Unmarshal(yamlFile, &config) - configs := config.DirectiveList + groups := config.Groups if err != nil { panic(err) } var resultConfigs []Group - for _, config := range configs { + for _, config := range groups { var directives []SeclangDirective for _, yamlDirective := range config.Directives { directive := loadConditionDirective(yamlDirective) @@ -291,7 +292,7 @@ func LoadDirectivesWithConditions(yamlFile []byte) Ruleset { directives = append(directives, directive) } } - resultConfigs = append(resultConfigs, Group{Id: config.Id, Directives: directives, Marker: config.Marker}) + resultConfigs = append(resultConfigs, Group{Id: config.Id, Tags: config.Tags, Directives: directives, Marker: config.Marker}) } return Ruleset{Global: config.Global, Groups: resultConfigs} } @@ -399,6 +400,7 @@ func FromCRSLangToUnformattedDirectives(configListWrapped Ruleset) *Ruleset { for _, config := range configListWrapped.Groups { configList := new(Group) configList.Id = config.Id + configList.Tags = config.Tags configList.Marker = config.Marker for _, directiveWrapped := range config.Directives { var directive SeclangDirective @@ -417,7 +419,7 @@ func FromCRSLangToUnformattedDirectives(configListWrapped Ruleset) *Ruleset { } // Ignore paranoia level check rules when adding group tags lastDigits := *&directiveWrapped.(*RuleWithCondition).Metadata.Id % 1000 - if lastDigits/100 != 0 { + if !(lastDigits < 20) { for _, tag := range config.Tags { chainableDir.GetMetadata().AddTag(tag) } diff --git a/types/configuration.go b/types/configuration.go index dabb3dc..fff9f87 100644 --- a/types/configuration.go +++ b/types/configuration.go @@ -109,8 +109,9 @@ func (g *Group) ExtractDefaultValues() { for j := range g.Directives { // Only consider Rule directives if g.Directives[j].GetKind() == RuleKind { + // Ignore paranoia level check rules lastDigits := g.Directives[j].(*RuleWithCondition).Metadata.Id % 1000 - if lastDigits/100 != 0 { + if lastDigits < 20 { rule := g.Directives[j].(*RuleWithCondition) rules = append(rules, rule) auxTags := []string{} From 62541d72418dc20a92cdf398a61ae09510f97ad4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Zipitr=C3=ADa?= <3012076+fzipi@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:12:36 -0300 Subject: [PATCH 10/17] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- main.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index 9aad0a8..53a61c3 100644 --- a/main.go +++ b/main.go @@ -16,7 +16,7 @@ var ( ) func main() { - toSeclang := flag.Bool("s", false, "Transalates the specified CRSLang file to Seclang files, instead of the default Seclang to CRSLang.") + toSeclang := flag.Bool("s", false, "Translates the specified CRSLang file to Seclang files, instead of the default Seclang to CRSLang.") // Experimental flag dirMode := flag.Bool("d", false, "If set, the script output will be divided into multiple files when translating from Seclang to CRSLang.") output := flag.String("o", "", "Output file name used in translation from Seclang to CRSLang. Output folder used in translation from CRSLang to Seclang.") @@ -59,6 +59,9 @@ Flags: log.Fatal(err.Error()) } } else { + if *output == "" { + *output = "crslang" + } err := translator.WriteRuleSeparately(configList, *output) if err != nil { log.Fatal(err.Error()) From dda03ad1835b83010a93e9f49eb9a6e66c030a44 Mon Sep 17 00:00:00 2001 From: Agustindeleon Date: Sat, 28 Feb 2026 19:31:27 -0300 Subject: [PATCH 11/17] chore: update error messages --- translator/crslang.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/translator/crslang.go b/translator/crslang.go index e30c16f..c94c6ca 100644 --- a/translator/crslang.go +++ b/translator/crslang.go @@ -49,7 +49,7 @@ func WriteRuleSeparately(rulset types.Ruleset, output string) error { if directive.GetKind() == types.RuleKind { rule, ok := directive.(*types.RuleWithCondition) if !ok { - return fmt.Errorf("Error casting to RuleWithCondition") + return fmt.Errorf("error casting directive of type %T in group %s to *types.RuleWithCondition", directive, group.Id) } fileName := filepath.Join(ruleFolder, strconv.Itoa(rule.Metadata.Id)+".yaml") @@ -62,13 +62,13 @@ func WriteRuleSeparately(rulset types.Ruleset, output string) error { } else if directive.GetKind() == types.CommentKind { comment, ok := directive.(types.CommentDirective) if !ok { - return fmt.Errorf("Error casting to Comment %T", directive) + return fmt.Errorf("error casting directive of type %T in group %s to types.CommentDirective", directive, group.Id) } comments = append(comments, comment.Metadata.Comment) } else if directive.GetKind() == types.ConfigurationKind { config, ok := directive.(types.ConfigurationDirective) if !ok { - return fmt.Errorf("Error casting to Configuration %T", directive) + return fmt.Errorf("error casting directive of type %T in group %s to types.ConfigurationDirective", directive, group.Id) } configs = append(configs, config) } From daff679a2edebc29f69f543ebeef84a3ca8075da Mon Sep 17 00:00:00 2001 From: Agustindeleon Date: Sat, 28 Feb 2026 19:33:44 -0300 Subject: [PATCH 12/17] chore: update d flag message --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index 53a61c3..dc34e79 100644 --- a/main.go +++ b/main.go @@ -18,7 +18,7 @@ var ( func main() { toSeclang := flag.Bool("s", false, "Translates the specified CRSLang file to Seclang files, instead of the default Seclang to CRSLang.") // Experimental flag - dirMode := flag.Bool("d", false, "If set, the script output will be divided into multiple files when translating from Seclang to CRSLang.") + dirMode := flag.Bool("d", false, "Directory mode. In Seclang -> CRSLang (default), split the output into multiple YAML files. In CRSLang -> Seclang (-s), treat the input path as a directory containing ruleset.yaml/group.yaml instead of a single YAML file.") output := flag.String("o", "", "Output file name used in translation from Seclang to CRSLang. Output folder used in translation from CRSLang to Seclang.") flag.Usage = func() { From 78ac122c8b73bb27894eda398d503511dda43154 Mon Sep 17 00:00:00 2001 From: Agustindeleon Date: Sat, 28 Feb 2026 19:51:10 -0300 Subject: [PATCH 13/17] chore: add verifyId function to prevent dots in group ids --- translator/crslang.go | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/translator/crslang.go b/translator/crslang.go index c94c6ca..676e38a 100644 --- a/translator/crslang.go +++ b/translator/crslang.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "strconv" "github.com/coreruleset/crslang/types" @@ -23,7 +24,16 @@ func ToCRSLang(configList types.Ruleset) *types.Ruleset { return configListWithConditions } -func WriteRuleSeparately(rulset types.Ruleset, output string) error { +// WriteRuleSeparately writes each rule in a separate file, and creates a group.yaml file for each group and a ruleset.yaml file for the whole ruleset. The output is organized in the following structure: +// output/ +// ├── ruleset.yaml +// ├── group1/ +// │ ├── group.yaml +// │ └── rules/ +// │ ├── 1.yaml +// │ ├── 2.yaml +// │ └── ... +func WriteRuleSeparately(ruleset types.Ruleset, output string) error { output = filepath.Clean(output) if err := os.MkdirAll(output, 0755); err != nil { return err @@ -32,7 +42,7 @@ func WriteRuleSeparately(rulset types.Ruleset, output string) error { groups := []string{} // EXPERIMENTAL: output each group and rule in separate files - for _, group := range rulset.Groups { + for _, group := range ruleset.Groups { groups = append(groups, group.Id) groupFolder := filepath.Join(output, group.Id) @@ -88,7 +98,7 @@ func WriteRuleSeparately(rulset types.Ruleset, output string) error { } newRuleset := types.Ruleset{ - Global: rulset.Global, + Global: ruleset.Global, GroupsIds: groups, } err := PrintYAML(newRuleset, filepath.Join(output, "ruleset.yaml")) @@ -98,6 +108,15 @@ func WriteRuleSeparately(rulset types.Ruleset, output string) error { return nil } +// LoadRulesFromDirectory loads a ruleset from a directory containing a ruleset.yaml file and group subdirectories with group.yaml and rule yaml files. The structure of the directory should be as follows: +// dir/ +// ├── ruleset.yaml +// ├── group1/ +// │ ├── group.yaml +// │ └── rules/ +// │ ├── 1.yaml +// │ ├── 2.yaml +// │ └── ... func LoadRulesFromDirectory(dir string) (types.Ruleset, error) { info, err := os.Stat(dir) @@ -122,6 +141,9 @@ func LoadRulesFromDirectory(dir string) (types.Ruleset, error) { } for _, groupId := range ruleset.GroupsIds { + if err := verifyId(groupId); err != nil { + return types.Ruleset{}, err + } groupFile, err := os.ReadFile(filepath.Join(dir, groupId, "group.yaml")) if err != nil { return types.Ruleset{}, err @@ -159,3 +181,12 @@ func LoadRulesFromDirectory(dir string) (types.Ruleset, error) { ruleset.GroupsIds = nil return ruleset, nil } + +var validID = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +func verifyId(id string) error { + if !validID.MatchString(id) { + return fmt.Errorf("invalid id: %s. Ids can only contain letters, numbers, underscores and hyphens", id) + } + return nil +} From 5e34332b1ec26001ecc7be15a8f9f733e13aaf8d Mon Sep 17 00:00:00 2001 From: Agustindeleon Date: Sat, 28 Feb 2026 20:17:09 -0300 Subject: [PATCH 14/17] chore: update rule id type to int --- translator/crslang.go | 6 +++--- types/configuration.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/translator/crslang.go b/translator/crslang.go index 676e38a..917d47a 100644 --- a/translator/crslang.go +++ b/translator/crslang.go @@ -52,7 +52,7 @@ func WriteRuleSeparately(ruleset types.Ruleset, output string) error { return err } - ruleIds := []string{} + ruleIds := []int{} comments := []string{} configs := []types.ConfigurationDirective{} for _, directive := range group.Directives { @@ -67,7 +67,7 @@ func WriteRuleSeparately(ruleset types.Ruleset, output string) error { if err != nil { return err } - ruleIds = append(ruleIds, strconv.Itoa(rule.Metadata.Id)) + ruleIds = append(ruleIds, rule.Metadata.Id) } else if directive.GetKind() == types.CommentKind { comment, ok := directive.(types.CommentDirective) @@ -164,7 +164,7 @@ func LoadRulesFromDirectory(dir string) (types.Ruleset, error) { group.Directives = append(group.Directives, config) } for _, ruleId := range group.Rules { - ruleFile, err := os.ReadFile(filepath.Join(dir, groupId, "rules", ruleId+".yaml")) + ruleFile, err := os.ReadFile(filepath.Join(dir, groupId, "rules", strconv.Itoa(ruleId)+".yaml")) if err != nil { return types.Ruleset{}, err } diff --git a/types/configuration.go b/types/configuration.go index fff9f87..76048e2 100644 --- a/types/configuration.go +++ b/types/configuration.go @@ -21,7 +21,7 @@ type Group struct { Comments []string `yaml:"comments,omitempty"` Configurations []ConfigurationDirective `yaml:"configurations,omitempty"` Directives []SeclangDirective `yaml:"directives,omitempty"` - Rules []string `yaml:"rules,omitempty"` + Rules []int `yaml:"rules,omitempty"` Marker ConfigurationDirective `yaml:"marker,omitempty"` } From de9a4f136140e53c9fc925d85b51641e2afda1f3 Mon Sep 17 00:00:00 2001 From: Agustindeleon Date: Sun, 1 Mar 2026 02:03:02 -0300 Subject: [PATCH 15/17] fix: ignore paranoia level check rule conditions --- types/condition_directives.go | 2 +- types/configuration.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/types/condition_directives.go b/types/condition_directives.go index 067629f..7b62717 100644 --- a/types/condition_directives.go +++ b/types/condition_directives.go @@ -419,7 +419,7 @@ func FromCRSLangToUnformattedDirectives(configListWrapped Ruleset) *Ruleset { } // Ignore paranoia level check rules when adding group tags lastDigits := *&directiveWrapped.(*RuleWithCondition).Metadata.Id % 1000 - if !(lastDigits < 20) { + if !(lastDigits > 20) { for _, tag := range config.Tags { chainableDir.GetMetadata().AddTag(tag) } diff --git a/types/configuration.go b/types/configuration.go index 76048e2..76c0179 100644 --- a/types/configuration.go +++ b/types/configuration.go @@ -111,7 +111,7 @@ func (g *Group) ExtractDefaultValues() { if g.Directives[j].GetKind() == RuleKind { // Ignore paranoia level check rules lastDigits := g.Directives[j].(*RuleWithCondition).Metadata.Id % 1000 - if lastDigits < 20 { + if lastDigits > 20 { rule := g.Directives[j].(*RuleWithCondition) rules = append(rules, rule) auxTags := []string{} From b1b8c29b0c5f96a35d60a3e4279d5154328de6e0 Mon Sep 17 00:00:00 2001 From: Agustindeleon Date: Sun, 1 Mar 2026 02:06:07 -0300 Subject: [PATCH 16/17] fix: lastDigits condition --- types/condition_directives.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/types/condition_directives.go b/types/condition_directives.go index 7b62717..067629f 100644 --- a/types/condition_directives.go +++ b/types/condition_directives.go @@ -419,7 +419,7 @@ func FromCRSLangToUnformattedDirectives(configListWrapped Ruleset) *Ruleset { } // Ignore paranoia level check rules when adding group tags lastDigits := *&directiveWrapped.(*RuleWithCondition).Metadata.Id % 1000 - if !(lastDigits > 20) { + if !(lastDigits < 20) { for _, tag := range config.Tags { chainableDir.GetMetadata().AddTag(tag) } From 33d9cb4f87bcb228f3ea1f1618439a8bfe827980 Mon Sep 17 00:00:00 2001 From: Agustindeleon Date: Mon, 2 Mar 2026 08:15:00 -0300 Subject: [PATCH 17/17] test: add test cases --- translator/crslang.go | 3 + translator/translator_test.go | 331 ++++++++++++++++++++++++++++++++++ 2 files changed, 334 insertions(+) diff --git a/translator/crslang.go b/translator/crslang.go index 917d47a..efe4aa5 100644 --- a/translator/crslang.go +++ b/translator/crslang.go @@ -155,14 +155,17 @@ func LoadRulesFromDirectory(dir string) (types.Ruleset, error) { } for _, comment := range group.Comments { group.Directives = append(group.Directives, types.CommentDirective{ + Kind: types.CommentKind, Metadata: types.CommentMetadata{ Comment: comment, }, }) } + group.Comments = nil for _, config := range group.Configurations { group.Directives = append(group.Directives, config) } + group.Configurations = nil for _, ruleId := range group.Rules { ruleFile, err := os.ReadFile(filepath.Join(dir, groupId, "rules", strconv.Itoa(ruleId)+".yaml")) if err != nil { diff --git a/translator/translator_test.go b/translator/translator_test.go index f31dbc4..2842b01 100644 --- a/translator/translator_test.go +++ b/translator/translator_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/coreruleset/crslang/types" + "github.com/stretchr/testify/require" "go.yaml.in/yaml/v4" ) @@ -80,3 +81,333 @@ func TestFromCRSLangToSeclang(t *testing.T) { } } + +func TestWriteAndLoadRuleSeparately(t *testing.T) { + testCases := []struct { + name string + input types.Ruleset + expected types.Ruleset + }{ + { + name: "Simple ruleset with comment, config and rule", + input: types.Ruleset{ + Global: types.DefaultConfigs{ + Version: "4.0.0", + Tags: []string{"OWASP_CRS"}, + }, + Groups: []types.Group{ + { + Id: "test-group-1", + Tags: []string{"tag1", "tag2"}, + Directives: []types.SeclangDirective{ + types.CommentDirective{ + Kind: types.CommentKind, + Metadata: types.CommentMetadata{ + Comment: "Test comment", + }, + }, + types.ConfigurationDirective{ + Kind: types.ConfigurationKind, + Name: types.SecRuleEngine, + Parameter: "On", + }, + &types.RuleWithCondition{ + Kind: types.RuleKind, + Metadata: types.SecRuleMetadata{ + OnlyPhaseMetadata: types.OnlyPhaseMetadata{ + Phase: "1", + }, + Id: 12345, + Msg: "Test rule", + }, + Conditions: []types.Condition{ + { + Variables: []types.Variable{ + { + Name: types.REQUEST_URI, + }, + }, + Operator: types.Operator{ + Name: types.Rx, + Value: "/test", + }, + }, + }, + }, + }, + }, + }, + }, + expected: types.Ruleset{ + Global: types.DefaultConfigs{ + Version: "4.0.0", + Tags: []string{"OWASP_CRS"}, + }, + Groups: []types.Group{ + { + Id: "test-group-1", + Tags: []string{"tag1", "tag2"}, + Directives: []types.SeclangDirective{ + types.CommentDirective{ + Kind: types.CommentKind, + Metadata: types.CommentMetadata{ + Comment: "Test comment", + }, + }, + types.ConfigurationDirective{ + Kind: types.ConfigurationKind, + Name: types.SecRuleEngine, + Parameter: "On", + }, + &types.RuleWithCondition{ + Kind: types.RuleKind, + Metadata: types.SecRuleMetadata{ + OnlyPhaseMetadata: types.OnlyPhaseMetadata{ + Phase: "1", + }, + Id: 12345, + Msg: "Test rule", + }, + Conditions: []types.Condition{ + { + Variables: []types.Variable{ + { + Name: types.REQUEST_URI, + }, + }, + Operator: types.Operator{ + Name: types.Rx, + Value: "/test", + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "Multiple groups with different directive types", + input: types.Ruleset{ + Global: types.DefaultConfigs{ + Version: "4.0.0", + Tags: []string{"OWASP_CRS"}, + }, + Groups: []types.Group{ + { + Id: "group-a", + Tags: []string{"security", "testing"}, + Directives: []types.SeclangDirective{ + types.CommentDirective{ + Kind: types.CommentKind, + Metadata: types.CommentMetadata{ + Comment: "This is a test group", + }, + }, + &types.RuleWithCondition{ + Kind: types.RuleKind, + Metadata: types.SecRuleMetadata{ + OnlyPhaseMetadata: types.OnlyPhaseMetadata{ + Phase: "1", + }, + Id: 100, + Msg: "First rule", + }, + Conditions: []types.Condition{ + { + Variables: []types.Variable{ + { + Name: types.REQUEST_URI, + }, + }, + Operator: types.Operator{ + Name: types.Rx, + Value: "/admin", + }, + }, + }, + }, + &types.RuleWithCondition{ + Kind: types.RuleKind, + Metadata: types.SecRuleMetadata{ + OnlyPhaseMetadata: types.OnlyPhaseMetadata{ + Phase: "2", + }, + Id: 200, + Msg: "Second rule", + }, + Conditions: []types.Condition{ + { + Collections: []types.Collection{ + { + Name: types.REQUEST_HEADERS, + }, + }, + Operator: types.Operator{ + Name: types.Contains, + Value: "test", + }, + }, + }, + }, + }, + }, + { + Id: "group-b", + Directives: []types.SeclangDirective{ + types.ConfigurationDirective{ + Kind: types.ConfigurationKind, + Name: types.SecRuleEngine, + Parameter: "DetectionOnly", + }, + &types.RuleWithCondition{ + Kind: types.RuleKind, + Metadata: types.SecRuleMetadata{ + OnlyPhaseMetadata: types.OnlyPhaseMetadata{ + Phase: "3", + }, + Id: 300, + Msg: "Third rule", + }, + Conditions: []types.Condition{ + { + Variables: []types.Variable{ + { + Name: types.RESPONSE_BODY, + }, + }, + Operator: types.Operator{ + Name: types.Rx, + Value: "sensitive", + }, + }, + }, + }, + }, + }, + }, + }, + expected: types.Ruleset{ + Global: types.DefaultConfigs{ + Version: "4.0.0", + Tags: []string{"OWASP_CRS"}, + }, + Groups: []types.Group{ + { + Id: "group-a", + Tags: []string{"security", "testing"}, + Directives: []types.SeclangDirective{ + types.CommentDirective{ + Kind: types.CommentKind, + Metadata: types.CommentMetadata{ + Comment: "This is a test group", + }, + }, + &types.RuleWithCondition{ + Kind: types.RuleKind, + Metadata: types.SecRuleMetadata{ + OnlyPhaseMetadata: types.OnlyPhaseMetadata{ + Phase: "1", + }, + Id: 100, + Msg: "First rule", + }, + Conditions: []types.Condition{ + { + Variables: []types.Variable{ + { + Name: types.REQUEST_URI, + }, + }, + Operator: types.Operator{ + Name: types.Rx, + Value: "/admin", + }, + }, + }, + }, + &types.RuleWithCondition{ + Kind: types.RuleKind, + Metadata: types.SecRuleMetadata{ + OnlyPhaseMetadata: types.OnlyPhaseMetadata{ + Phase: "2", + }, + Id: 200, + Msg: "Second rule", + }, + Conditions: []types.Condition{ + { + Collections: []types.Collection{ + { + Name: types.REQUEST_HEADERS, + }, + }, + Operator: types.Operator{ + Name: types.Contains, + Value: "test", + }, + }, + }, + }, + }, + }, + { + Id: "group-b", + Directives: []types.SeclangDirective{ + types.ConfigurationDirective{ + Kind: types.ConfigurationKind, + Name: types.SecRuleEngine, + Parameter: "DetectionOnly", + }, + &types.RuleWithCondition{ + Kind: types.RuleKind, + Metadata: types.SecRuleMetadata{ + OnlyPhaseMetadata: types.OnlyPhaseMetadata{ + Phase: "3", + }, + Id: 300, + Msg: "Third rule", + }, + Conditions: []types.Condition{ + { + Variables: []types.Variable{ + { + Name: types.RESPONSE_BODY, + }, + }, + Operator: types.Operator{ + Name: types.Rx, + Value: "sensitive", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Create temporary directory for output + tmpDir := t.TempDir() + + // Write ruleset separately + err := WriteRuleSeparately(tc.input, tmpDir) + if err != nil { + t.Fatalf("WriteRuleSeparately failed: %v", err) + } + + // Load from directory + loadedRuleset, err := LoadRulesFromDirectory(tmpDir) + if err != nil { + t.Fatalf("LoadRulesFromDirectory failed: %v", err) + } + + require.Equal(t, tc.expected, loadedRuleset, tc.name) + }) + } +}