Procházet zdrojové kódy

Define multiple allowlists per rule (#1496)

* feat: multiple rule allowlists

* feat(config): add allowlist 'condition'
Richard Gomez před 1 rokem
rodič
revize
aabe381539

+ 41 - 36
README.md

@@ -252,13 +252,6 @@ description = "awesome rule 1"
 # does not support lookaheads.
 regex = '''one-go-style-regex-for-this-rule'''
 
-# Golang regular expression used to match paths. This can be used as a standalone rule or it can be used
-# in conjunction with a valid `regex` entry.
-path = '''a-file-path-regex'''
-
-# Array of strings used for metadata and reporting purposes.
-tags = ["tag","another tag"]
-
 # Int used to extract secret from regex match and used as the group that will have
 # its entropy checked if `entropy` is set.
 secretGroup = 3
@@ -266,6 +259,10 @@ secretGroup = 3
 # Float representing the minimum shannon entropy a regex group must have to be considered a secret.
 entropy = 3.5
 
+# Golang regular expression used to match paths. This can be used as a standalone rule or it can be used
+# in conjunction with a valid `regex` entry.
+path = '''a-file-path-regex'''
+
 # Keywords are used for pre-regex check filtering. Rules that contain
 # keywords will perform a quick string compare check to make sure the
 # keyword(s) are in the content being scanned. Ideally these values should
@@ -277,29 +274,40 @@ keywords = [
   "token",
 ]
 
-# You can include an allowlist table for a single rule to reduce false positives or ignore commits
-# with known/rotated secrets
-[rules.allowlist]
-description = "ignore commit A"
-commits = [ "commit-A", "commit-B"]
-paths = [
-  '''go\.mod''',
-  '''go\.sum'''
-]
-# note: (rule) regexTarget defaults to check the _Secret_ in the finding.
-# if regexTarget is not specified then _Secret_ will be used.
-# Acceptable values for regexTarget are "match" and "line"
-regexTarget = "match"
-regexes = [
-  '''process''',
-  '''getenv''',
-]
-# note: stopwords targets the extracted secret, not the entire regex match
-# like 'regexes' does. (stopwords introduced in 8.8.0)
-stopwords = [
-  '''client''',
-  '''endpoint''',
-]
+# Array of strings used for metadata and reporting purposes.
+tags = ["tag","another tag"]
+
+    # ⚠️ In v8.21.0 `[rules.allowlist]` was replaced with `[[rules.allowlists]]`.
+    # This change was backwards-compatible: instances of `[rules.allowlist]` still  work.  
+    #
+    # You can define multiple allowlists for a rule to reduce false positives.
+    # A finding will be ignored if _ANY_ `[[rules.allowlists]]` matches.
+    [[rules.allowlists]]
+    description = "ignore commit A"
+    # When multiple criteria are defined the default condition is "OR".
+    # e.g., this can match on |commits| OR |paths| OR |stopwords|.
+    condition = "OR"
+    commits = [ "commit-A", "commit-B"]
+    paths = [
+      '''go\.mod''',
+      '''go\.sum'''
+    ]
+    # note: stopwords targets the extracted secret, not the entire regex match
+    # like 'regexes' does. (stopwords introduced in 8.8.0)
+    stopwords = [
+      '''client''',
+      '''endpoint''',
+    ]
+
+    [[rules.allowlists]]
+    # The "AND" condition can be used to make sure all criteria match.
+    # e.g., this matches if |regexes| AND |paths| are satisified.
+    condition = "AND"
+    # note: |regexes| defaults to check the _Secret_ in the finding.
+    # Acceptable values for |regexTarget| are "secret" (default), "match", and "line".
+    regexTarget = "match"
+    regexes = [ '''(?i)parseur[il]''' ]
+    paths = [ '''package-lock\.json''' ]
 
 # You can extend a particular rule from the default config. e.g., gitlab-pat
 # if you have defined a custom token prefix on your GitLab instance
@@ -307,11 +315,9 @@ stopwords = [
 id = "gitlab-pat"
 # all the other attributes from the default rule are inherited
 
-[rules.allowlist]
-regexTarget = "line"
-regexes = [
-    '''MY-glpat-''',
-]
+    [[rules.allowlists]]
+    regexTarget = "line"
+    regexes = [ '''MY-glpat-''' ]
 
 # This is a global allowlist which has a higher order of precedence than rule-specific allowlists.
 # If a commit listed in the `commits` field below is encountered then that commit will be skipped and no
@@ -328,7 +334,6 @@ paths = [
 # if regexTarget is not specified then _Secret_ will be used.
 # Acceptable values for regexTarget are "match" and "line"
 regexTarget = "match"
-
 regexes = [
   '''219-09-9999''',
   '''078-05-1120''',

+ 13 - 13
cmd/generate/config/rules/config.tmpl

@@ -43,23 +43,23 @@ keywords = [{{ range $j, $keyword := . }}"{{ $keyword }}"{{ end }}]{{end}}{{ end
 tags = [
     {{ range $j, $tag := . }}"{{ $tag }}",{{ end }}
 ]{{ end }}
-{{ if or $rule.Allowlist.Regexes $rule.Allowlist.Paths $rule.Allowlist.Commits $rule.Allowlist.StopWords }}
-[rules.allowlist]
-{{- with $rule.Allowlist.RegexTarget }}
-regexTarget = "{{ . }}"{{ end -}}
-{{- with $rule.Allowlist.Regexes }}
-regexes = [{{ range $i, $regex := . }}
-    '''{{ $regex }}''',{{ end }}
+{{ with $rule.Allowlists }}{{ range $i, $allowlist := . }}{{ if or $allowlist.Regexes $allowlist.Paths $allowlist.Commits $allowlist.StopWords }}
+[[rules.allowlists]]
+{{ with $allowlist.MatchCondition }}condition = "{{ . }}"
+{{ end -}}
+{{- with $allowlist.Commits }}commits = [
+    {{ range $j, $commit := . }}"{{ $commit }}",{{ end }}
 ]{{ end }}
-{{- with $rule.Allowlist.Paths }}paths = [
+{{- with $allowlist.Paths }}paths = [
     {{ range $j, $path := . }}'''{{ $path }}''',{{ end }}
 ]{{ end }}
-{{- with $rule.Allowlist.Commits }}commits = [
-    {{ range $j, $commit := . }}"{{ $commit }}",{{ end }}
+{{- with $allowlist.RegexTarget }}regexTarget = "{{ . }}"
+{{ end -}}
+{{- with $allowlist.Regexes }}regexes = [{{ range $i, $regex := . }}
+    '''{{ $regex }}''',{{ end }}
 ]{{ end }}
-{{- with $rule.Allowlist.StopWords }}
-stopwords = [{{ range $j, $stopword := . }}
+{{- with $allowlist.StopWords }}stopwords = [{{ range $j, $stopword := . }}
     "{{ $stopword }}",{{ end }}
-]{{ end }}
+]{{ end }}{{ end }}{{ end }}
 {{ end }}
 {{ end -}}

+ 4 - 2
cmd/generate/config/rules/generic.go

@@ -33,8 +33,10 @@ func GenericCredential() *config.Rule {
 			"access",
 		},
 		Entropy: 3.5,
-		Allowlist: config.Allowlist{
-			StopWords: DefaultStopWords,
+		Allowlists: []config.Allowlist{
+			{
+				StopWords: DefaultStopWords,
+			},
 		},
 	}
 

+ 6 - 4
cmd/generate/config/rules/hashicorp_vault.go

@@ -15,10 +15,12 @@ func VaultServiceToken() *config.Rule {
 		Regex:       utils.GenerateUniqueTokenRegex(`(?:hvs\.[\w-]{90,120}|s\.(?i:[a-z0-9]{24}))`, false),
 		Entropy:     3.5,
 		Keywords:    []string{"hvs", "s."},
-		Allowlist: config.Allowlist{
-			Regexes: []*regexp.Regexp{
-				// https://github.com/gitleaks/gitleaks/issues/1490#issuecomment-2334166357
-				regexp.MustCompile(`s\.[A-Za-z]{24}`),
+		Allowlists: []config.Allowlist{
+			{
+				Regexes: []*regexp.Regexp{
+					// https://github.com/gitleaks/gitleaks/issues/1490#issuecomment-2334166357
+					regexp.MustCompile(`s\.[A-Za-z]{24}`),
+				},
 			},
 		},
 	}

+ 9 - 7
cmd/generate/config/rules/kubernetes.go

@@ -31,13 +31,15 @@ func KubernetesSecret() *config.Rule {
 		},
 		// Kubernetes secrets are usually yaml files.
 		Path: regexp.MustCompile(`(?i)\.ya?ml$`),
-		Allowlist: config.Allowlist{
-			Regexes: []*regexp.Regexp{
-				// Ignore empty or placeholder values.
-				// variable: {{ .Values.Example }} (https://helm.sh/docs/chart_template_guide/variables/)
-				// variable: ""
-				// variable: ''
-				regexp.MustCompile(`[\w.-]+:(?:[ \t]*(?:\||>[-+]?)\s+)?[ \t]*(?:\{\{[ \t\w"|$:=,.-]+}}|""|'')`),
+		Allowlists: []config.Allowlist{
+			{
+				Regexes: []*regexp.Regexp{
+					// Ignore empty or placeholder values.
+					// variable: {{ .Values.Example }} (https://helm.sh/docs/chart_template_guide/variables/)
+					// variable: ""
+					// variable: ''
+					regexp.MustCompile(`[\w.-]+:(?:[ \t]*(?:\||>[-+]?)\s+)?[ \t]*(?:\{\{[ \t\w"|$:=,.-]+}}|""|'')`),
+				},
 			},
 		},
 	}

+ 9 - 7
cmd/generate/config/rules/nuget.go

@@ -16,13 +16,15 @@ func NugetConfigPassword() *config.Rule {
 		Path:        regexp.MustCompile(`(?i)nuget\.config$`),
 		Keywords:    []string{"<add key="},
 		Entropy:     1,
-		Allowlist: config.Allowlist{
-			Regexes: []*regexp.Regexp{
-				// samples from https://learn.microsoft.com/en-us/nuget/reference/nuget-config-file
-				regexp.MustCompile(`33f!!lloppa`),
-				regexp.MustCompile(`hal\+9ooo_da!sY`),
-				// exclude environment variables
-				regexp.MustCompile(`^\%\S.*\%$`),
+		Allowlists: []config.Allowlist{
+			{
+				Regexes: []*regexp.Regexp{
+					// samples from https://learn.microsoft.com/en-us/nuget/reference/nuget-config-file
+					regexp.MustCompile(`33f!!lloppa`),
+					regexp.MustCompile(`hal\+9ooo_da!sY`),
+					// exclude environment variables
+					regexp.MustCompile(`^\%\S.*\%$`),
+				},
 			},
 		},
 	}

+ 7 - 4
cmd/generate/config/rules/sumologic.go

@@ -21,10 +21,13 @@ func SumoLogicAccessID() *config.Rule {
 		Keywords: []string{
 			"sumo",
 		},
-		Allowlist: config.Allowlist{
-			RegexTarget: "line",
-			Regexes: []*regexp.Regexp{
-				regexp.MustCompile(`sumOf`),
+		Allowlists: []config.Allowlist{
+			{
+				RegexTarget: "line",
+				Regexes:     []*regexp.Regexp{regexp.MustCompile(`sumOf`)},
+			},
+			{
+				Paths: []*regexp.Regexp{regexp.MustCompile(`tests/.+$`)},
 			},
 		},
 	}

+ 31 - 8
config/allowlist.go

@@ -1,16 +1,33 @@
 package config
 
 import (
+	"fmt"
 	"regexp"
 	"strings"
 )
 
+type AllowlistMatchCondition string
+
+const (
+	AllowlistMatchOr  AllowlistMatchCondition = "OR"
+	AllowlistMatchAnd                         = "AND"
+)
+
 // Allowlist allows a rule to be ignored for specific
 // regexes, paths, and/or commits
 type Allowlist struct {
 	// Short human readable description of the allowlist.
 	Description string
 
+	// MatchCondition determines whether all criteria must match.
+	MatchCondition AllowlistMatchCondition
+
+	// Commits is a slice of commit SHAs that are allowed to be ignored. Defaults to "OR".
+	Commits []string
+
+	// Paths is a slice of path regular expressions that are allowed to be ignored.
+	Paths []*regexp.Regexp
+
 	// Regexes is slice of content regular expressions that are allowed to be ignored.
 	Regexes []*regexp.Regexp
 
@@ -23,12 +40,6 @@ type Allowlist struct {
 	// If RegexTarget is empty, it will be tested against the found secret.
 	RegexTarget string
 
-	// Paths is a slice of path regular expressions that are allowed to be ignored.
-	Paths []*regexp.Regexp
-
-	// Commits is a slice of commit SHAs that are allowed to be ignored.
-	Commits []string
-
 	// StopWords is a slice of stop words that are allowed to be ignored.
 	// This targets the _secret_, not the content of the regex match like the
 	// Regexes slice.
@@ -54,8 +65,8 @@ func (a *Allowlist) PathAllowed(path string) bool {
 }
 
 // RegexAllowed returns true if the regex is allowed to be ignored.
-func (a *Allowlist) RegexAllowed(s string) bool {
-	return anyRegexMatch(s, a.Regexes)
+func (a *Allowlist) RegexAllowed(secret string) bool {
+	return anyRegexMatch(secret, a.Regexes)
 }
 
 func (a *Allowlist) ContainsStopWord(s string) bool {
@@ -67,3 +78,15 @@ func (a *Allowlist) ContainsStopWord(s string) bool {
 	}
 	return false
 }
+
+func (a *Allowlist) Validate() error {
+	// Disallow empty allowlists.
+	if len(a.Commits) == 0 &&
+		len(a.Paths) == 0 &&
+		len(a.Regexes) == 0 &&
+		len(a.StopWords) == 0 {
+		return fmt.Errorf("[[rules.allowlists]] must contain at least one check for: commits, paths, regexes, or stopwords")
+	}
+
+	return nil
+}

+ 93 - 55
config/config.go

@@ -2,6 +2,7 @@ package config
 
 import (
 	_ "embed"
+	"fmt"
 	"regexp"
 	"sort"
 	"strings"
@@ -28,30 +29,36 @@ type ViperConfig struct {
 	Rules       []struct {
 		ID          string
 		Description string
-		Entropy     float64
-		SecretGroup int
 		Regex       string
+		SecretGroup int
+		Entropy     float64
 		Keywords    []string
 		Path        string
 		Tags        []string
 
-		Allowlist struct {
-			RegexTarget string
-			Regexes     []string
-			Paths       []string
-			Commits     []string
-			StopWords   []string
-		}
+		// Deprecated: this is a shim for backwards-compatibility. It should be removed in 9.x.
+		AllowList  *viperRuleAllowlist
+		Allowlists []viperRuleAllowlist
 	}
 	Allowlist struct {
+		Commits     []string
+		Paths       []string
 		RegexTarget string
 		Regexes     []string
-		Paths       []string
-		Commits     []string
 		StopWords   []string
 	}
 }
 
+type viperRuleAllowlist struct {
+	Description string
+	Condition   string
+	Commits     []string
+	Paths       []string
+	RegexTarget string
+	Regexes     []string
+	StopWords   []string
+}
+
 // Config is a configuration struct that contains rules and an allowlist if present.
 type Config struct {
 	Title       string
@@ -81,60 +88,93 @@ func (vc *ViperConfig) Translate() (Config, error) {
 		rulesMap     = make(map[string]Rule)
 	)
 
-	for _, r := range vc.Rules {
-		var allowlistRegexes []*regexp.Regexp
-		for _, a := range r.Allowlist.Regexes {
-			allowlistRegexes = append(allowlistRegexes, regexp.MustCompile(a))
-		}
-		var allowlistPaths []*regexp.Regexp
-		for _, a := range r.Allowlist.Paths {
-			allowlistPaths = append(allowlistPaths, regexp.MustCompile(a))
-		}
-
-		if r.Keywords == nil {
-			r.Keywords = []string{}
+	// Validate individual rules.
+	for _, vr := range vc.Rules {
+		if vr.Keywords == nil {
+			vr.Keywords = []string{}
 		} else {
-			for _, k := range r.Keywords {
+			for _, k := range vr.Keywords {
 				keywords[strings.ToLower(k)] = struct{}{}
 			}
 		}
 
-		if r.Tags == nil {
-			r.Tags = []string{}
+		if vr.Tags == nil {
+			vr.Tags = []string{}
 		}
 
 		var configRegex *regexp.Regexp
 		var configPathRegex *regexp.Regexp
-		if r.Regex == "" {
-			configRegex = nil
-		} else {
-			configRegex = regexp.MustCompile(r.Regex)
+		if vr.Regex != "" {
+			configRegex = regexp.MustCompile(vr.Regex)
 		}
-		if r.Path == "" {
-			configPathRegex = nil
-		} else {
-			configPathRegex = regexp.MustCompile(r.Path)
+		if vr.Path != "" {
+			configPathRegex = regexp.MustCompile(vr.Path)
 		}
-		r := Rule{
-			RuleID:      r.ID,
-			Description: r.Description,
+
+		rule := Rule{
+			RuleID:      vr.ID,
+			Description: vr.Description,
 			Regex:       configRegex,
+			SecretGroup: vr.SecretGroup,
+			Entropy:     vr.Entropy,
 			Path:        configPathRegex,
-			SecretGroup: r.SecretGroup,
-			Entropy:     r.Entropy,
-			Tags:        r.Tags,
-			Keywords:    r.Keywords,
-			Allowlist: Allowlist{
-				RegexTarget: r.Allowlist.RegexTarget,
-				Regexes:     allowlistRegexes,
-				Paths:       allowlistPaths,
-				Commits:     r.Allowlist.Commits,
-				StopWords:   r.Allowlist.StopWords,
-			},
+			Keywords:    vr.Keywords,
+			Tags:        vr.Tags,
 		}
+		// Parse the allowlist, including the older format for backwards compatibility.
+		if vr.AllowList != nil {
+			if len(vr.Allowlists) > 0 {
+				return Config{}, fmt.Errorf("%s: [rules.allowlist] is deprecated, it cannot be used alongside [[rules.allowlist]]", rule.RuleID)
+			}
+			vr.Allowlists = append(vr.Allowlists, *vr.AllowList)
+		}
+		for _, a := range vr.Allowlists {
+			var condition AllowlistMatchCondition
+			c := strings.ToUpper(a.Condition)
+			switch c {
+			case "AND", "&&":
+				condition = AllowlistMatchAnd
+			case "", "OR", "||":
+				condition = AllowlistMatchOr
+			default:
+				return Config{}, fmt.Errorf("%s: unknown allowlist condition '%s' (expected 'and', 'or')", rule.RuleID, c)
+			}
+
+			// Validate the target.
+			if a.RegexTarget != "" {
+				switch a.RegexTarget {
+				case "secret":
+					a.RegexTarget = ""
+				case "match", "line":
+					// do nothing
+				default:
+					return Config{}, fmt.Errorf("%s: unknown allowlist |regexTarget| '%s' (expected 'match', 'line')", rule.RuleID, a.RegexTarget)
+				}
+			}
+			var allowlistRegexes []*regexp.Regexp
+			for _, a := range a.Regexes {
+				allowlistRegexes = append(allowlistRegexes, regexp.MustCompile(a))
+			}
+			var allowlistPaths []*regexp.Regexp
+			for _, a := range a.Paths {
+				allowlistPaths = append(allowlistPaths, regexp.MustCompile(a))
+			}
 
-		orderedRules = append(orderedRules, r.RuleID)
-		rulesMap[r.RuleID] = r
+			allowlist := Allowlist{
+				MatchCondition: condition,
+				RegexTarget:    a.RegexTarget,
+				Regexes:        allowlistRegexes,
+				Paths:          allowlistPaths,
+				Commits:        a.Commits,
+				StopWords:      a.StopWords,
+			}
+			if err := allowlist.Validate(); err != nil {
+				return Config{}, fmt.Errorf("%s: %w", rule.RuleID, err)
+			}
+			rule.Allowlists = append(rule.Allowlists, allowlist)
+		}
+		orderedRules = append(orderedRules, rule.RuleID)
+		rulesMap[rule.RuleID] = rule
 	}
 	var allowlistRegexes []*regexp.Regexp
 	for _, a := range vc.Allowlist.Regexes {
@@ -269,11 +309,9 @@ func (c *Config) extend(extensionConfig Config) {
 			}
 			baseRule.Tags = append(baseRule.Tags, currentRule.Tags...)
 			baseRule.Keywords = append(baseRule.Keywords, currentRule.Keywords...)
-			baseRule.Allowlist.Commits = append(baseRule.Allowlist.Commits, currentRule.Allowlist.Commits...)
-			baseRule.Allowlist.Paths = append(baseRule.Allowlist.Paths, currentRule.Allowlist.Paths...)
-			baseRule.Allowlist.Regexes = append(baseRule.Allowlist.Regexes, currentRule.Allowlist.Regexes...)
-			baseRule.Allowlist.RegexTarget = currentRule.Allowlist.RegexTarget
-			baseRule.Allowlist.StopWords = append(baseRule.Allowlist.StopWords, currentRule.Allowlist.StopWords...)
+			for _, a := range currentRule.Allowlists {
+				baseRule.Allowlists = append(baseRule.Allowlists, a)
+			}
 			// The keywords from the base rule and the extended rule must be merged into the global keywords list
 			for _, k := range baseRule.Keywords {
 				c.Keywords[k] = struct{}{}

+ 107 - 38
config/config_test.go

@@ -24,18 +24,52 @@ func TestTranslate(t *testing.T) {
 		// Error to expect.
 		wantError error
 	}{
+		{
+			cfgName: "allowlist_old_compat",
+			cfg: Config{
+				Rules: map[string]Rule{"example": {
+					RuleID:   "example",
+					Regex:    regexp.MustCompile(`example\d+`),
+					Tags:     []string{},
+					Keywords: []string{},
+					Allowlists: []Allowlist{
+						{
+							MatchCondition: "OR",
+							Regexes:        []*regexp.Regexp{regexp.MustCompile("123")},
+						},
+					},
+				},
+				},
+			},
+		},
+		{
+			cfgName:   "allowlist_invalid_empty",
+			cfg:       Config{},
+			wantError: fmt.Errorf("example: [[rules.allowlists]] must contain at least one check for: commits, paths, regexes, or stopwords"),
+		},
+		{
+			cfgName:   "allowlist_invalid_old_and_new",
+			cfg:       Config{},
+			wantError: fmt.Errorf("example: [rules.allowlist] is deprecated, it cannot be used alongside [[rules.allowlist]]"),
+		},
+		{
+			cfgName:   "allowlist_invalid_regextarget",
+			cfg:       Config{},
+			wantError: fmt.Errorf("example: unknown allowlist |regexTarget| 'mtach' (expected 'match', 'line')"),
+		},
 		{
 			cfgName: "allow_aws_re",
 			cfg: Config{
 				Rules: map[string]Rule{"aws-access-key": {
+					RuleID:      "aws-access-key",
 					Description: "AWS Access Key",
 					Regex:       regexp.MustCompile("(?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}"),
-					Tags:        []string{"key", "AWS"},
 					Keywords:    []string{},
-					RuleID:      "aws-access-key",
-					Allowlist: Allowlist{
-						Regexes: []*regexp.Regexp{
-							regexp.MustCompile("AKIALALEMEL33243OLIA"),
+					Tags:        []string{"key", "AWS"},
+					Allowlists: []Allowlist{
+						{
+							MatchCondition: "OR",
+							Regexes:        []*regexp.Regexp{regexp.MustCompile("AKIALALEMEL33243OLIA")},
 						},
 					},
 				},
@@ -46,13 +80,16 @@ func TestTranslate(t *testing.T) {
 			cfgName: "allow_commit",
 			cfg: Config{
 				Rules: map[string]Rule{"aws-access-key": {
+					RuleID:      "aws-access-key",
 					Description: "AWS Access Key",
 					Regex:       regexp.MustCompile("(?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}"),
-					Tags:        []string{"key", "AWS"},
 					Keywords:    []string{},
-					RuleID:      "aws-access-key",
-					Allowlist: Allowlist{
-						Commits: []string{"allowthiscommit"},
+					Tags:        []string{"key", "AWS"},
+					Allowlists: []Allowlist{
+						{
+							MatchCondition: "OR",
+							Commits:        []string{"allowthiscommit"},
+						},
 					},
 				},
 				},
@@ -62,14 +99,15 @@ func TestTranslate(t *testing.T) {
 			cfgName: "allow_path",
 			cfg: Config{
 				Rules: map[string]Rule{"aws-access-key": {
+					RuleID:      "aws-access-key",
 					Description: "AWS Access Key",
 					Regex:       regexp.MustCompile("(?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}"),
-					Tags:        []string{"key", "AWS"},
 					Keywords:    []string{},
-					RuleID:      "aws-access-key",
-					Allowlist: Allowlist{
-						Paths: []*regexp.Regexp{
-							regexp.MustCompile(".go"),
+					Tags:        []string{"key", "AWS"},
+					Allowlists: []Allowlist{
+						{
+							MatchCondition: "OR",
+							Paths:          []*regexp.Regexp{regexp.MustCompile(".go")},
 						},
 					},
 				},
@@ -80,14 +118,13 @@ func TestTranslate(t *testing.T) {
 			cfgName: "entropy_group",
 			cfg: Config{
 				Rules: map[string]Rule{"discord-api-key": {
+					RuleID:      "discord-api-key",
 					Description: "Discord API key",
 					Regex:       regexp.MustCompile(`(?i)(discord[a-z0-9_ .\-,]{0,25})(=|>|:=|\|\|:|<=|=>|:).{0,5}['\"]([a-h0-9]{64})['\"]`),
-					RuleID:      "discord-api-key",
-					Allowlist:   Allowlist{},
+					Keywords:    []string{},
 					Entropy:     3.5,
 					SecretGroup: 3,
 					Tags:        []string{},
-					Keywords:    []string{},
 				},
 				},
 			},
@@ -112,49 +149,80 @@ func TestTranslate(t *testing.T) {
 			cfg: Config{
 				Rules: map[string]Rule{
 					"aws-access-key": {
+						RuleID:      "aws-access-key",
 						Description: "AWS Access Key",
 						Regex:       regexp.MustCompile("(?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}"),
-						Tags:        []string{"key", "AWS"},
 						Keywords:    []string{},
-						RuleID:      "aws-access-key",
+						Tags:        []string{"key", "AWS"},
 					},
 					"aws-secret-key": {
+						RuleID:      "aws-secret-key",
 						Description: "AWS Secret Key",
 						Regex:       regexp.MustCompile(`(?i)aws_(.{0,20})?=?.[\'\"0-9a-zA-Z\/+]{40}`),
-						Tags:        []string{"key", "AWS"},
 						Keywords:    []string{},
-						RuleID:      "aws-secret-key",
+						Tags:        []string{"key", "AWS"},
 					},
 					"aws-secret-key-again": {
+						RuleID:      "aws-secret-key-again",
 						Description: "AWS Secret Key",
 						Regex:       regexp.MustCompile(`(?i)aws_(.{0,20})?=?.[\'\"0-9a-zA-Z\/+]{40}`),
-						Tags:        []string{"key", "AWS"},
 						Keywords:    []string{},
-						RuleID:      "aws-secret-key-again",
+						Tags:        []string{"key", "AWS"},
 					},
 				},
 			},
 		},
 		{
-			cfgName: "extend_rule_allowlist",
+			cfgName: "extend_rule_allowlist_or",
 			cfg: Config{
 				Rules: map[string]Rule{
 					"aws-secret-key-again-again": {
 						RuleID:      "aws-secret-key-again-again",
 						Description: "AWS Secret Key",
 						Regex:       regexp.MustCompile(`(?i)aws_(.{0,20})?=?.[\'\"0-9a-zA-Z\/+]{40}`),
+						Keywords:    []string{},
 						Tags:        []string{"key", "AWS"},
+						Allowlists: []Allowlist{
+							{
+								MatchCondition: "OR",
+								StopWords:      []string{"fake"},
+							},
+							{
+								MatchCondition: "OR",
+								Commits:        []string{"abcdefg1"},
+								Paths:          []*regexp.Regexp{regexp.MustCompile(`ignore\.xaml`)},
+								Regexes:        []*regexp.Regexp{regexp.MustCompile(`foo.+bar`)},
+								RegexTarget:    "line",
+								StopWords:      []string{"example"},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			cfgName: "extend_rule_allowlist_and",
+			cfg: Config{
+				Rules: map[string]Rule{
+					"aws-secret-key-again-again": {
+						RuleID:      "aws-secret-key-again-again",
+						Description: "AWS Secret Key",
+						Regex:       regexp.MustCompile(`(?i)aws_(.{0,20})?=?.[\'\"0-9a-zA-Z\/+]{40}`),
 						Keywords:    []string{},
-						Allowlist: Allowlist{
-							Commits: []string{"abcdefg1"},
-							Regexes: []*regexp.Regexp{
-								regexp.MustCompile(`foo.+bar`),
+						Tags:        []string{"key", "AWS"},
+						Allowlists: []Allowlist{
+							{
+								MatchCondition: "OR",
+								StopWords:      []string{"fake"},
 							},
-							RegexTarget: "line",
-							Paths: []*regexp.Regexp{
-								regexp.MustCompile(`ignore\.xaml`),
+							{
+								MatchCondition: "AND",
+								Commits:        []string{"abcdefg1"},
+								Paths:          []*regexp.Regexp{regexp.MustCompile(`ignore\.xaml`)},
+								Regexes:        []*regexp.Regexp{regexp.MustCompile(`foo.+bar`)},
+								RegexTarget:    "line",
+								StopWords:      []string{"example"},
 							},
-							StopWords: []string{"example"},
 						},
 					},
 				},
@@ -168,11 +236,12 @@ func TestTranslate(t *testing.T) {
 						RuleID:      "aws-secret-key-again-again",
 						Description: "AWS Secret Key",
 						Regex:       regexp.MustCompile(`(?i)aws_(.{0,20})?=?.[\'\"0-9a-zA-Z\/+]{40}`),
-						Tags:        []string{"key", "AWS"},
 						Keywords:    []string{},
-						Allowlist: Allowlist{
-							Paths: []*regexp.Regexp{
-								regexp.MustCompile(`something.py`),
+						Tags:        []string{"key", "AWS"},
+						Allowlists: []Allowlist{
+							{
+								MatchCondition: "OR",
+								Paths:          []*regexp.Regexp{regexp.MustCompile(`something.py`)},
 							},
 						},
 					},
@@ -200,8 +269,8 @@ func TestTranslate(t *testing.T) {
 				Rules: map[string]Rule{"aws-access-key": {
 					RuleID:      "aws-access-key",
 					Description: "AWS Access Key",
-					Entropy:     999.0,
 					Regex:       regexp.MustCompile("(?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}"),
+					Entropy:     999.0,
 					Keywords:    []string{},
 					Tags:        []string{"key", "AWS"},
 				},
@@ -299,7 +368,7 @@ func TestTranslate(t *testing.T) {
 			err = viper.Unmarshal(&vc)
 			require.NoError(t, err)
 			cfg, err := vc.Translate()
-			if !assert.Equal(t, tt.wantError, err) {
+			if err != nil && !assert.EqualError(t, tt.wantError, err.Error()) {
 				return
 			}
 

+ 9 - 5
config/gitleaks.toml

@@ -481,7 +481,7 @@ keywords = [
     "access",
 ]
 
-[rules.allowlist]
+[[rules.allowlists]]
 stopwords = [
     "000000",
     "aaaaaa",
@@ -2169,7 +2169,7 @@ regex = '''(?i)(?:\bkind:[ \t]*["']?secret["']?(?:.|\s){0,200}?\bdata:(?:.|\s){0
 path = '''(?i)\.ya?ml$'''
 keywords = ["secret"]
 
-[rules.allowlist]
+[[rules.allowlists]]
 regexes = [
     '''[\w.-]+:(?:[ \t]*(?:\||>[-+]?)\s+)?[ \t]*(?:\{\{[ \t\w"|$:=,.-]+}}|""|'')''',
 ]
@@ -2355,7 +2355,7 @@ path = '''(?i)nuget\.config$'''
 entropy = 1
 keywords = ["<add key="]
 
-[rules.allowlist]
+[[rules.allowlists]]
 regexes = [
     '''33f!!lloppa''',
     '''hal\+9ooo_da!sY''',
@@ -2685,11 +2685,15 @@ regex = '''(?i:(?:sumo)(?:[0-9a-z\-_\t .]{0,20})(?:[\s|']|[\s|"]){0,3})(?:=|>|:{
 entropy = 3
 keywords = ["sumo"]
 
-[rules.allowlist]
+[[rules.allowlists]]
 regexTarget = "line"
 regexes = [
     '''sumOf''',
 ]
+[[rules.allowlists]]
+paths = [
+    '''tests/.+$''',
+]
 
 [[rules]]
 id = "sumologic-access-token"
@@ -2774,7 +2778,7 @@ keywords = [
     "s.",
 ]
 
-[rules.allowlist]
+[[rules.allowlists]]
 regexes = [
     '''s\.[A-Za-z]{24}''',
 ]

+ 2 - 3
config/rule.go

@@ -39,9 +39,8 @@ type Rule struct {
 	// keyword(s) are in the content being scanned.
 	Keywords []string
 
-	// Allowlist allows a rule to be ignored for specific
-	// regexes, paths, and/or commits
-	Allowlist Allowlist
+	// Allowlists allows a rule to be ignored for specific commits, paths, regexes, and/or stopwords.
+	Allowlists []Allowlist
 }
 
 // Validate guards against common misconfigurations.

+ 3 - 1
detect/baseline_test.go

@@ -5,6 +5,7 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 
 	"github.com/zricethezav/gitleaks/v8/report"
 )
@@ -128,7 +129,8 @@ func TestIgnoreIssuesInBaseline(t *testing.T) {
 	}
 
 	for _, test := range tests {
-		d, _ := NewDetectorDefaultConfig()
+		d, err := NewDetectorDefaultConfig()
+		require.NoError(t, err)
 		d.baseline = test.baseline
 		for _, finding := range test.findings {
 			d.addFinding(finding)

+ 124 - 23
detect/detect.go

@@ -14,7 +14,7 @@ import (
 
 	ahocorasick "github.com/BobuSumisu/aho-corasick"
 	"github.com/fatih/semgroup"
-
+	"github.com/rs/zerolog"
 	"github.com/rs/zerolog/log"
 	"github.com/spf13/viper"
 	"golang.org/x/exp/maps"
@@ -239,12 +239,55 @@ func (d *Detector) Detect(fragment Fragment) []report.Finding {
 
 // detectRule scans the given fragment for the given rule and returns a list of findings
 func (d *Detector) detectRule(fragment Fragment, currentRaw string, rule config.Rule, encodedSegments []EncodedSegment) []report.Finding {
-	var findings []report.Finding
+	var (
+		findings []report.Finding
+		logger   = func() zerolog.Logger {
+			l := log.With().Str("rule-id", rule.RuleID)
+			if fragment.CommitSHA != "" {
+				l = l.Str("commit", fragment.CommitSHA)
+			}
+			l = l.Str("path", fragment.FilePath)
+			return l.Logger()
+		}()
+	)
 
 	// check if filepath or commit is allowed for this rule
-	if rule.Allowlist.CommitAllowed(fragment.CommitSHA) ||
-		rule.Allowlist.PathAllowed(fragment.FilePath) {
-		return findings
+	for _, a := range rule.Allowlists {
+		var (
+			isAllowed     bool
+			commitAllowed = a.CommitAllowed(fragment.CommitSHA)
+			pathAllowed   = a.PathAllowed(fragment.FilePath)
+		)
+		if a.MatchCondition == config.AllowlistMatchAnd {
+			// Determine applicable checks.
+			var allowlistChecks []bool
+			if len(a.Commits) > 0 {
+				allowlistChecks = append(allowlistChecks, commitAllowed)
+			}
+			if len(a.Paths) > 0 {
+				allowlistChecks = append(allowlistChecks, pathAllowed)
+			}
+			// These will be checked later.
+			if len(a.Regexes) > 0 {
+				allowlistChecks = append(allowlistChecks, false)
+			}
+			if len(a.StopWords) > 0 {
+				allowlistChecks = append(allowlistChecks, false)
+			}
+
+			// Check if allowed.
+			isAllowed = allTrue(allowlistChecks)
+		} else {
+			isAllowed = commitAllowed || pathAllowed
+		}
+		if isAllowed {
+			logger.Trace().
+				Str("condition", string(a.MatchCondition)).
+				Bool("commit-allowed", commitAllowed).
+				Bool("path-allowed", commitAllowed).
+				Msg("Skipping fragment due to rule allowlist")
+			return findings
+		}
 	}
 
 	if rule.Path != nil && rule.Regex == nil && len(encodedSegments) == 0 {
@@ -285,6 +328,7 @@ func (d *Detector) detectRule(fragment Fragment, currentRaw string, rule config.
 
 	// use currentRaw instead of fragment.Raw since this represents the current
 	// decoding pass on the text
+MatchLoop:
 	for _, matchIndex := range rule.Regex.FindAllStringIndex(currentRaw, -1) {
 		// Extract secret from match
 		secret := strings.Trim(currentRaw[matchIndex[0]:matchIndex[1]], "\n")
@@ -333,8 +377,11 @@ func (d *Detector) detectRule(fragment Fragment, currentRaw string, rule config.
 			Line:        fragment.Raw[loc.startLineIndex:loc.endLineIndex],
 		}
 
-		if strings.Contains(fragment.Raw[loc.startLineIndex:loc.endLineIndex],
-			gitleaksAllowSignature) && !d.IgnoreGitleaksAllow {
+		if !d.IgnoreGitleaksAllow &&
+			strings.Contains(fragment.Raw[loc.startLineIndex:loc.endLineIndex], gitleaksAllowSignature) {
+			logger.Trace().
+				Str("finding", finding.Secret).
+				Msg("Skipping finding due to 'gitleaks:allow' signature")
 			continue
 		}
 
@@ -365,21 +412,8 @@ func (d *Detector) detectRule(fragment Fragment, currentRaw string, rule config.
 			}
 		}
 
-		// check if the secret is in the list of stopwords
-		if rule.Allowlist.ContainsStopWord(finding.Secret) ||
-			d.Config.Allowlist.ContainsStopWord(finding.Secret) {
-			continue
-		}
-
 		// check if the regexTarget is defined in the allowlist "regexes" entry
-		allowlistTarget := finding.Secret
-		switch rule.Allowlist.RegexTarget {
-		case "match":
-			allowlistTarget = finding.Match
-		case "line":
-			allowlistTarget = finding.Line
-		}
-
+		// or if the secret is in the list of stopwords
 		globalAllowlistTarget := finding.Secret
 		switch d.Config.Allowlist.RegexTarget {
 		case "match":
@@ -387,11 +421,67 @@ func (d *Detector) detectRule(fragment Fragment, currentRaw string, rule config.
 		case "line":
 			globalAllowlistTarget = finding.Line
 		}
-		if rule.Allowlist.RegexAllowed(allowlistTarget) ||
-			d.Config.Allowlist.RegexAllowed(globalAllowlistTarget) {
+		if d.Config.Allowlist.RegexAllowed(globalAllowlistTarget) {
+			logger.Trace().
+				Str("finding", globalAllowlistTarget).
+				Msg("Skipping finding due to global allowlist regex")
+			continue
+		} else if d.Config.Allowlist.ContainsStopWord(finding.Secret) {
+			logger.Trace().
+				Str("finding", finding.Secret).
+				Msg("Skipping finding due to global allowlist stopword")
 			continue
 		}
 
+		// check if the result matches any of the rule allowlists.
+		for _, a := range rule.Allowlists {
+			allowlistTarget := finding.Secret
+			switch a.RegexTarget {
+			case "match":
+				allowlistTarget = finding.Match
+			case "line":
+				allowlistTarget = finding.Line
+			}
+
+			var (
+				isAllowed        bool
+				regexAllowed     = a.RegexAllowed(allowlistTarget)
+				containsStopword = a.ContainsStopWord(finding.Secret)
+			)
+			// check if the secret is in the list of stopwords
+			if a.MatchCondition == config.AllowlistMatchAnd {
+				// Determine applicable checks.
+				var allowlistChecks []bool
+				if len(a.Commits) > 0 {
+					allowlistChecks = append(allowlistChecks, a.CommitAllowed(fragment.CommitSHA))
+				}
+				if len(a.Paths) > 0 {
+					allowlistChecks = append(allowlistChecks, a.PathAllowed(fragment.FilePath))
+				}
+				if len(a.Regexes) > 0 {
+					allowlistChecks = append(allowlistChecks, regexAllowed)
+				}
+				if len(a.StopWords) > 0 {
+					allowlistChecks = append(allowlistChecks, containsStopword)
+				}
+
+				// Check if allowed.
+				isAllowed = allTrue(allowlistChecks)
+			} else {
+				isAllowed = regexAllowed || containsStopword
+			}
+
+			if isAllowed {
+				logger.Trace().
+					Str("finding", finding.Secret).
+					Str("condition", string(a.MatchCondition)).
+					Bool("regex-allowed", regexAllowed).
+					Bool("contains-stopword", containsStopword).
+					Msg("Skipping finding due to rule allowlist")
+				continue MatchLoop
+			}
+		}
+
 		// check entropy
 		entropy := shannonEntropy(finding.Secret)
 		finding.Entropy = float32(entropy)
@@ -419,6 +509,17 @@ func (d *Detector) detectRule(fragment Fragment, currentRaw string, rule config.
 	return findings
 }
 
+func allTrue(bools []bool) bool {
+	allMatch := true
+	for _, check := range bools {
+		if !check {
+			allMatch = false
+			break
+		}
+	}
+	return allMatch
+}
+
 // addFinding synchronously adds a finding to the findings slice
 func (d *Detector) addFinding(finding report.Finding) {
 	globalFingerprint := fmt.Sprintf("%s:%s:%d", finding.File, finding.RuleID, finding.StartLine)

+ 253 - 29
detect/detect_test.go

@@ -2,8 +2,10 @@ package detect
 
 import (
 	"fmt"
+	"github.com/google/go-cmp/cmp"
 	"os"
 	"path/filepath"
+	"regexp"
 	"testing"
 
 	"github.com/spf13/viper"
@@ -60,7 +62,6 @@ func TestDetect(t *testing.T) {
 				Raw:      `awsToken := \"AKIALALEMEL33243OKIA\ // gitleaks:allow"`,
 				FilePath: "tmp.go",
 			},
-			expectedFindings: []report.Finding{},
 		},
 		{
 			cfgName: "simple",
@@ -72,7 +73,6 @@ func TestDetect(t *testing.T) {
 		        `,
 				FilePath: "tmp.go",
 			},
-			expectedFindings: []report.Finding{},
 		},
 		{
 			cfgName: "simple",
@@ -222,7 +222,6 @@ func TestDetect(t *testing.T) {
 				Raw:      `awsToken := \"AKIALALEMEL33243OLIA\"`,
 				FilePath: "tmp.go",
 			},
-			expectedFindings: []report.Finding{},
 		},
 		{
 			cfgName: "allow_path",
@@ -230,7 +229,6 @@ func TestDetect(t *testing.T) {
 				Raw:      `awsToken := \"AKIALALEMEL33243OLIA\"`,
 				FilePath: "tmp.go",
 			},
-			expectedFindings: []report.Finding{},
 		},
 		{
 			cfgName: "allow_commit",
@@ -239,7 +237,6 @@ func TestDetect(t *testing.T) {
 				FilePath:  "tmp.go",
 				CommitSHA: "allowthiscommit",
 			},
-			expectedFindings: []report.Finding{},
 		},
 		{
 			cfgName: "entropy_group",
@@ -270,7 +267,6 @@ func TestDetect(t *testing.T) {
 				Raw:      `const Discord_Public_Key = "e7322523fb86ed64c836a979cf8465fbd436378c653c1db38f9ae87bc62a6fd5"`,
 				FilePath: "tmp.go",
 			},
-			expectedFindings: []report.Finding{},
 		},
 		{
 			cfgName: "generic_with_py_path",
@@ -317,8 +313,7 @@ func TestDetect(t *testing.T) {
 				Raw:      `const Discord_Public_Key = "e7322523fb86ed64c836a979cf8465fbd436378c653c1db38f9ae87bc62a6fd5"`,
 				FilePath: "tmp.go",
 			},
-			expectedFindings: []report.Finding{},
-			wantError:        fmt.Errorf("discord-api-key: invalid regex secret group 5, max regex secret group 3"),
+			wantError: fmt.Errorf("discord-api-key: invalid regex secret group 5, max regex secret group 3"),
 		},
 		{
 			cfgName: "simple",
@@ -326,7 +321,6 @@ func TestDetect(t *testing.T) {
 				Raw:      `awsToken := \"AKIALALEMEL33243OLIA\"`,
 				FilePath: filepath.Join(configPath, "simple.toml"),
 			},
-			expectedFindings: []report.Finding{},
 		},
 		{
 			cfgName: "allow_global_aws_re",
@@ -334,7 +328,6 @@ func TestDetect(t *testing.T) {
 				Raw:      `awsToken := \"AKIALALEMEL33243OLIA\"`,
 				FilePath: "tmp.go",
 			},
-			expectedFindings: []report.Finding{},
 		},
 		{
 			cfgName: "generic_with_py_path",
@@ -342,7 +335,6 @@ func TestDetect(t *testing.T) {
 				Raw:      `const Discord_Public_Key = "load2523fb86ed64c836a979cf8465fbd436378c653c1db38f9ae87bc62a6fd5"`,
 				FilePath: "tmp.py",
 			},
-			expectedFindings: []report.Finding{},
 		},
 		{
 			cfgName:      "path_only",
@@ -351,7 +343,6 @@ func TestDetect(t *testing.T) {
 				Raw:      `const Discord_Public_Key = "e7322523fb86ed64c836a979cf8465fbd436378c653c1db38f9ae87bc62a6fd5"`,
 				FilePath: ".baseline.json",
 			},
-			expectedFindings: []report.Finding{},
 		},
 		{
 			cfgName: "base64_encoded",
@@ -477,25 +468,27 @@ func TestDetect(t *testing.T) {
 	}
 
 	for _, tt := range tests {
-		viper.Reset()
-		viper.AddConfigPath(configPath)
-		viper.SetConfigName(tt.cfgName)
-		viper.SetConfigType("toml")
-		err := viper.ReadInConfig()
-		require.NoError(t, err)
+		t.Run(fmt.Sprintf("%s - %s", tt.cfgName, tt.fragment.FilePath), func(t *testing.T) {
+			viper.Reset()
+			viper.AddConfigPath(configPath)
+			viper.SetConfigName(tt.cfgName)
+			viper.SetConfigType("toml")
+			err := viper.ReadInConfig()
+			require.NoError(t, err)
 
-		var vc config.ViperConfig
-		err = viper.Unmarshal(&vc)
-		require.NoError(t, err)
-		cfg, err := vc.Translate()
-		cfg.Path = filepath.Join(configPath, tt.cfgName+".toml")
-		assert.Equal(t, tt.wantError, err)
-		d := NewDetector(cfg)
-		d.MaxDecodeDepth = maxDecodeDepth
-		d.baselinePath = tt.baselinePath
+			var vc config.ViperConfig
+			err = viper.Unmarshal(&vc)
+			require.NoError(t, err)
+			cfg, err := vc.Translate()
+			cfg.Path = filepath.Join(configPath, tt.cfgName+".toml")
+			assert.Equal(t, tt.wantError, err)
+			d := NewDetector(cfg)
+			d.MaxDecodeDepth = maxDecodeDepth
+			d.baselinePath = tt.baselinePath
 
-		findings := d.Detect(tt.fragment)
-		assert.ElementsMatch(t, tt.expectedFindings, findings)
+			findings := d.Detect(tt.fragment)
+			assert.ElementsMatch(t, tt.expectedFindings, findings)
+		})
 	}
 }
 
@@ -855,6 +848,237 @@ func TestDetectWithSymlinks(t *testing.T) {
 	}
 }
 
+func TestDetectRuleAllowlist(t *testing.T) {
+	cases := map[string]struct {
+		fragment  Fragment
+		allowlist config.Allowlist
+		expected  []report.Finding
+	}{
+		// Commit / path
+		"commit allowed": {
+			fragment: Fragment{
+				CommitSHA: "41edf1f7f612199f401ccfc3144c2ebd0d7aeb48",
+			},
+			allowlist: config.Allowlist{
+				Commits: []string{"41edf1f7f612199f401ccfc3144c2ebd0d7aeb48"},
+			},
+		},
+		"path allowed": {
+			fragment: Fragment{
+				FilePath: "package-lock.json",
+			},
+			allowlist: config.Allowlist{
+				Paths: []*regexp.Regexp{regexp.MustCompile(`package-lock.json`)},
+			},
+		},
+		"commit AND path allowed": {
+			fragment: Fragment{
+				CommitSHA: "41edf1f7f612199f401ccfc3144c2ebd0d7aeb48",
+				FilePath:  "package-lock.json",
+			},
+			allowlist: config.Allowlist{
+				MatchCondition: config.AllowlistMatchAnd,
+				Commits:        []string{"41edf1f7f612199f401ccfc3144c2ebd0d7aeb48"},
+				Paths:          []*regexp.Regexp{regexp.MustCompile(`package-lock.json`)},
+			},
+		},
+		"commit AND path NOT allowed": {
+			fragment: Fragment{
+				CommitSHA: "41edf1f7f612199f401ccfc3144c2ebd0d7aeb48",
+				FilePath:  "package.json",
+			},
+			allowlist: config.Allowlist{
+				MatchCondition: config.AllowlistMatchAnd,
+				Commits:        []string{"41edf1f7f612199f401ccfc3144c2ebd0d7aeb48"},
+				Paths:          []*regexp.Regexp{regexp.MustCompile(`package-lock.json`)},
+			},
+			expected: []report.Finding{
+				{
+					StartColumn: 50,
+					EndColumn:   60,
+					Line:        "let username = 'james@mail.com';\nlet password = 'Summer2024!';",
+					Match:       "Summer2024!",
+					Secret:      "Summer2024!",
+					File:        "package.json",
+					Entropy:     3.095795154571533,
+					RuleID:      "test-rule",
+				},
+			},
+		},
+		"commit AND path NOT allowed - other conditions": {
+			fragment: Fragment{
+				CommitSHA: "41edf1f7f612199f401ccfc3144c2ebd0d7aeb48",
+				FilePath:  "package-lock.json",
+			},
+			allowlist: config.Allowlist{
+				MatchCondition: config.AllowlistMatchAnd,
+				Commits:        []string{"41edf1f7f612199f401ccfc3144c2ebd0d7aeb48"},
+				Paths:          []*regexp.Regexp{regexp.MustCompile(`package-lock.json`)},
+				Regexes:        []*regexp.Regexp{regexp.MustCompile("password")},
+			},
+			expected: []report.Finding{
+				{
+					StartColumn: 50,
+					EndColumn:   60,
+					Line:        "let username = 'james@mail.com';\nlet password = 'Summer2024!';",
+					Match:       "Summer2024!",
+					Secret:      "Summer2024!",
+					File:        "package-lock.json",
+					Entropy:     3.095795154571533,
+					RuleID:      "test-rule",
+				},
+			},
+		},
+		"commit OR path allowed": {
+			fragment: Fragment{
+				CommitSHA: "41edf1f7f612199f401ccfc3144c2ebd0d7aeb48",
+				FilePath:  "package-lock.json",
+			},
+			allowlist: config.Allowlist{
+				MatchCondition: config.AllowlistMatchOr,
+				Commits:        []string{"704178e7dca77ff143778a31cff0fc192d59b030"},
+				Paths:          []*regexp.Regexp{regexp.MustCompile(`package-lock.json`)},
+			},
+		},
+
+		// Regex / stopwords
+		"regex allowed": {
+			fragment: Fragment{},
+			allowlist: config.Allowlist{
+				Regexes: []*regexp.Regexp{regexp.MustCompile(`(?i)summer.+`)},
+			},
+		},
+		"stopwords allowed": {
+			fragment: Fragment{},
+			allowlist: config.Allowlist{
+				StopWords: []string{"summer"},
+			},
+		},
+		"regex AND stopword allowed": {
+			fragment: Fragment{},
+			allowlist: config.Allowlist{
+				MatchCondition: config.AllowlistMatchAnd,
+				Regexes:        []*regexp.Regexp{regexp.MustCompile(`(?i)summer.+`)},
+				StopWords:      []string{"2024"},
+			},
+		},
+		"regex AND stopword allowed - other conditions": {
+			fragment: Fragment{
+				CommitSHA: "41edf1f7f612199f401ccfc3144c2ebd0d7aeb48",
+				FilePath:  "config.js",
+			},
+			allowlist: config.Allowlist{
+				MatchCondition: config.AllowlistMatchAnd,
+				Commits:        []string{"41edf1f7f612199f401ccfc3144c2ebd0d7aeb48"},
+				Paths:          []*regexp.Regexp{regexp.MustCompile(`config.js`)},
+				Regexes:        []*regexp.Regexp{regexp.MustCompile(`(?i)summer.+`)},
+				StopWords:      []string{"2024"},
+			},
+		},
+		"regex AND stopword NOT allowed - non-git, other conditions": {
+			fragment: Fragment{
+				FilePath: "config.js",
+			},
+			allowlist: config.Allowlist{
+				MatchCondition: config.AllowlistMatchAnd,
+				Commits:        []string{"41edf1f7f612199f401ccfc3144c2ebd0d7aeb48"},
+				Paths:          []*regexp.Regexp{regexp.MustCompile(`config.js`)},
+				Regexes:        []*regexp.Regexp{regexp.MustCompile(`(?i)summer.+`)},
+				StopWords:      []string{"2024"},
+			},
+			expected: []report.Finding{
+				{
+					StartColumn: 50,
+					EndColumn:   60,
+					Line:        "let username = 'james@mail.com';\nlet password = 'Summer2024!';",
+					Match:       "Summer2024!",
+					Secret:      "Summer2024!",
+					File:        "config.js",
+					Entropy:     3.095795154571533,
+					RuleID:      "test-rule",
+				},
+			},
+		},
+		"regex AND stopword NOT allowed": {
+			fragment: Fragment{},
+			allowlist: config.Allowlist{
+				MatchCondition: config.AllowlistMatchAnd,
+				Regexes: []*regexp.Regexp{
+					regexp.MustCompile(`(?i)winter.+`),
+				},
+				StopWords: []string{"2024"},
+			},
+			expected: []report.Finding{
+				{
+					StartColumn: 50,
+					EndColumn:   60,
+					Line:        "let username = 'james@mail.com';\nlet password = 'Summer2024!';",
+					Match:       "Summer2024!",
+					Secret:      "Summer2024!",
+					Entropy:     3.095795154571533,
+					RuleID:      "test-rule",
+				},
+			},
+		},
+		"regex AND stopword NOT allowed - other conditions": {
+			fragment: Fragment{
+				CommitSHA: "a060c9d2d5e90c992763f1bd4c3cd2a6f121241b",
+				FilePath:  "config.js",
+			},
+			allowlist: config.Allowlist{
+				MatchCondition: config.AllowlistMatchAnd,
+				Commits:        []string{"41edf1f7f612199f401ccfc3144c2ebd0d7aeb48"},
+				Paths:          []*regexp.Regexp{regexp.MustCompile(`package-lock.json`)},
+				Regexes:        []*regexp.Regexp{regexp.MustCompile(`(?i)winter.+`)},
+				StopWords:      []string{"2024"},
+			},
+			expected: []report.Finding{
+				{
+					StartColumn: 50,
+					EndColumn:   60,
+					Line:        "let username = 'james@mail.com';\nlet password = 'Summer2024!';",
+					Match:       "Summer2024!",
+					Secret:      "Summer2024!",
+					File:        "config.js",
+					Entropy:     3.095795154571533,
+					RuleID:      "test-rule",
+				},
+			},
+		},
+		"regex OR stopword allowed": {
+			fragment: Fragment{},
+			allowlist: config.Allowlist{
+				MatchCondition: config.AllowlistMatchOr,
+				Regexes:        []*regexp.Regexp{regexp.MustCompile(`(?i)summer.+`)},
+				StopWords:      []string{"winter"},
+			},
+		},
+	}
+
+	raw := `let username = 'james@mail.com';
+let password = 'Summer2024!';`
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			rule := config.Rule{
+				RuleID: "test-rule",
+				Regex:  regexp.MustCompile(`Summer2024!`),
+				Allowlists: []config.Allowlist{
+					tc.allowlist,
+				},
+			}
+			d, err := NewDetectorDefaultConfig()
+			require.NoError(t, err)
+
+			f := tc.fragment
+			f.Raw = raw
+			actual := d.detectRule(f, raw, rule, []EncodedSegment{})
+			if diff := cmp.Diff(tc.expected, actual); diff != "" {
+				t.Errorf("diff: (-want +got)\n%s", diff)
+			}
+		})
+	}
+}
+
 func moveDotGit(t *testing.T, from, to string) {
 	t.Helper()
 

+ 1 - 1
testdata/config/allow_aws_re.toml

@@ -5,5 +5,5 @@ title = "simple config with allowlist for aws"
     id = "aws-access-key"
     regex = '''(?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}'''
     tags = ["key", "AWS"]
-    [rules.allowlist]
+    [[rules.allowlists]]
         regexes = ['''AKIALALEMEL33243OLIA''']

+ 1 - 1
testdata/config/allow_commit.toml

@@ -5,5 +5,5 @@ title = "simple config with allowlist for a specific commit"
     id = "aws-access-key"
     regex = '''(?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}'''
     tags = ["key", "AWS"]
-    [rules.allowlist]
+    [[rules.allowlists]]
         commits = ['''allowthiscommit''']

+ 1 - 1
testdata/config/allow_path.toml

@@ -5,5 +5,5 @@ title = "simple config with allowlist for .go files"
     id = "aws-access-key"
     regex = '''(?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}'''
     tags = ["key", "AWS"]
-    [rules.allowlist]
+    [[rules.allowlists]]
         paths = ['''.go''']

+ 6 - 0
testdata/config/allowlist_invalid_empty.toml

@@ -0,0 +1,6 @@
+title = "simple config with allowlist for aws"
+
+[[rules]]
+id = "example"
+regex = '''example\d+'''
+    [[rules.allowlists]]

+ 9 - 0
testdata/config/allowlist_invalid_old_and_new.toml

@@ -0,0 +1,9 @@
+title = "simple config with allowlist for aws"
+
+[[rules]]
+id = "example"
+regex = '''example\d+'''
+    [rules.allowlist]
+    regexes = ['''123''']
+    [[rules.allowlists]]
+    regexes = ['''456''']

+ 8 - 0
testdata/config/allowlist_invalid_regextarget.toml

@@ -0,0 +1,8 @@
+title = "simple config with allowlist for aws"
+
+[[rules]]
+id = "example"
+regex = '''example\d+'''
+    [[rules.allowlists]]
+    regexTarget = "mtach"
+    regexes = ['''456''']

+ 7 - 0
testdata/config/allowlist_old_compat.toml

@@ -0,0 +1,7 @@
+title = "simple config with allowlist for aws"
+
+[[rules]]
+    id = "example"
+    regex = '''example\d+'''
+    [rules.allowlist]
+        regexes = ['''123''']

+ 1 - 1
testdata/config/extend_empty_regexpath.toml

@@ -3,6 +3,6 @@ path="../testdata/config/extend_3.toml"
 
 [[rules]]
 id = "aws-secret-key-again-again"
-[rules.allowlist]
+[[rules.allowlists]]
 description = "False positive. Keys used for colors match the rule, and should be excluded."
 paths = ['''something.py''']

+ 14 - 0
testdata/config/extend_rule_allowlist_and.toml

@@ -0,0 +1,14 @@
+title = "gitleaks extended 3"
+
+[extend]
+path="../testdata/config/extend_rule_allowlist_base.toml"
+
+[[rules]]
+    id = "aws-secret-key-again-again"
+[[rules.allowlists]]
+    condition = "AND"
+    commits = ['''abcdefg1''']
+    regexes = ['''foo.+bar''']
+    regexTarget = "line"
+    paths = ['''ignore\.xaml''']
+    stopwords = ['''example''']

+ 11 - 0
testdata/config/extend_rule_allowlist_base.toml

@@ -0,0 +1,11 @@
+title = "gitleaks extended 3"
+
+## This should not be loaded since we can only extend configs to a depth of 3
+
+[[rules]]
+    id = "aws-secret-key-again-again"
+    description = "AWS Secret Key"
+    regex = '''(?i)aws_(.{0,20})?=?.[\'\"0-9a-zA-Z\/+]{40}'''
+    tags = ["key", "AWS"]
+[[rules.allowlists]]
+    stopwords = ["fake"]

+ 2 - 2
testdata/config/extend_rule_allowlist.toml → testdata/config/extend_rule_allowlist_or.toml

@@ -1,11 +1,11 @@
 title = "gitleaks extended 3"
 
 [extend]
-path="../testdata/config/extend_3.toml"
+path="../testdata/config/extend_rule_allowlist_base.toml"
 
 [[rules]]
     id = "aws-secret-key-again-again"
-[rules.allowlist]
+[[rules.allowlists]]
     commits = ['''abcdefg1''']
     regexes = ['''foo.+bar''']
     regexTarget = "line"