Fix: perf: reuse regex match results in detectRule() to avoid double regex execution

Fixed gitleaks/gitleaks#2121 — 1 line bug-fix.

The Bug

Repo: gitleaks/gitleaks Issue: #2121 Status: PR-submitted PR: https://github.com/gitleaks/gitleaks/pull/2163

Description: perf: reuse regex match results in detectRule() to avoid double regex execution

Fix scope: 1 line changed in detect/detect.go

Root Cause

The detectRule() function in detect/detect.go calls r.Regex.FindAllStringIndex(currentRaw, -1) twice for every rule evaluation. On line 442, the result is stored in the matches variable and checked for length:

matches := r.Regex.FindAllStringIndex(currentRaw, -1)
if len(matches) == 0 {
    return findings
}

Then, on line 452, the exact same call is repeated in the for range loop instead of reusing the already-computed matches:

for _, matchIndex := range r.Regex.FindAllStringIndex(currentRaw, -1) {

This means every FindAllStringIndex call performs a full scan of currentRaw against the rule’s compiled regex. For a repository with hundreds of rules and files containing thousands of lines, each file triggers this double scan for every rule evaluated. Go’s regexp.FindAllStringIndex must traverse the entire input string to find all match locations, so the cost scales linearly with input size.

Gitleaks evaluates every active rule against every fragment (file content or commit diff). In a typical scan of a mid-sized repository with 50+ rules, each rule’s regex is compiled once but executed once per fragment. The double execution therefore wastes 50+ full-text regex scans per file — a significant multiplier in real-world scans.

Code Analysis

The bug (lines 442 and 452 in detect/detect.go):

// FIRST call — stored in matches
matches := r.Regex.FindAllStringIndex(currentRaw, -1)
if len(matches) == 0 {
    return findings
}

// ... intervening code ...

// SECOND call — identical, redundant
for _, matchIndex := range r.Regex.FindAllStringIndex(currentRaw, -1) {

The fix (1 line change on line 452):

// BEFORE:
for _, matchIndex := range r.Regex.FindAllStringIndex(currentRaw, -1) {

// AFTER:
for _, matchIndex := range matches {

The variable matches is declared at line 442 and holds exactly the same [][]int slice returned by FindAllStringIndex. Reusing it eliminates the second full regex scan entirely. Since matches is only read (never mutated between lines 442 and 452), this change is trivially safe.

The Fix

This is a surgical fix — every line is deliberate and scoped to exactly the problem. The change replaces the second FindAllStringIndex invocation with the pre-computed matches variable. No other logic is touched.

The performance improvement is proportional to the number of rules and fragment size. For a scan of 10,000 files against 50 rules, this eliminates up to 500,000 unnecessary regex scans. On large monorepos or commit ranges, the savings can be measured in seconds to minutes of wall-clock time.

Pattern & Takeaways

Pattern: Premature range-over-function-call — calling a function to produce an iterable value inside a range expression when that value was already computed for a guard check. This pattern is easy to miss during initial development because the code reads naturally: “if no matches, return; for each match, process.” The duplication is invisible in isolation.

The fix demonstrates three principles:

  1. Reuse computed state — once you’ve computed a result, pass it through rather than recomputing.
  2. Look for duplicated expensive calls — any call to a non-trivial function that appears twice in close proximity is a candidate for refactoring.
  3. Surgical change minimizes risk — changing one identifier (r.Regex.FindAllStringIndex(currentRaw, -1)matches) has zero side effects.

Key insight for code review: When you see a guard condition followed by a loop over the same data, verify that the guard’s result is being reused. Common variants include: if len(x.Foo()) == 0 followed by for _, v := range x.Foo(), or if err != nil patterns where err is re-derived. The fix is always the same: save the result and reuse.

Transfer Potential

Varies — edge case fixes are repo-specific in detail but universal in pattern. The minimal-change principle and boundary-condition thinking transfer to any codebase. Reading this post helps recognize similar patterns in your own projects.


Auto-generated from PR #2121. View all patches on GitHub.

References

[1] gitleaks/gitleaks [2] #2121 [3] https://github.com/gitleaks/gitleaks/pull/2163