mirror of
https://github.com/securego/gosec.git
synced 2026-06-20 00:15:59 +02:00
* 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
214 lines
5.6 KiB
Go
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, "; "))
|
|
}
|