diff --git a/experimental/seclang/parser_v2.go b/experimental/seclang/parser_v2.go new file mode 100644 index 000000000..970cb21c4 --- /dev/null +++ b/experimental/seclang/parser_v2.go @@ -0,0 +1,1234 @@ +// Copyright 2024 Juan Pablo Tosso and the OWASP Coraza contributors +// SPDX-License-Identifier: Apache-2.0 + +//go:build coraza.experimental.crslang_parser + +// Package seclang provides an experimental ANTLR4-based SecLang parser +// that uses the crslang type system to parse SecLang into structured types, +// then converts those types into Coraza's internal representation. +// +// This implementation uses the ANTLR4 grammar from: +// https://github.com/coreruleset/seclang_parser +// And the crslang types from: +// https://github.com/coreruleset/crslang +// +// Build with -tags=coraza.experimental.crslang_parser to enable this parser. +package seclang + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/antlr4-go/antlr/v4" + "github.com/coreruleset/crslang/listener" + crstypes "github.com/coreruleset/crslang/types" + seclang_parser "github.com/coreruleset/seclang_parser/parser" + + "github.com/corazawaf/coraza/v3/experimental/plugins/plugintypes" + actionsmod "github.com/corazawaf/coraza/v3/internal/actions" + "github.com/corazawaf/coraza/v3/internal/corazawaf" + "github.com/corazawaf/coraza/v3/internal/operators" + utils "github.com/corazawaf/coraza/v3/internal/strings" + "github.com/corazawaf/coraza/v3/types" + "github.com/corazawaf/coraza/v3/types/variables" +) + +// ParserState holds mutable state that persists across directive parsing +type ParserState struct { + // Default actions per phase (raw action strings) + RuleDefaultActions []string + HasRuleDefaultActions bool + + // Disabled features + DisabledRuleActions []string + DisabledRuleOperators []string + + // Datasets for file-based operators + Datasets map[string][]string + + // Current location for error reporting + CurrentFile string + CurrentDir string + CurrentLine int + + // Working directory + WorkingDir string + + // Filesystem root + Root fs.FS + + // IgnoreRuleCompilationErrors, if true, ignores rule compilation errors + IgnoreRuleCompilationErrors bool +} + +// Parser uses the crslang listener to parse SecLang into structured types, +// then converts those types into Coraza's internal representation. +type Parser struct { + waf *corazawaf.WAF + root fs.FS + currentFile string + currentDir string + + state *ParserState +} + +// NewParser creates a new ANTLR4-based SecLang parser that uses crslang's listener +func NewParser(waf *corazawaf.WAF) *Parser { + return &Parser{ + waf: waf, + root: nil, + state: &ParserState{ + Datasets: make(map[string][]string), + }, + } +} + +// SetRoot sets the filesystem root for this parser +func (p *Parser) SetRoot(root fs.FS) { + p.root = root + p.state.Root = root +} + +// FromFile imports directives from a file +func (p *Parser) FromFile(profilePath string) error { + originalDir := p.currentDir + + var files []string + if strings.Contains(profilePath, "*") { + fsys := p.root + if fsys == nil { + fsys = &osFS{} + } + + var err error + files, err = fs.Glob(fsys, profilePath) + if err != nil { + return fmt.Errorf("failed to glob: %w", err) + } + + if len(files) == 0 { + p.waf.Logger.Warn(). + Str("pattern", profilePath). + Msg("empty glob result") + } + } else { + files = append(files, profilePath) + } + + for _, filePath := range files { + filePath = strings.TrimSpace(filePath) + + if !strings.HasPrefix(filePath, "/") { + filePath = filepath.Join(p.currentDir, filePath) + } + + p.currentFile = filePath + lastDir := p.currentDir + p.currentDir = filepath.Dir(filePath) + p.state.CurrentFile = filePath + p.state.CurrentDir = p.currentDir + + fsys := p.root + if fsys == nil { + fsys = &osFS{} + } + + data, err := fs.ReadFile(fsys, filePath) + if err != nil { + p.currentDir = originalDir + p.currentFile = "" + return fmt.Errorf("failed to read file %s: %w", filePath, err) + } + + if err := p.parseString(string(data)); err != nil { + p.currentDir = originalDir + p.currentFile = "" + return fmt.Errorf("failed to parse file %s: %w", filePath, err) + } + + p.currentDir = lastDir + } + + p.currentDir = originalDir + p.currentFile = "" + + return nil +} + +// FromString imports directives from a string +func (p *Parser) FromString(data string) error { + oldCurrentFile := p.currentFile + p.currentFile = "_inline_" + p.state.CurrentFile = "_inline_" + + err := p.parseString(data) + + p.currentFile = oldCurrentFile + p.state.CurrentFile = oldCurrentFile + + return err +} + +// parseString parses SecLang configuration using ANTLR4 and crslang's listener +func (p *Parser) parseString(data string) error { + input := antlr.NewInputStream(data) + + lexer := seclang_parser.NewSecLangLexer(input) + lexerErrorListener := newErrorListener() + lexer.RemoveErrorListeners() + lexer.AddErrorListener(lexerErrorListener) + + stream := antlr.NewCommonTokenStream(lexer, 0) + + secLangParser := seclang_parser.NewSecLangParser(stream) + parserErrorListener := newErrorListener() + secLangParser.RemoveErrorListeners() + secLangParser.AddErrorListener(parserErrorListener) + + tree := secLangParser.Configuration() + + allErrors := lexerErrorListener.errors + allErrors = append(allErrors, parserErrorListener.errors...) + if len(allErrors) > 0 { + return fmt.Errorf("parse errors: %v", allErrors) + } + + // Use crslang's listener to extract structured data + crsListener := &listener.ExtendedSeclangParserListener{ + BaseSecLangParserListener: &seclang_parser.BaseSecLangParserListener{}, + } + antlr.ParseTreeWalkerDefault.Walk(crsListener, tree) + + // Convert crslang types to Coraza WAF configuration. + // After tree walking, all directives are in ConfigurationList.DirectiveList + // (ExitConfiguration copies DirectiveList into ConfigurationList). + converter := newTypeConverter(p.waf, p.state) + for _, dl := range crsListener.ConfigurationList.DirectiveList { + if err := converter.convertDirectives(&dl); err != nil { + return err + } + } + + return nil +} + +// osFS is a simple wrapper that implements fs.FS for the root filesystem +type osFS struct{} + +func (osFS) Open(name string) (fs.File, error) { + if strings.HasPrefix(name, "/") { + return os.DirFS("/").Open(strings.TrimPrefix(name, "/")) + } + return os.DirFS(".").Open(name) +} + +// errorListener collects syntax errors during parsing +type errorListener struct { + *antlr.DefaultErrorListener + errors []error +} + +func newErrorListener() *errorListener { + return &errorListener{ + DefaultErrorListener: antlr.NewDefaultErrorListener(), + errors: make([]error, 0), + } +} + +func (el *errorListener) SyntaxError( + _ antlr.Recognizer, + _ interface{}, + line, column int, + msg string, + _ antlr.RecognitionException, +) { + el.errors = append(el.errors, fmt.Errorf("syntax error at line %d:%d: %s", line, column, msg)) +} + +// ruleAction mirrors internal/seclang.ruleAction for action processing +type ruleAction struct { + Key string + Value string + Atype plugintypes.ActionType + F plugintypes.Action +} + +// typeConverter converts crslang types to Coraza's internal representation +type typeConverter struct { + waf *corazawaf.WAF + state *ParserState +} + +func newTypeConverter(waf *corazawaf.WAF, state *ParserState) *typeConverter { + return &typeConverter{ + waf: waf, + state: state, + } +} + +// convertDirectives converts a list of crslang directives to Coraza configuration +func (c *typeConverter) convertDirectives(directiveList *crstypes.DirectiveList) error { + if directiveList == nil { + return nil + } + + for _, directive := range directiveList.Directives { + if err := c.convertDirective(directive); err != nil { + if c.state.IgnoreRuleCompilationErrors { + c.waf.Logger.Debug(). + Err(err). + Msg("Ignoring rule compilation error") + continue + } + return err + } + } + + // Handle SecMarker which is stored as the Marker field, not in Directives + if directiveList.Marker.Name != "" { + if err := c.convertConfigDirective(&directiveList.Marker); err != nil { + return err + } + } + + return nil +} + +// convertDirective converts a single crslang directive to Coraza configuration +func (c *typeConverter) convertDirective(directive crstypes.SeclangDirective) error { + switch d := directive.(type) { + case *crstypes.SecRule: + return c.convertSecRule(d) + case *crstypes.SecAction: + return c.convertSecAction(d) + case crstypes.ConfigurationDirective: + return c.convertConfigDirective(&d) + case *crstypes.ConfigurationDirective: + return c.convertConfigDirective(d) + case crstypes.DefaultAction: + return c.convertDefaultAction(&d) + case *crstypes.DefaultAction: + return c.convertDefaultAction(d) + case crstypes.RemoveRuleDirective: + return c.convertRemoveRule(&d) + case *crstypes.RemoveRuleDirective: + return c.convertRemoveRule(d) + case *crstypes.UpdateTargetDirective: + return c.convertUpdateTarget(d) + case *crstypes.UpdateActionDirective: + return c.convertUpdateActions(d) + case crstypes.CommentMetadata: + // Comments are ignored + return nil + case *crstypes.CommentDirective: + // Comments are ignored + return nil + case crstypes.CommentDirective: + // Comments are ignored + return nil + default: + c.waf.Logger.Warn(). + Str("type", fmt.Sprintf("%T", directive)). + Msg("unsupported directive type") + return nil + } +} + +// convertConfigDirective converts a configuration directive +func (c *typeConverter) convertConfigDirective(config *crstypes.ConfigurationDirective) error { + name := string(config.Name) + value := config.Parameter + + switch config.Name { + case crstypes.SecRuleEngine: + engine, err := types.ParseRuleEngineStatus(value) + if err != nil { + return err + } + c.waf.RuleEngine = engine + case crstypes.SecRequestBodyAccess: + b, err := parseBoolean(strings.ToLower(value)) + if err != nil { + return err + } + c.waf.RequestBodyAccess = b + case crstypes.SecResponseBodyAccess: + b, err := parseBoolean(strings.ToLower(value)) + if err != nil { + return err + } + c.waf.ResponseBodyAccess = b + case crstypes.SecRequestBodyLimit: + limit, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return err + } + c.waf.RequestBodyLimit = limit + case crstypes.SecResponseBodyLimit: + limit, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return err + } + c.waf.ResponseBodyLimit = limit + case crstypes.SecComponentSignature: + c.waf.ComponentNames = append(c.waf.ComponentNames, value) + case crstypes.SecMarker: + return c.convertSecMarker(value) + default: + c.waf.Logger.Debug(). + Str("directive", name). + Msg("configuration directive not yet implemented") + } + + return nil +} + +// convertSecMarker creates a marker rule +func (c *typeConverter) convertSecMarker(name string) error { + rule := corazawaf.NewRule() + rule.Raw_ = fmt.Sprintf("SecMarker %s", name) + rule.SecMark_ = name + rule.ID_ = 0 + rule.LogID_ = "0" + rule.Phase_ = 0 + rule.Line_ = c.state.CurrentLine + rule.File_ = c.state.CurrentFile + return c.waf.Rules.Add(rule) +} + +// convertSecRule converts a crslang SecRule to a Coraza rule +func (c *typeConverter) convertSecRule(secRule *crstypes.SecRule) error { + rule := corazawaf.NewRule() + + // 1. Convert variables and collections + if err := c.addVariables(rule, secRule.Variables, secRule.Collections); err != nil { + return fmt.Errorf("failed to convert variables: %w", err) + } + + // 2. Convert operator + if err := c.setOperator(rule, secRule.Operator); err != nil { + return fmt.Errorf("failed to convert operator: %w", err) + } + + // 3. Process actions (metadata first, then merge with defaults, then apply rest) + if err := c.applyActions(rule, secRule.GetActions(), secRule.GetTransformations(), secRule.GetMetadata()); err != nil { + return fmt.Errorf("failed to convert actions: %w", err) + } + + // Set file/line metadata + rule.File_ = c.state.CurrentFile + rule.Line_ = c.state.CurrentLine + + // Handle chain rules + if secRule.ChainedRule != nil { + rule.HasChain = true + } + + // Check if this rule should be chained to a parent + if parent := getLastRuleExpectingChain(c.waf); parent != nil { + rule.ParentID_ = parent.ID_ + rule.LogID_ = parent.LogID_ + lastChain := parent + for lastChain.Chain != nil { + lastChain = lastChain.Chain + } + rule.Phase_ = 0 + lastChain.Chain = rule + parent.Raw_ += " \n" + secRule.ToSeclang() + } else { + rule.Raw_ = secRule.ToSeclang() + if err := c.waf.Rules.Add(rule); err != nil { + return err + } + } + + // Recursively convert chained rules + if secRule.ChainedRule != nil { + return c.convertChainableDirective(secRule.ChainedRule) + } + + return nil +} + +// convertChainableDirective converts a chained directive (can be SecRule, SecAction, etc.) +func (c *typeConverter) convertChainableDirective(directive crstypes.ChainableDirective) error { + switch d := directive.(type) { + case *crstypes.SecRule: + return c.convertSecRule(d) + case *crstypes.SecAction: + return c.convertSecAction(d) + default: + return fmt.Errorf("unsupported chained directive type: %T", directive) + } +} + +// convertSecAction converts a crslang SecAction to a Coraza rule +func (c *typeConverter) convertSecAction(secAction *crstypes.SecAction) error { + rule := corazawaf.NewRule() + + // SecAction has no variables or operator + if err := c.applyActions(rule, secAction.GetActions(), secAction.GetTransformations(), secAction.GetMetadata()); err != nil { + return fmt.Errorf("failed to convert actions: %w", err) + } + + rule.File_ = c.state.CurrentFile + rule.Line_ = c.state.CurrentLine + + if secAction.ChainedRule != nil { + rule.HasChain = true + } + + if parent := getLastRuleExpectingChain(c.waf); parent != nil { + rule.ParentID_ = parent.ID_ + rule.LogID_ = parent.LogID_ + lastChain := parent + for lastChain.Chain != nil { + lastChain = lastChain.Chain + } + rule.Phase_ = 0 + lastChain.Chain = rule + parent.Raw_ += " \n" + secAction.ToSeclang() + } else { + rule.Raw_ = secAction.ToSeclang() + if err := c.waf.Rules.Add(rule); err != nil { + return err + } + } + + if secAction.ChainedRule != nil { + return c.convertChainableDirective(secAction.ChainedRule) + } + + return nil +} + +// convertDefaultAction stores default actions in parser state +func (c *typeConverter) convertDefaultAction(da *crstypes.DefaultAction) error { + // Convert back to seclang string for compatibility with existing ParseDefaultActions + seclangStr := da.ToSeclang() + // Strip "SecDefaultAction " prefix, trailing whitespace, and surrounding quotes. + // ToSeclang() appends a trailing newline, so TrimSpace is required before + // MaybeRemoveQuotes can detect the closing quote character. + seclangStr = strings.TrimPrefix(seclangStr, "SecDefaultAction ") + seclangStr = utils.MaybeRemoveQuotes(strings.TrimSpace(seclangStr)) + + c.state.RuleDefaultActions = append(c.state.RuleDefaultActions, seclangStr) + c.state.HasRuleDefaultActions = true + return nil +} + +// convertRemoveRule removes rules by ID, tag, or msg +func (c *typeConverter) convertRemoveRule(remove *crstypes.RemoveRuleDirective) error { + for _, id := range remove.Ids { + c.waf.Rules.DeleteByID(id) + } + for _, idRange := range remove.IdRanges { + c.waf.Rules.DeleteByRange(idRange.Start, idRange.End) + } + for _, tag := range remove.Tags { + c.waf.Rules.DeleteByTag(tag) + } + for _, msg := range remove.Msgs { + c.waf.Rules.DeleteByMsg(msg) + } + return nil +} + +// convertUpdateTarget modifies variables on existing rules +func (c *typeConverter) convertUpdateTarget(update *crstypes.UpdateTargetDirective) error { + // Build variable string from crslang types for each target rule + varStr := c.buildVariableString(update.Variables, update.Collections) + + for _, id := range update.Ids { + rule := c.waf.Rules.FindByID(id) + if rule == nil { + c.waf.Logger.Warn(). + Int("rule_id", id). + Msg("SecRuleUpdateTargetById: rule not found") + continue + } + if err := c.addVariablesFromString(rule, varStr); err != nil { + return fmt.Errorf("failed to update target for rule %d: %w", id, err) + } + } + return nil +} + +// convertUpdateActions modifies actions on existing rules +func (c *typeConverter) convertUpdateActions(update *crstypes.UpdateActionDirective) error { + rule := c.waf.Rules.FindByID(update.Id) + if rule == nil { + c.waf.Logger.Warn(). + Int("rule_id", update.Id). + Msg("SecRuleUpdateActionById: rule not found") + return nil + } + + // Build actions from the update directive + actions, err := c.buildRuleActions(update.GetActions()) + if err != nil { + return fmt.Errorf("failed to build actions for update: %w", err) + } + + // Check for disruptive action replacement + hasDisruptive := false + for _, a := range actions { + if a.Atype == plugintypes.ActionTypeDisruptive { + hasDisruptive = true + break + } + } + if hasDisruptive { + rule.ClearDisruptiveActions() + } + + // Apply metadata actions first + for _, a := range actions { + if a.Atype == plugintypes.ActionTypeMetadata { + if err := a.F.Init(rule, a.Value); err != nil { + return fmt.Errorf("failed to init metadata action %s: %w", a.Key, err) + } + } + } + + // Apply non-metadata actions + for _, a := range actions { + if a.Atype == plugintypes.ActionTypeMetadata { + continue + } + if err := a.F.Init(rule, a.Value); err != nil { + return err + } + if err := rule.AddAction(a.Key, a.F); err != nil { + return err + } + } + + return nil +} + +// addVariables converts crslang variables and collections and adds them to a rule +func (c *typeConverter) addVariables(rule *corazawaf.Rule, vars []crstypes.Variable, cols []crstypes.Collection) error { + // Process simple variables + for _, v := range vars { + varName := v.Name.String() + rv, err := variables.Parse(varName) + if err != nil { + return fmt.Errorf("unknown variable %q: %w", varName, err) + } + if v.Excluded { + if err := rule.AddVariableNegation(rv, ""); err != nil { + return err + } + } else { + if err := rule.AddVariable(rv, "", false); err != nil { + return err + } + } + } + + // Process collections (variables with selectors) + for _, col := range cols { + colName := col.Name.String() + rv, err := variables.Parse(colName) + if err != nil { + return fmt.Errorf("unknown collection %q: %w", colName, err) + } + + if len(col.Arguments) == 0 && len(col.Excluded) == 0 { + // Collection without specific key (e.g., just ARGS) + if err := rule.AddVariable(rv, "", col.Count); err != nil { + return err + } + } else { + // Collection with specific keys (e.g., ARGS:username) + for _, arg := range col.Arguments { + if err := rule.AddVariable(rv, arg, col.Count); err != nil { + return err + } + } + } + + // Handle exclusions + for _, exc := range col.Excluded { + if err := rule.AddVariableNegation(rv, exc); err != nil { + return err + } + } + } + + return nil +} + +// setOperator converts a crslang operator and sets it on the rule +func (c *typeConverter) setOperator(rule *corazawaf.Rule, op crstypes.Operator) error { + opName := op.Name.String() + + opts := plugintypes.OperatorOptions{ + Arguments: op.Value, + Path: []string{ + c.state.CurrentDir, + }, + Root: c.state.Root, + Datasets: c.state.Datasets, + } + + if wd := c.state.WorkingDir; wd != "" { + opts.Path = append(opts.Path, wd) + } + + // Check if operator is disabled + if utils.InSlice(opName, c.state.DisabledRuleOperators) { + return fmt.Errorf("%s rule operator is disabled", opName) + } + + opfn, err := operators.Get(opName, opts) + if err != nil { + return fmt.Errorf("operator %q: %w", opName, err) + } + + funcName := "@" + opName + if op.Negate { + funcName = "!" + funcName + } + rule.SetOperator(opfn, funcName, op.Value) + return nil +} + +// applyActions processes crslang actions and metadata, and applies them to a rule. +// This follows the same pattern as internal/seclang/rule_parser.go applyParsedActions. +func (c *typeConverter) applyActions(rule *corazawaf.Rule, seclangActions *crstypes.SeclangActions, trans crstypes.Transformations, metadata crstypes.Metadata) error { + // Build ruleAction list from crslang actions + actions, err := c.buildRuleActions(seclangActions) + if err != nil { + return err + } + + // Build metadata actions from crslang Metadata (id, phase, msg, severity, etc.) + metaActions, err := c.buildMetadataActions(metadata) + if err != nil { + return err + } + actions = append(metaActions, actions...) + + // Add transformation actions + for _, t := range trans.Transformations { + tName := t.String() + f, getErr := actionsmod.Get("t") + if getErr != nil { + return getErr + } + actions = append(actions, ruleAction{ + Key: "t", + Value: tName, + Atype: f.Type(), + F: f, + }) + } + + // Check for disabled actions + for _, a := range actions { + if utils.InSlice(a.Key, c.state.DisabledRuleActions) { + return fmt.Errorf("%s rule action is disabled", a.Key) + } + } + + // Execute metadata actions first (id, phase, msg, severity, etc.) + for _, a := range actions { + if a.Atype == plugintypes.ActionTypeMetadata { + if err := a.F.Init(rule, a.Value); err != nil { + return fmt.Errorf("failed to init action %s: %s", a.Key, err.Error()) + } + } + } + + // Parse and merge with default actions + phase := rule.Phase_ + defaults, err := c.getDefaultActions(phase) + if err != nil { + return err + } + if defaults != nil { + actions = mergeActions(actions, defaults) + } + + // Execute non-metadata actions + for _, a := range actions { + if a.Atype == plugintypes.ActionTypeMetadata { + continue + } + if err := a.F.Init(rule, a.Value); err != nil { + return err + } + if err := rule.AddAction(a.Key, a.F); err != nil { + return err + } + } + + return nil +} + +// buildMetadataActions converts crslang metadata into ruleAction entries for metadata actions. +// crslang stores id, phase, msg, severity, etc. in the Metadata struct, not in Actions. +func (c *typeConverter) buildMetadataActions(metadata crstypes.Metadata) ([]ruleAction, error) { + if metadata == nil { + return nil, nil + } + + var actions []ruleAction + + // Build metadata action pairs based on the concrete type + switch m := metadata.(type) { + case *crstypes.SecRuleMetadata: + if m.Id != 0 { + a, err := c.makeMetadataAction("id", strconv.Itoa(m.Id)) + if err != nil { + return nil, err + } + actions = append(actions, a) + } + if m.Phase != "" { + a, err := c.makeMetadataAction("phase", m.Phase) + if err != nil { + return nil, err + } + actions = append(actions, a) + } + if m.Msg != "" { + a, err := c.makeMetadataAction("msg", m.Msg) + if err != nil { + return nil, err + } + actions = append(actions, a) + } + if m.Severity != "" { + a, err := c.makeMetadataAction("severity", m.Severity) + if err != nil { + return nil, err + } + actions = append(actions, a) + } + for _, tag := range m.Tags { + a, err := c.makeMetadataAction("tag", tag) + if err != nil { + return nil, err + } + actions = append(actions, a) + } + if m.Rev != "" { + a, err := c.makeMetadataAction("rev", m.Rev) + if err != nil { + return nil, err + } + actions = append(actions, a) + } + if m.Ver != "" { + a, err := c.makeMetadataAction("ver", m.Ver) + if err != nil { + return nil, err + } + actions = append(actions, a) + } + if m.Maturity != "" { + a, err := c.makeMetadataAction("maturity", m.Maturity) + if err != nil { + return nil, err + } + actions = append(actions, a) + } + case *crstypes.OnlyPhaseMetadata: + if m.Phase != "" { + a, err := c.makeMetadataAction("phase", m.Phase) + if err != nil { + return nil, err + } + actions = append(actions, a) + } + } + + return actions, nil +} + +func (c *typeConverter) makeMetadataAction(key, value string) (ruleAction, error) { + f, err := actionsmod.Get(key) + if err != nil { + return ruleAction{}, fmt.Errorf("metadata action %q: %w", key, err) + } + return ruleAction{ + Key: key, + Value: value, + Atype: f.Type(), + F: f, + }, nil +} + +// buildRuleActions converts crslang SeclangActions into ruleAction slice +func (c *typeConverter) buildRuleActions(seclangActions *crstypes.SeclangActions) ([]ruleAction, error) { + if seclangActions == nil { + return nil, nil + } + + var actions []ruleAction + disruptiveIdx := -1 + + // Disruptive action + if seclangActions.DisruptiveAction != nil { + a, err := c.actionToRuleAction(seclangActions.DisruptiveAction) + if err != nil { + return nil, err + } + disruptiveIdx = len(actions) + actions = append(actions, a) + } + + // Non-disruptive actions + for _, action := range seclangActions.NonDisruptiveActions { + a, err := c.actionToRuleAction(action) + if err != nil { + return nil, err + } + // Handle multiple disruptive actions (last one wins) + if a.Atype == plugintypes.ActionTypeDisruptive { + if disruptiveIdx != -1 { + actions[disruptiveIdx] = a + } else { + disruptiveIdx = len(actions) + actions = append(actions, a) + } + } else { + actions = append(actions, a) + } + } + + // Flow actions + for _, action := range seclangActions.FlowActions { + a, err := c.actionToRuleAction(action) + if err != nil { + return nil, err + } + actions = append(actions, a) + } + + // Data actions + for _, action := range seclangActions.DataActions { + a, err := c.actionToRuleAction(action) + if err != nil { + return nil, err + } + actions = append(actions, a) + } + + return actions, nil +} + +// actionToRuleAction converts a single crslang Action to a ruleAction +func (c *typeConverter) actionToRuleAction(action crstypes.Action) (ruleAction, error) { + key := action.GetKey() + + // Handle setvar specially - crslang's GetAllParams() returns "setvar:TX.key=value" + // but Coraza's setvar Init expects just "TX.key=value" + if sv, ok := action.(crstypes.SetvarAction); ok { + params := sv.GetAllParams() + if len(params) == 0 { + return ruleAction{}, fmt.Errorf("empty setvar action") + } + if len(params) > 1 { + return ruleAction{}, fmt.Errorf("multiple setvar parameters are not supported: got %d", len(params)) + } + + // Strip the "setvar:" prefix that GetAllParams includes + param := strings.TrimPrefix(params[0], "setvar:") + f, err := actionsmod.Get("setvar") + if err != nil { + return ruleAction{}, err + } + return ruleAction{ + Key: "setvar", + Value: param, + Atype: f.Type(), + F: f, + }, nil + } + + // Extract value from action + var value string + if awp, ok := action.(crstypes.ActionWithParam); ok { + value = awp.GetParam() + } + + f, err := actionsmod.Get(key) + if err != nil { + return ruleAction{}, fmt.Errorf("unknown action %q: %w", key, err) + } + + return ruleAction{ + Key: key, + Value: value, + Atype: f.Type(), + F: f, + }, nil +} + +// getDefaultActions returns the parsed default actions for the given phase +func (c *typeConverter) getDefaultActions(phase types.RulePhase) ([]ruleAction, error) { + defaultActions := make(map[types.RulePhase][]ruleAction) + + if c.state.HasRuleDefaultActions { + for _, da := range c.state.RuleDefaultActions { + act, err := parseActions(da) + if err != nil { + return nil, err + } + daPhase := types.RulePhase(0) + for _, a := range act { + if a.Key == "phase" { + daPhase, err = types.ParseRulePhase(a.Value) + if err != nil { + return nil, err + } + } + } + if daPhase != 0 { + defaultActions[daPhase] = act + } + } + } + + // If no default actions for phase 2, use hardcoded defaults + if defaultActions[types.PhaseRequestBody] == nil { + act, err := parseActions("phase:2,log,auditlog,pass") + if err != nil { + return nil, err + } + defaultActions[types.PhaseRequestBody] = act + } + + return defaultActions[phase], nil +} + +// parseActions parses a comma-separated action string into ruleAction slice. +// This replicates the logic from internal/seclang/rule_parser.go parseActions. +func parseActions(actions string) ([]ruleAction, error) { + var res []ruleAction + disruptiveActionIndex := -1 + + beforeKey := -1 + afterKey := -1 + inQuotes := false + + for i := 1; i < len(actions); i++ { + c := actions[i] + if actions[i-1] == '\\' { + continue + } + if c == '\'' { + inQuotes = !inQuotes + continue + } + if inQuotes { + continue + } + switch c { + case ':': + if afterKey != -1 { + continue + } + afterKey = i + case ',': + var val string + if afterKey == -1 { + afterKey = i + } else { + val = actions[afterKey+1 : i] + } + var err error + res, disruptiveActionIndex, err = appendRuleAction(res, actions[beforeKey+1:afterKey], val, disruptiveActionIndex) + if err != nil { + return nil, err + } + beforeKey = i + afterKey = -1 + } + } + + var val string + if afterKey == -1 { + afterKey = len(actions) + } else { + val = actions[afterKey+1:] + } + var err error + res, _, err = appendRuleAction(res, actions[beforeKey+1:afterKey], val, disruptiveActionIndex) + if err != nil { + return nil, err + } + return res, nil +} + +func appendRuleAction(res []ruleAction, key string, val string, disruptiveActionIndex int) ([]ruleAction, int, error) { + key = strings.ToLower(strings.TrimSpace(key)) + val = strings.TrimSpace(val) + val = utils.MaybeRemoveQuotes(val) + f, err := actionsmod.Get(key) + if err != nil { + return res, -1, err + } + if f.Type() == plugintypes.ActionTypeDisruptive && disruptiveActionIndex != -1 { + res[disruptiveActionIndex] = ruleAction{ + Key: key, + Value: val, + F: f, + Atype: f.Type(), + } + } else { + if f.Type() == plugintypes.ActionTypeDisruptive { + disruptiveActionIndex = len(res) + } + res = append(res, ruleAction{ + Key: key, + Value: val, + F: f, + Atype: f.Type(), + }) + } + return res, disruptiveActionIndex, nil +} + +// mergeActions merges rule actions with default actions. +// Replicates internal/seclang/rule_parser.go mergeActions. +func mergeActions(origin []ruleAction, defaults []ruleAction) []ruleAction { + var res []ruleAction + var da ruleAction + for _, action := range defaults { + if action.Atype == plugintypes.ActionTypeDisruptive { + da = action + continue + } + if action.Atype == plugintypes.ActionTypeMetadata { + continue + } + res = append(res, action) + } + hasDa := false + for _, action := range origin { + if action.Atype == plugintypes.ActionTypeDisruptive { + if action.Key != "block" { + hasDa = true + res = append(res, action) + } + } else { + res = append(res, action) + } + } + if !hasDa { + res = append(res, da) + } + return res +} + +// buildVariableString builds a pipe-separated variable string from crslang types +func (c *typeConverter) buildVariableString(vars []crstypes.Variable, cols []crstypes.Collection) string { + var parts []string + for _, v := range vars { + name := v.Name.String() + if v.Excluded { + name = "!" + name + } + parts = append(parts, name) + } + for _, col := range cols { + name := col.Name.String() + prefix := "" + if col.Count { + prefix = "&" + } + if len(col.Arguments) == 0 { + parts = append(parts, prefix+name) + } else { + for _, arg := range col.Arguments { + parts = append(parts, prefix+name+":"+arg) + } + } + for _, exc := range col.Excluded { + parts = append(parts, "!"+name+":"+exc) + } + } + return strings.Join(parts, "|") +} + +// addVariablesFromString parses a variable string and adds to a rule +// Used by update target directive +func (c *typeConverter) addVariablesFromString(rule *corazawaf.Rule, vars string) error { + // Parse the variable string character by character (same as RuleParser.ParseVariables) + // For simplicity, we split on | and process each + for _, part := range strings.Split(vars, "|") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + isNegation := false + isCount := false + + if strings.HasPrefix(part, "!") { + isNegation = true + part = part[1:] + } + if strings.HasPrefix(part, "&") { + isCount = true + part = part[1:] + } + + varName, key, _ := strings.Cut(part, ":") + + rv, err := variables.Parse(varName) + if err != nil { + return fmt.Errorf("unknown variable %q: %w", varName, err) + } + + if isNegation { + if err := rule.AddVariableNegation(rv, key); err != nil { + return err + } + } else { + if err := rule.AddVariable(rv, key, isCount); err != nil { + return err + } + } + } + return nil +} + +// getLastRuleExpectingChain finds the last rule in the WAF that expects a chain +func getLastRuleExpectingChain(w *corazawaf.WAF) *corazawaf.Rule { + rules := w.Rules.GetRules() + if len(rules) == 0 { + return nil + } + + lastRule := &rules[len(rules)-1] + parent := lastRule + for parent.Chain != nil { + parent = parent.Chain + } + if parent.HasChain && parent.Chain == nil { + return lastRule + } + + return nil +} + +func parseBoolean(s string) (bool, error) { + switch s { + case "on": + return true, nil + case "off": + return false, nil + default: + return false, fmt.Errorf("invalid boolean value %q, expected on/off", s) + } +} diff --git a/experimental/seclang/parser_v2_test.go b/experimental/seclang/parser_v2_test.go new file mode 100644 index 000000000..495196949 --- /dev/null +++ b/experimental/seclang/parser_v2_test.go @@ -0,0 +1,1143 @@ +// Copyright 2024 Juan Pablo Tosso and the OWASP Coraza contributors +// SPDX-License-Identifier: Apache-2.0 + +//go:build coraza.experimental.crslang_parser + +package seclang + +import ( + "os" + "path/filepath" + "testing" + "testing/fstest" + + "github.com/corazawaf/coraza/v3/internal/corazawaf" + "github.com/corazawaf/coraza/v3/types" +) + +func TestParser_FromString_RuleEngine(t *testing.T) { + tests := []struct { + name string + input string + expected types.RuleEngineStatus + }{ + { + name: "SecRuleEngine On", + input: "SecRuleEngine On", + expected: types.RuleEngineOn, + }, + { + name: "SecRuleEngine Off", + input: "SecRuleEngine Off", + expected: types.RuleEngineOff, + }, + { + name: "SecRuleEngine DetectionOnly", + input: "SecRuleEngine DetectionOnly", + expected: types.RuleEngineDetectionOnly, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + err := parser.FromString(tt.input) + if err != nil { + t.Fatalf("FromString() error = %v", err) + } + + if waf.RuleEngine != tt.expected { + t.Errorf("RuleEngine = %v, expected %v", waf.RuleEngine, tt.expected) + } + }) + } +} + +func TestParser_FromString_RequestBodyAccess(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + { + name: "SecRequestBodyAccess On", + input: "SecRequestBodyAccess On", + expected: true, + }, + { + name: "SecRequestBodyAccess Off", + input: "SecRequestBodyAccess Off", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + err := parser.FromString(tt.input) + if err != nil { + t.Fatalf("FromString() error = %v", err) + } + + if waf.RequestBodyAccess != tt.expected { + t.Errorf("RequestBodyAccess = %v, expected %v", waf.RequestBodyAccess, tt.expected) + } + }) + } +} + +func TestParser_FromString_ResponseBodyAccess(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + { + name: "SecResponseBodyAccess On", + input: "SecResponseBodyAccess On", + expected: true, + }, + { + name: "SecResponseBodyAccess Off", + input: "SecResponseBodyAccess Off", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + err := parser.FromString(tt.input) + if err != nil { + t.Fatalf("FromString() error = %v", err) + } + + if waf.ResponseBodyAccess != tt.expected { + t.Errorf("ResponseBodyAccess = %v, expected %v", waf.ResponseBodyAccess, tt.expected) + } + }) + } +} + +func TestParser_FromString_BodyLimits(t *testing.T) { + tests := []struct { + name string + input string + checkReq bool + checkRes bool + expected int64 + }{ + { + name: "SecRequestBodyLimit", + input: "SecRequestBodyLimit 131072", + checkReq: true, + expected: 131072, + }, + { + name: "SecResponseBodyLimit", + input: "SecResponseBodyLimit 524288", + checkRes: true, + expected: 524288, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + err := parser.FromString(tt.input) + if err != nil { + t.Fatalf("FromString() error = %v", err) + } + + if tt.checkReq && waf.RequestBodyLimit != tt.expected { + t.Errorf("RequestBodyLimit = %v, expected %v", waf.RequestBodyLimit, tt.expected) + } + if tt.checkRes && waf.ResponseBodyLimit != tt.expected { + t.Errorf("ResponseBodyLimit = %v, expected %v", waf.ResponseBodyLimit, tt.expected) + } + }) + } +} + +func TestParser_FromString_MultipleDirectives(t *testing.T) { + input := ` + SecRuleEngine On + SecRequestBodyAccess On + SecRequestBodyLimit 131072 + SecResponseBodyAccess Off + ` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + err := parser.FromString(input) + if err != nil { + t.Fatalf("FromString() error = %v", err) + } + + if waf.RuleEngine != types.RuleEngineOn { + t.Errorf("RuleEngine = %v, expected On", waf.RuleEngine) + } + if !waf.RequestBodyAccess { + t.Error("RequestBodyAccess should be true") + } + if waf.RequestBodyLimit != 131072 { + t.Errorf("RequestBodyLimit = %v, expected 131072", waf.RequestBodyLimit) + } + if waf.ResponseBodyAccess { + t.Error("ResponseBodyAccess should be false") + } +} + +func TestParser_FromString_Comments(t *testing.T) { + input := ` + # This is a comment + SecRuleEngine On + # Another comment + SecRequestBodyAccess On + ` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + err := parser.FromString(input) + if err != nil { + t.Fatalf("FromString() error = %v", err) + } + + if waf.RuleEngine != types.RuleEngineOn { + t.Errorf("RuleEngine = %v, expected On", waf.RuleEngine) + } +} + +func TestParser_FromString_SecRule(t *testing.T) { + input := `SecRule REQUEST_URI "@rx /admin" "id:1,phase:1,deny,status:403,msg:'Admin access denied'"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + err := parser.FromString(input) + if err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) == 0 { + t.Fatal("Expected at least one rule") + } + + rule := rules[0] + if rule.ID_ != 1 { + t.Errorf("Rule ID = %v, expected 1", rule.ID_) + } + if rule.Phase_ != 1 { + t.Errorf("Rule Phase = %v, expected 1", rule.Phase_) + } +} + +func TestParser_FromString_SecAction(t *testing.T) { + input := `SecAction "id:900000,phase:1,pass,nolog,setvar:tx.blocking_paranoia_level=1"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + err := parser.FromString(input) + if err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) == 0 { + t.Fatal("Expected at least one rule") + } + + rule := rules[0] + if rule.ID_ != 900000 { + t.Errorf("Rule ID = %v, expected 900000", rule.ID_) + } + if rule.Phase_ != 1 { + t.Errorf("Rule Phase = %v, expected 1", rule.Phase_) + } +} + +func TestParser_FromString_SecMarker(t *testing.T) { + // SecMarker in ANTLR grammar is stored as a DirectiveList Marker, + // not as a rule. It acts as a section boundary. + input := `SecMarker "END_HOST_CHECK"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + err := parser.FromString(input) + if err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) == 0 { + t.Fatal("Expected at least one rule") + } + + rule := rules[0] + if rule.SecMark_ != "END_HOST_CHECK" { + t.Errorf("SecMark = %q, expected END_HOST_CHECK", rule.SecMark_) + } +} + +func TestParser_FromString_SecComponentSignature(t *testing.T) { + input := `SecComponentSignature "OWASP_CRS/4.0.0"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + err := parser.FromString(input) + if err != nil { + t.Fatalf("FromString() error = %v", err) + } + + if len(waf.ComponentNames) == 0 { + t.Fatal("Expected at least one component name") + } + // The value may include quotes depending on how the parser handles it + found := false + for _, name := range waf.ComponentNames { + if name == "OWASP_CRS/4.0.0" || name == `"OWASP_CRS/4.0.0"` { + found = true + break + } + } + if !found { + t.Errorf("ComponentNames = %v, expected to contain OWASP_CRS/4.0.0", waf.ComponentNames) + } +} + +func TestParser_SyntaxErrors(t *testing.T) { + tests := []struct { + name string + input string + }{ + { + name: "Invalid directive", + input: "InvalidDirective blah", + }, + { + name: "Malformed input", + input: "SecRuleEngine", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + err := parser.FromString(tt.input) + if err == nil { + t.Error("FromString() expected error, got nil") + } + }) + } +} + +func TestParser_FromString_MultipleRules(t *testing.T) { + input := ` + SecRuleEngine On + SecRule REQUEST_METHOD "@rx ^POST$" "id:1,phase:1,pass,nolog" + SecRule REQUEST_URI "@contains /admin" "id:2,phase:1,deny,status:403" + ` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + err := parser.FromString(input) + if err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) != 2 { + t.Fatalf("Expected 2 rules, got %d", len(rules)) + } + + if rules[0].ID_ != 1 { + t.Errorf("First rule ID = %v, expected 1", rules[0].ID_) + } + if rules[1].ID_ != 2 { + t.Errorf("Second rule ID = %v, expected 2", rules[1].ID_) + } +} + +func TestParser_FromFile(t *testing.T) { + conf := `SecRuleEngine On +SecRule REQUEST_URI "@rx /admin" "id:1,phase:1,deny,status:403" +` + dir := t.TempDir() + filePath := filepath.Join(dir, "test.conf") + if err := os.WriteFile(filePath, []byte(conf), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromFile(filePath); err != nil { + t.Fatalf("FromFile() error = %v", err) + } + + if waf.RuleEngine != types.RuleEngineOn { + t.Errorf("RuleEngine = %v, expected On", waf.RuleEngine) + } + rules := waf.Rules.GetRules() + if len(rules) == 0 { + t.Fatal("Expected at least one rule") + } + if rules[0].ID_ != 1 { + t.Errorf("Rule ID = %v, expected 1", rules[0].ID_) + } +} + +func TestParser_FromFile_Glob(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"a.conf", "b.conf"} { + conf := `SecRuleEngine On` + "\n" + if err := os.WriteFile(filepath.Join(dir, name), []byte(conf), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + } + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromFile(filepath.Join(dir, "*.conf")); err != nil { + t.Fatalf("FromFile() glob error = %v", err) + } + // Both files set RuleEngine On — the parse should succeed + if waf.RuleEngine != types.RuleEngineOn { + t.Errorf("RuleEngine = %v, expected On", waf.RuleEngine) + } +} + +func TestParser_FromFile_NotFound(t *testing.T) { + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + err := parser.FromFile("/nonexistent/path/test.conf") + if err == nil { + t.Error("FromFile() expected error for missing file, got nil") + } +} + +func TestParser_SetRoot(t *testing.T) { + conf := `SecRuleEngine On +SecRule REQUEST_URI "@rx /test" "id:1,phase:1,deny" +` + memFS := fstest.MapFS{ + "rules.conf": &fstest.MapFile{Data: []byte(conf)}, + } + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + parser.SetRoot(memFS) + + if err := parser.FromFile("rules.conf"); err != nil { + t.Fatalf("FromFile() with SetRoot error = %v", err) + } + + if waf.RuleEngine != types.RuleEngineOn { + t.Errorf("RuleEngine = %v, expected On", waf.RuleEngine) + } + if waf.Rules.Count() != 1 { + t.Errorf("Expected 1 rule, got %d", waf.Rules.Count()) + } +} + +func TestParser_FromString_ChainRule(t *testing.T) { + input := `SecRule REQUEST_METHOD "@rx ^POST$" "id:100,phase:1,pass,nolog,chain" +SecRule REQUEST_URI "@contains /upload" "t:none"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) != 1 { + t.Fatalf("Expected 1 top-level rule (with chain), got %d", len(rules)) + } + if rules[0].ID_ != 100 { + t.Errorf("Rule ID = %v, expected 100", rules[0].ID_) + } + if rules[0].Chain == nil { + t.Error("Expected chain rule to be non-nil") + } +} + +func TestParser_FromString_SecDefaultAction(t *testing.T) { + input := `SecDefaultAction "phase:1,log,auditlog,pass" +SecRule REQUEST_URI "@rx /test" "id:200,phase:1,deny"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) == 0 { + t.Fatal("Expected at least one rule") + } + if rules[0].ID_ != 200 { + t.Errorf("Rule ID = %v, expected 200", rules[0].ID_) + } +} + +func TestParser_FromString_SecRuleRemoveById(t *testing.T) { + input := `SecRule REQUEST_URI "@rx /test" "id:300,phase:1,pass" +SecRuleRemoveById 300` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) != 0 { + t.Errorf("Expected 0 rules after removal, got %d", len(rules)) + } +} + +func TestParser_FromString_SecRuleRemoveByTag(t *testing.T) { + // Note: SecRuleRemoveByTag parsing is accepted by the grammar, but the + // crslang listener does not currently extract the tag value from the directive. + // The converter handles this gracefully (iterates over an empty Tags slice). + input := `SecRule REQUEST_URI "@rx /test" "id:310,phase:1,pass,tag:'removal-test'" +SecRuleRemoveByTag removal-test` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + err := parser.FromString(input) + if err != nil { + t.Fatalf("FromString() unexpected error = %v", err) + } +} + +func TestParser_FromString_SecRuleUpdateTargetById(t *testing.T) { + input := `SecRule REQUEST_URI "@rx /test" "id:400,phase:1,pass" +SecRuleUpdateTargetById 400 REQUEST_METHOD` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) == 0 { + t.Fatal("Expected at least one rule") + } +} + +func TestParser_FromString_SecRuleUpdateTargetById_NotFound(t *testing.T) { + // Updating a non-existent rule should log a warning and not fail + input := `SecRuleUpdateTargetById 9999 REQUEST_METHOD` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() unexpected error = %v", err) + } +} + +func TestParser_FromString_SecRuleUpdateActionById(t *testing.T) { + input := `SecRule REQUEST_URI "@rx /test" "id:500,phase:1,pass" +SecRuleUpdateActionById 500 "deny"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) == 0 { + t.Fatal("Expected at least one rule") + } +} + +func TestParser_FromString_SecRuleUpdateActionById_NotFound(t *testing.T) { + // Updating a non-existent rule should log a warning and not fail + input := `SecRuleUpdateActionById 9999 "deny"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() unexpected error = %v", err) + } +} + +func TestParser_FromString_RuleWithMetadata(t *testing.T) { + input := `SecRule REQUEST_URI "@rx /admin" "id:600,phase:1,deny,msg:'Admin blocked',severity:'CRITICAL',tag:'access-control',tag:'attack/admin'"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) == 0 { + t.Fatal("Expected at least one rule") + } + rule := rules[0] + if rule.ID_ != 600 { + t.Errorf("Rule ID = %v, expected 600", rule.ID_) + } + if rule.Msg == nil { + t.Error("Expected non-nil Msg") + } +} + +func TestParser_FromString_RuleWithTransformations(t *testing.T) { + input := `SecRule ARGS "@rx test" "id:700,phase:2,pass,t:none,t:lowercase"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) == 0 { + t.Fatal("Expected at least one rule") + } +} + +func TestParser_FromString_CollectionWithSelector(t *testing.T) { + input := `SecRule ARGS:username "@rx admin" "id:800,phase:2,deny"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) == 0 { + t.Fatal("Expected at least one rule") + } +} + +func TestParser_FromString_ExcludedVariable(t *testing.T) { + input := `SecRule REQUEST_HEADERS|!REQUEST_HEADERS:User-Agent "@rx test" "id:900,phase:1,pass"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) == 0 { + t.Fatal("Expected at least one rule") + } +} + +func TestParser_FromString_CountVariable(t *testing.T) { + input := `SecRule &ARGS "@eq 0" "id:1000,phase:2,pass"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) == 0 { + t.Fatal("Expected at least one rule") + } +} + +func TestParser_FromString_IgnoreRuleCompilationErrors(t *testing.T) { + // A rule with an invalid regex fails during operator compilation (after ANTLR parse succeeds). + // With IgnoreRuleCompilationErrors=true, the bad rule should be skipped silently. + input := `SecRule REQUEST_URI "@rx (" "id:1100,phase:1,pass"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + parser.state.IgnoreRuleCompilationErrors = true + + err := parser.FromString(input) + if err != nil { + t.Fatalf("FromString() with IgnoreRuleCompilationErrors error = %v", err) + } + + // The bad rule should be skipped, so no rules should be added + if waf.Rules.Count() != 0 { + t.Errorf("Expected 0 rules with IgnoreRuleCompilationErrors, got %d", waf.Rules.Count()) + } +} + +func TestParser_FromString_DefaultActionsPhase2(t *testing.T) { + // Rules at phase:2 use default hardcoded actions (log,auditlog,pass) + input := `SecRule REQUEST_URI "@rx /test" "id:1200,phase:2,pass"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) == 0 { + t.Fatal("Expected at least one rule") + } + if rules[0].Phase_ != 2 { + t.Errorf("Rule Phase = %v, expected 2", rules[0].Phase_) + } +} + +func TestParser_FromString_MultipleDefaultActionsPhases(t *testing.T) { + input := `SecDefaultAction "phase:1,log,auditlog,pass" +SecDefaultAction "phase:2,log,auditlog,pass" +SecRule REQUEST_URI "@rx /test" "id:1300,phase:1,deny" +SecRule ARGS "@rx attack" "id:1301,phase:2,deny"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) != 2 { + t.Fatalf("Expected 2 rules, got %d", len(rules)) + } +} + +func TestParser_FromString_SecRuleWithVersion(t *testing.T) { + input := `SecRule REQUEST_URI "@rx /test" "id:1400,phase:1,pass,nolog,ver:'OWASP_CRS/4.0.0'"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) == 0 { + t.Fatal("Expected at least one rule") + } +} + +func TestParser_FromString_SecActionWithSetvar(t *testing.T) { + input := `SecAction "id:1500,phase:1,pass,nolog,setvar:tx.paranoia_level=1"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) == 0 { + t.Fatal("Expected at least one rule") + } + if rules[0].ID_ != 1500 { + t.Errorf("Rule ID = %v, expected 1500", rules[0].ID_) + } +} + +func TestParser_FromString_SecActionChainToSecRule(t *testing.T) { + // SecAction with chain action followed by SecRule + input := `SecAction "id:1600,phase:1,pass,nolog,chain" +SecRule REQUEST_URI "@rx /test" "t:none"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) != 1 { + t.Fatalf("Expected 1 top-level rule (with chain), got %d", len(rules)) + } + if rules[0].ID_ != 1600 { + t.Errorf("Rule ID = %v, expected 1600", rules[0].ID_) + } + if rules[0].Chain == nil { + t.Error("Expected chain rule to be non-nil") + } +} + +func TestParser_FromString_SecRuleRemoveByIdRange(t *testing.T) { + input := `SecRule REQUEST_URI "@rx /a" "id:1700,phase:1,pass" +SecRule REQUEST_URI "@rx /b" "id:1701,phase:1,pass" +SecRule REQUEST_URI "@rx /c" "id:1800,phase:1,pass" +SecRuleRemoveById 1700-1701` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) != 1 { + t.Errorf("Expected 1 rule after range removal, got %d", len(rules)) + } + if len(rules) > 0 && rules[0].ID_ != 1800 { + t.Errorf("Remaining rule ID = %v, expected 1800", rules[0].ID_) + } +} + +func TestParser_FromString_SecRuleWithRevAndVer(t *testing.T) { + input := `SecRule REQUEST_URI "@rx /test" "id:1900,phase:1,pass,nolog,rev:'2',ver:'OWASP_CRS/4.0.0'"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) == 0 { + t.Fatal("Expected at least one rule") + } +} + +func TestParser_FromString_SecRuleUpdateTargetByIdCountVar(t *testing.T) { + input := `SecRule REQUEST_URI "@rx /test" "id:2000,phase:1,pass" +SecRuleUpdateTargetById 2000 &ARGS` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) == 0 { + t.Fatal("Expected at least one rule") + } +} + +func TestParser_FromString_SecRequestBodyAccessInvalid(t *testing.T) { + input := `SecRequestBodyAccess maybe` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + err := parser.FromString(input) + if err == nil { + t.Error("FromString() expected error for invalid boolean, got nil") + } +} + +func TestParser_FromFile_WithSubdirectory(t *testing.T) { + // Test FromFile resolving relative paths in subdirectories + dir := t.TempDir() + subDir := dir + "/sub" + if err := os.MkdirAll(subDir, 0o700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + + conf := `SecRuleEngine On +SecRule REQUEST_URI "@rx /test" "id:1,phase:1,deny" +` + filePath := subDir + "/test.conf" + if err := os.WriteFile(filePath, []byte(conf), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromFile(filePath); err != nil { + t.Fatalf("FromFile() error = %v", err) + } + + if waf.Rules.Count() != 1 { + t.Errorf("Expected 1 rule, got %d", waf.Rules.Count()) + } +} + +func TestParser_FromString_SecRuleUpdateActionByIdDisruptive(t *testing.T) { + // Test SecRuleUpdateActionById replacing a disruptive action + input := `SecRule REQUEST_URI "@rx /test" "id:2100,phase:1,pass" +SecRuleUpdateActionById 2100 "deny"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) == 0 { + t.Fatal("Expected at least one rule") + } +} + +func TestParser_FromString_SecRuleWithExcludedAndCountCollection(t *testing.T) { + // Test a rule with an excluded variable in a collection (builds variable string) + input := `SecRule REQUEST_HEADERS|!REQUEST_HEADERS:Content-Type "@rx test" "id:2200,phase:1,pass"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) == 0 { + t.Fatal("Expected at least one rule") + } +} + +func TestParser_FromString_SecRuleUpdateTargetByIdExcludedVar(t *testing.T) { + // Test SecRuleUpdateTargetById adding an excluded variable + input := `SecRule REQUEST_HEADERS "@rx test" "id:2300,phase:1,pass" +SecRuleUpdateTargetById 2300 !REQUEST_HEADERS:User-Agent` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) == 0 { + t.Fatal("Expected at least one rule") + } +} + +func TestParser_FromString_SecRuleNegatedOperator(t *testing.T) { + // Test a rule with a negated operator (!@rx) + input := `SecRule REQBODY_PROCESSOR "!@rx (?:URLENCODED|MULTIPART|XML|JSON)" "id:2400,phase:1,pass,nolog"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) == 0 { + t.Fatal("Expected at least one rule") + } +} + +func TestParser_FromString_SecRuleWithAllMetadata(t *testing.T) { + // Test a rule with all metadata fields including rev and ver + input := `SecRule REQUEST_URI "@rx /test" "id:2500,phase:1,deny,msg:'Test',severity:'CRITICAL',tag:'test',rev:'2',ver:'OWASP_CRS/4.0.0'"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) == 0 { + t.Fatal("Expected at least one rule") + } + if rules[0].ID_ != 2500 { + t.Errorf("Rule ID = %v, expected 2500", rules[0].ID_) + } +} + +func TestParser_FromString_SecRuleMultipleCollectionArgs(t *testing.T) { + // Test a rule with multiple collection arguments + input := `SecRule ARGS:foo|ARGS:bar "@rx attack" "id:2600,phase:2,deny"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) == 0 { + t.Fatal("Expected at least one rule") + } +} + +func TestParser_FromString_SecRuleUpdateTargetByIdBodyVar(t *testing.T) { + // Test SecRuleUpdateTargetById with a body variable + input := `SecRule REQUEST_URI "@rx /test" "id:2700,phase:1,pass" +SecRuleUpdateTargetById 2700 REQUEST_BODY` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) == 0 { + t.Fatal("Expected at least one rule") + } +} + +func TestParser_FromString_SecDefaultActionPhase2Override(t *testing.T) { + // Test that SecDefaultAction for phase:2 overrides the hardcoded defaults + // and exercises the mergeActions code path + input := `SecDefaultAction "phase:2,log,auditlog,deny" +SecRule ARGS "@rx attack" "id:2800,phase:2,pass"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) == 0 { + t.Fatal("Expected at least one rule") + } +} + +func TestParser_FromString_SecRuleInvalidRegex(t *testing.T) { + // Test that operator initialization errors are returned properly + // (invalid regex that passes ANTLR but fails during compilation) + input := `SecRule REQUEST_URI "@rx (" "id:2900,phase:1,pass"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + err := parser.FromString(input) + if err == nil { + t.Error("FromString() expected error for invalid regex, got nil") + } +} + +func TestParser_FromString_SecRuleWithMaturity(t *testing.T) { + // Test a rule with the maturity metadata field + input := `SecRule REQUEST_URI "@rx /test" "id:3000,phase:1,pass,maturity:'2'"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) == 0 { + t.Fatal("Expected at least one rule") + } +} + +func TestParser_FromString_DisabledRuleAction(t *testing.T) { + // Test that a disabled action causes an error unless IgnoreRuleCompilationErrors is set + input := `SecRule REQUEST_URI "@rx /test" "id:3100,phase:1,deny"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + parser.state.DisabledRuleActions = []string{"deny"} + + err := parser.FromString(input) + if err == nil { + t.Error("FromString() expected error for disabled action, got nil") + } +} + +func TestParser_FromString_DisabledRuleOperator(t *testing.T) { + // Test that a disabled operator causes an error + input := `SecRule REQUEST_URI "@rx /test" "id:3200,phase:1,pass"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + parser.state.DisabledRuleOperators = []string{"rx"} + + err := parser.FromString(input) + if err == nil { + t.Error("FromString() expected error for disabled operator, got nil") + } +} + +func TestParser_FromString_SecRuleChainToSecAction(t *testing.T) { + // Test SecRule chain → SecAction (exercises convertChainableDirective SecAction case) + input := `SecRule REQUEST_METHOD "@rx ^POST$" "id:3300,phase:1,pass,nolog,chain" +SecAction "setvar:tx.counter=+1"` + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + if err := parser.FromString(input); err != nil { + t.Fatalf("FromString() error = %v", err) + } + + rules := waf.Rules.GetRules() + if len(rules) != 1 { + t.Fatalf("Expected 1 top-level rule (with chain), got %d", len(rules)) + } + if rules[0].ID_ != 3300 { + t.Errorf("Rule ID = %v, expected 3300", rules[0].ID_) + } + if rules[0].Chain == nil { + t.Error("Expected chain rule to be non-nil") + } +} + +func TestParser_FromFile_RelativePath(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + conf := `SecRuleEngine On` + if err := os.WriteFile(filepath.Join(dir, "relative.conf"), []byte(conf), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + waf := corazawaf.NewWAF() + parser := NewParser(waf) + + // Use a relative path - this triggers osFS.Open's relative path branch + if err := parser.FromFile("relative.conf"); err != nil { + t.Fatalf("FromFile() relative path error = %v", err) + } + + if waf.RuleEngine != types.RuleEngineOn { + t.Errorf("RuleEngine = %v, expected On", waf.RuleEngine) + } +} diff --git a/go.mod b/go.mod index 5098dac9b..0447b473a 100644 --- a/go.mod +++ b/go.mod @@ -17,8 +17,11 @@ go 1.25.0 // - ocsf-schema-golang require ( + github.com/antlr4-go/antlr/v4 v4.13.1 github.com/anuraaga/go-modsecurity v0.0.0-20220824035035-b9a4099778df github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc + github.com/coreruleset/crslang v0.1.0 + github.com/coreruleset/seclang_parser v0.3.0 github.com/corazawaf/libinjection-go v0.3.2 github.com/foxcpp/go-mockdns v1.1.0 github.com/jcchavezs/mergefs v0.1.0 @@ -42,9 +45,10 @@ require ( github.com/kaptinlin/go-i18n v0.1.4 // indirect github.com/miekg/dns v1.1.57 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect + golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect golang.org/x/mod v0.33.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect diff --git a/go.sum b/go.sum index 0a7340c52..d10e53422 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,13 @@ +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/anuraaga/go-modsecurity v0.0.0-20220824035035-b9a4099778df h1:YWiVl53v0R8Knj/k+4slO0SXPL67Y4dXWiOIWNzrkew= github.com/anuraaga/go-modsecurity v0.0.0-20220824035035-b9a4099778df/go.mod h1:7jguE759ADzy2EkxGRXigiC0ER1Yq2IFk2qNtwgzc7U= github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc h1:OlJhrgI3I+FLUCTI3JJW8MoqyM78WbqJjecqMnqG+wc= github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc/go.mod h1:7rsocqNDkTCira5T0M7buoKR2ehh7YZiPkzxRuAgvVU= +github.com/coreruleset/crslang v0.1.0 h1:v+7BeCkKvwqgtlobkw3rdFXDOzmYTuSSndBBSqPl4Bg= +github.com/coreruleset/crslang v0.1.0/go.mod h1:ySZoCQ4iQGV6vcJMuYji0bmluKMGU+Pm4ttMiFliLWs= +github.com/coreruleset/seclang_parser v0.3.0 h1:VRtDGRHkdgeSwMYqwst3s/efx5faf3Panc0FKrPw/lg= +github.com/coreruleset/seclang_parser v0.3.0/go.mod h1:76tzWdJ918SPf5TFhoBdNLBYWv1Rgna/OaQP3Ui+4tc= github.com/corazawaf/libinjection-go v0.3.2 h1:9rrKt0lpg4WvUXt+lwS06GywfqRXXsa/7JcOw5cQLwI= github.com/corazawaf/libinjection-go v0.3.2/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -48,11 +54,15 @@ github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhso github.com/valllabh/ocsf-schema-golang v1.0.3 h1:eR8k/3jP/OOqB8LRCtdJ4U+vlgd/gk5y3KMXoodrsrw= github.com/valllabh/ocsf-schema-golang v1.0.3/go.mod h1:sZ3as9xqm1SSK5feFWIR2CuGeGRhsM7TR1MbpBctzPk= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= +go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=