Files
gosec/path_filter.go
T
Ravi Sastry Kadali 9f202122a7 feat: support path-based rule exclusions via exclude-rules (#1465)
* add path-based rule exclusions

Implements #1287

* Ssupport for excluding specific rules from specific paths, enabling large monorepos to apply different security rules to different components (e.g., CLI tools vs services).

* fix formatting issuue with path filter test to pass gci
2026-01-15 11:59:43 +01:00

214 lines
5.6 KiB
Go

package gosec
import (
"fmt"
"regexp"
"strings"
"github.com/securego/gosec/v2/issue"
)
// PathExcludeRule defines rules to exclude for specific file paths
type PathExcludeRule struct {
Path string `json:"path"` // Regex pattern for matching file paths
Rules []string `json:"rules"` // Rule IDs to exclude. Use "*" to exclude all rules
}
// compiledPathRule is a pre-compiled version of PathExcludeRule for efficient matching
type compiledPathRule struct {
pathRegex *regexp.Regexp
ruleSet map[string]bool // Set of rule IDs to exclude
excludeAll bool // True if "*" was specified in rules
original PathExcludeRule // Keep original for error messages
}
// PathExclusionFilter handles filtering of issues based on path and rule combinations
type PathExclusionFilter struct {
rules []compiledPathRule
}
// NewPathExclusionFilter creates a new filter from the provided exclusion rules.
// Returns an error if any path regex is invalid.
func NewPathExclusionFilter(rules []PathExcludeRule) (*PathExclusionFilter, error) {
if len(rules) == 0 {
return &PathExclusionFilter{rules: nil}, nil
}
compiled := make([]compiledPathRule, 0, len(rules))
for i, rule := range rules {
if rule.Path == "" {
return nil, fmt.Errorf("exclude-rules[%d]: path cannot be empty", i)
}
regex, err := regexp.Compile(rule.Path)
if err != nil {
return nil, fmt.Errorf("exclude-rules[%d]: invalid path regex %q: %w", i, rule.Path, err)
}
ruleSet := make(map[string]bool)
excludeAll := false
for _, ruleID := range rule.Rules {
ruleID = strings.TrimSpace(ruleID)
if ruleID == "*" {
excludeAll = true
} else if ruleID != "" {
ruleSet[ruleID] = true
}
}
compiled = append(compiled, compiledPathRule{
pathRegex: regex,
ruleSet: ruleSet,
excludeAll: excludeAll,
original: rule,
})
}
return &PathExclusionFilter{rules: compiled}, nil
}
// ShouldExclude returns true if the given issue should be excluded based on
// its file path and rule ID
func (f *PathExclusionFilter) ShouldExclude(filePath, ruleID string) bool {
if f == nil || len(f.rules) == 0 {
return false
}
// Normalize path separators for consistent matching
normalizedPath := strings.ReplaceAll(filePath, "\\", "/")
for _, rule := range f.rules {
if rule.pathRegex.MatchString(normalizedPath) {
if rule.excludeAll {
return true
}
if rule.ruleSet[ruleID] {
return true
}
}
}
return false
}
// FilterIssues applies path-based exclusions to a slice of issues.
// Returns the filtered issues and the count of excluded issues.
func (f *PathExclusionFilter) FilterIssues(issues []*issue.Issue) ([]*issue.Issue, int) {
if f == nil || len(f.rules) == 0 || len(issues) == 0 {
return issues, 0
}
filtered := make([]*issue.Issue, 0, len(issues))
excluded := 0
for _, iss := range issues {
if f.ShouldExclude(iss.File, iss.RuleID) {
excluded++
continue
}
filtered = append(filtered, iss)
}
return filtered, excluded
}
// ParseCLIExcludeRules parses the CLI format for exclude-rules.
// Format: "path:rule1,rule2;path2:rule3,rule4"
// Example: "cmd/.*:G204,G304;test/.*:G101"
func ParseCLIExcludeRules(input string) ([]PathExcludeRule, error) {
if input == "" {
return nil, nil
}
var rules []PathExcludeRule
// Split by semicolon for multiple rules
parts := strings.Split(input, ";")
for i, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// Split by colon to separate path and rules
colonIdx := strings.LastIndex(part, ":")
if colonIdx == -1 {
return nil, fmt.Errorf("exclude-rules part %d: missing ':' separator in %q", i+1, part)
}
pathPattern := strings.TrimSpace(part[:colonIdx])
rulesPart := strings.TrimSpace(part[colonIdx+1:])
if pathPattern == "" {
return nil, fmt.Errorf("exclude-rules part %d: path pattern cannot be empty", i+1)
}
if rulesPart == "" {
return nil, fmt.Errorf("exclude-rules part %d: rules list cannot be empty", i+1)
}
// Split rules by comma
ruleIDs := strings.Split(rulesPart, ",")
cleanedRules := make([]string, 0, len(ruleIDs))
for _, r := range ruleIDs {
r = strings.TrimSpace(r)
if r != "" {
cleanedRules = append(cleanedRules, r)
}
}
if len(cleanedRules) == 0 {
return nil, fmt.Errorf("exclude-rules part %d: no valid rules specified", i+1)
}
rules = append(rules, PathExcludeRule{
Path: pathPattern,
Rules: cleanedRules,
})
}
return rules, nil
}
// MergeExcludeRules combines exclude rules from multiple sources (config file + CLI).
// CLI rules take precedence and are processed first.
func MergeExcludeRules(configRules, cliRules []PathExcludeRule) []PathExcludeRule {
if len(cliRules) == 0 {
return configRules
}
if len(configRules) == 0 {
return cliRules
}
// CLI rules first, then config rules
merged := make([]PathExcludeRule, 0, len(cliRules)+len(configRules))
merged = append(merged, cliRules...)
merged = append(merged, configRules...)
return merged
}
// String returns a human-readable representation of the filter
func (f *PathExclusionFilter) String() string {
if f == nil || len(f.rules) == 0 {
return "PathExclusionFilter{empty}"
}
var parts []string
for _, rule := range f.rules {
if rule.excludeAll {
parts = append(parts, fmt.Sprintf("%s:*", rule.original.Path))
} else {
ruleIDs := make([]string, 0, len(rule.ruleSet))
for id := range rule.ruleSet {
ruleIDs = append(ruleIDs, id)
}
parts = append(parts, fmt.Sprintf("%s:[%s]", rule.original.Path, strings.Join(ruleIDs, ",")))
}
}
return fmt.Sprintf("PathExclusionFilter{%s}", strings.Join(parts, "; "))
}