Repository: gitleaks/gitleaks (go)

Issue:
Paths and Fingerprints are platform specific and not portable
**Describe the bug**

Gitleaks outputs platform specific paths and Fingerprint identifiers leading to reproducibility issues as well rule / ignore duplication.

**To Reproduce**

Create a `.gitleaksignore` with the following content:

```txt
foo/bar/gitleaks-false-positive.yaml:aws-access-token:4
```

and a `.gitleaks.toml` with the following content:

```toml
title = "Gitleaks Sandbox Config"

[extend]
  useDefault = true

[allowlist]
  description = "Allowing dummy values in foo/allowlist to test things"
  paths = ['''(^|/)foo/allowlist/.*?$''']
```

Run gitleaks (Linux and/or macOS) on a project that has the following files with the following contents:

- `foo/allowlist/gitleaks-false-positive.yaml`
- `foo/bar/gitleaks-false-positive.yaml`

```yaml
aws_token: "AKIALALEMEL33243OLIA"
```

Using the invocation: `gitleaks dir --verbose --redact .` everything is fine:

```output
    ○
    │╲
    │ ○
    ○ ░
    ░    gitleaks

4:36PM INF scan completed in 4.74ms
4:36PM INF no leaks found
```

Run the same check on Windows (PowerShell or Git Bash doesn't matter):

```output
    ○
    │╲
    │ ○
    ○ ░
    ░    gitleaks

Finding:     aws_token: "REDACTED
Secret:      REDACTED
RuleID:      aws-access-token
Entropy:     3.084184
File:        foo\allowlist\gitleaks-false-positive.yaml
Line:        4
Fingerprint: foo\allowlist\gitleaks-false-positive.yaml:aws-access-token:4

Finding:     aws_token: "REDACTED
Secret:      REDACTED
RuleID:      aws-access-token
Entropy:     3.084184
File:        foo\bar\gitleaks-false-positive.yaml
Line:        4
Fingerprint: foo\bar\gitleaks-false-positive.yaml:aws-access-token:4
```

Results in unexpected findings that should have been ignored according to config and ignore file.

Changing the `.gitleaksignore` to the following

```txt
foo/bar/gitleaks-false-positive.yaml:aws-access-token:4
foo\bar\gitleaks-false-positive.yaml:aws-access-token:4
```

gets rid of one of the findings … as expected I guess? I assume the same is true for the config.

**Expected behavior**

A config/ignore file written for Unix works on Windows and vice versa.

I would expect to have a canonical (Unix?) style path declaration in the Gitleaks config and ignore file that then Gitleaks internally and silently translates into the host system's preferred style for comparison. I would also only expect a single canonical Fingerprint format without platform / OS specific directory separator tokens.

I do not expect to have to duplicate all rules / ignore statements to accommodate Windows and Unix.

From my perspective as a consumer this is unexpected behaviour and a defect that prevents/defeats cross-platform collaboration.

**Screenshots**

I created a GitHub Workflow to reproduce the issue, its results are available here:

- Linux https://github.com/Okeanos/gitleaks-sandbox/actions/runs/11331611969/job/31512023707
- Windows https://github.com/Okeanos/gitleaks-sandbox/actions/runs/11331611969/job/31512023427

**Basic Info (please complete the following information):**
 - OS: Linux, macOS, Windows
 - Gitleaks Version: 8.20.1

**Additional context**

To my surprise I couldn't find any related issue to this using any of the keywords I could think of:

- windows, paths, path, normalize, normalise, backslash, translate

Am I really the first to notice this or want this behaviour fixed? Please someone tell me I am just holding this wrong and there's in fact no problem.

cc @zricethezav

Pull request description:
Make paths and fingerprints platform-agnostic
### Description:
This fixes #1565.

~**Note:** the current 'fix' is technically a breaking change for `path`/`paths` regular expressions as mentioned [here](https://github.com/gitleaks/gitleaks/issues/1565#issuecomment-2411954236). I don't know if this is acceptable, or what the best way to handle this would be. (Test both styles of paths if `runtime.GOOS == 'Windows'`?)~

**TODO**

- [x] Normalize finding paths
- [x] Normalize fingerprint paths
- [x] Normalize gitleaksignore paths
- [x] Add tests
- [x] Test .gitleaksignore on Windows
- [x] Test `rule.path` and `rule.allowlist.paths` regexes on Windows 

### Checklist:

* [x] Does your PR pass tests?
* [ ] Have you written new tests for your changes?
* [x] Have you lint your code locally prior to submission?


Patch:
diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml
index da8fe85..dfe1d9e 100644
--- a/.github/workflows/gitleaks.yml
+++ b/.github/workflows/gitleaks.yml
@@ -4,6 +4,7 @@ jobs:
   scan:
     name: gitleaks
     runs-on: ubuntu-latest
+    if: ${{ github.repository == 'gitleaks/gitleaks' }}
     steps:
       - uses: actions/checkout@v3
         with:
diff --git a/detect/detect.go b/detect/detect.go
index e923a1d..f2990d9 100644
--- a/detect/detect.go
+++ b/detect/detect.go
@@ -5,6 +5,7 @@ import (
 	"context"
 	"fmt"
 	"os"
+	"runtime"
 	"strings"
 	"sync"
 	"sync/atomic"
@@ -26,7 +27,10 @@ const (
 	chunkSize              = 100 * 1_000 // 100kb
 )
 
-var newLineRegexp = regexp.MustCompile("\n")
+var (
+	newLineRegexp = regexp.MustCompile("\n")
+	isWindows     = runtime.GOOS == "windows"
+)
 
 // Detector is the main detector struct
 type Detector struct {
@@ -80,7 +84,7 @@ type Detector struct {
 	baselinePath string
 
 	// gitleaksIgnore
-	gitleaksIgnore map[string]bool
+	gitleaksIgnore map[string]struct{}
 
 	// Sema (https://github.com/fatih/semgroup) controls the concurrency
 	Sema *semgroup.Group
@@ -99,9 +103,13 @@ type Fragment struct {
 
 	Bytes []byte
 
-	// FilePath is the path to the file if applicable
+	// FilePath is the path to the file, if applicable.
+	// The path separator MUST be normalized to `/`.
 	FilePath    string
 	SymlinkFile string
+	// WindowsFilePath is the path with the original separator.
+	// This provides a backwards-compatible solution to https://github.com/gitleaks/gitleaks/issues/1565.
+	WindowsFilePath string `json:"-"` // TODO: remove this in v9.
 
 	// CommitSHA is the SHA of the commit if applicable
 	CommitSHA string
@@ -115,7 +123,7 @@ type Fragment struct {
 func NewDetector(cfg config.Config) *Detector {
 	return &Detector{
 		commitMap:      make(map[string]bool),
-		gitleaksIgnore: make(map[string]bool),
+		gitleaksIgnore: make(map[string]struct{}),
 		findingMutex:   &sync.Mutex{},
 		findings:       make([]report.Finding, 0),
 		Config:         cfg,
@@ -146,25 +154,41 @@ func NewDetectorDefaultConfig() (*Detector, error) {
 func (d *Detector) AddGitleaksIgnore(gitleaksIgnorePath string) error {
 	logging.Debug().Msgf("found .gitleaksignore file: %s", gitleaksIgnorePath)
 	file, err := os.Open(gitleaksIgnorePath)
-
 	if err != nil {
 		return err
 	}
-
-	// https://github.com/securego/gosec/issues/512
 	defer func() {
+		// https://github.com/securego/gosec/issues/512
 		if err := file.Close(); err != nil {
 			logging.Warn().Msgf("Error closing .gitleaksignore file: %s\n", err)
 		}
 	}()
-	scanner := bufio.NewScanner(file)
 
+	scanner := bufio.NewScanner(file)
+	replacer := strings.NewReplacer("\\", "/")
 	for scanner.Scan() {
 		line := strings.TrimSpace(scanner.Text())
 		// Skip lines that start with a comment
-		if line != "" && !strings.HasPrefix(line, "#") {
-			d.gitleaksIgnore[line] = true
+		if line == "" || strings.HasPrefix(line, "#") {
+			continue
 		}
+
+		// Normalize the path.
+		// TODO: Make this a breaking change in v9.
+		s := strings.Split(line, ":")
+		switch len(s) {
+		case 3:
+			// Global fingerprint.
+			// `file:rule-id:start-line`
+			s[0] = replacer.Replace(s[0])
+		case 4:
+			// Commit fingerprint.
+			// `commit:file:rule-id:start-line`
+			s[1] = replacer.Replace(s[1])
+		default:
+			logging.Warn().Str("fingerprint", line).Msg("Invalid .gitleaksignore entry")
+		}
+		d.gitleaksIgnore[strings.Join(s, ":")] = struct{}{}
 	}
 	return nil
 }
@@ -191,9 +215,13 @@ func (d *Detector) Detect(fragment Fragment) []report.Finding {
 	var findings []report.Finding
 
 	// check if filepath is allowed
-	if fragment.FilePath != "" && (d.Config.Allowlist.PathAllowed(fragment.FilePath) ||
-		fragment.FilePath == d.Config.Path || (d.baselinePath != "" && fragment.FilePath == d.baselinePath)) {
-		return findings
+	if fragment.FilePath != "" {
+		// is the path our config or baseline file?
+		if fragment.FilePath == d.Config.Path || (d.baselinePath != "" && fragment.FilePath == d.baselinePath) ||
+			// is the path excluded by the global allowlist?
+			(d.Config.Allowlist.PathAllowed(fragment.FilePath) || (fragment.WindowsFilePath != "" && d.Config.Allowlist.PathAllowed(fragment.WindowsFilePath))) {
+			return findings
+		}
 	}
 
 	// add newline indices for location calculation in detectRule
@@ -269,7 +297,7 @@ func (d *Detector) detectRule(fragment Fragment, currentRaw string, r config.Rul
 		var (
 			isAllowed             bool
 			commitAllowed, commit = a.CommitAllowed(fragment.CommitSHA)
-			pathAllowed           = a.PathAllowed(fragment.FilePath)
+			pathAllowed           = a.PathAllowed(fragment.FilePath) || (fragment.WindowsFilePath != "" && a.PathAllowed(fragment.WindowsFilePath))
 		)
 		if a.MatchCondition == config.AllowlistMatchAnd {
 			// Determine applicable checks.
@@ -306,25 +334,27 @@ func (d *Detector) detectRule(fragment Fragment, currentRaw string, r config.Rul
 		}
 	}
 
-	if r.Path != nil && r.Regex == nil && len(encodedSegments) == 0 {
-		// Path _only_ rule
-		if r.Path.MatchString(fragment.FilePath) {
-			finding := report.Finding{
-				Description: r.Description,
-				File:        fragment.FilePath,
-				SymlinkFile: fragment.SymlinkFile,
-				RuleID:      r.RuleID,
-				Match:       fmt.Sprintf("file detected: %s", fragment.FilePath),
-				Tags:        r.Tags,
+	if r.Path != nil {
+		if r.Regex == nil && len(encodedSegments) == 0 {
+			// Path _only_ rule
+			if r.Path.MatchString(fragment.FilePath) || (fragment.WindowsFilePath != "" && r.Path.MatchString(fragment.WindowsFilePath)) {
+				finding := report.Finding{
+					RuleID:      r.RuleID,
+					Description: r.Description,
+					File:        fragment.FilePath,
+					SymlinkFile: fragment.SymlinkFile,
+					Match:       fmt.Sprintf("file detected: %s", fragment.FilePath),
+					Tags:        r.Tags,
+				}
+				return append(findings, finding)
+			}
+		} else {
+			// if path is set _and_ a regex is set, then we need to check both
+			// so if the path does not match, then we should return early and not
+			// consider the regex
+			if !(r.Path.MatchString(fragment.FilePath) || (fragment.WindowsFilePath != "" && r.Path.MatchString(fragment.WindowsFilePath))) {
+				return findings
 			}
-			return append(findings, finding)
-		}
-	} else if r.Path != nil {
-		// if path is set _and_ a regex is set, then we need to check both
-		// so if the path does not match, then we should return early and not
-		// consider the regex
-		if !r.Path.MatchString(fragment.FilePath) {
-			return findings
 		}
 	}
 
@@ -382,22 +412,21 @@ MatchLoop:
 		}
 
 		finding := report.Finding{
-			Description: r.Description,
-			File:        fragment.FilePath,
-			SymlinkFile: fragment.SymlinkFile,
 			RuleID:      r.RuleID,
+			Description: r.Description,
 			StartLine:   loc.startLine,
 			EndLine:     loc.endLine,
 			StartColumn: loc.startColumn,
 			EndColumn:   loc.endColumn,
-			Secret:      secret,
+			Line:        fragment.Raw[loc.startLineIndex:loc.endLineIndex],
 			Match:       secret,
+			Secret:      secret,
+			File:        fragment.FilePath,
+			SymlinkFile: fragment.SymlinkFile,
 			Tags:        append(r.Tags, metaTags...),
-			Line:        fragment.Raw[loc.startLineIndex:loc.endLineIndex],
 		}
 
-		if !d.IgnoreGitleaksAllow &&
-			strings.Contains(fragment.Raw[loc.startLineIndex:loc.endLineIndex], gitleaksAllowSignature) {
+		if !d.IgnoreGitleaksAllow && strings.Contains(finding.Line, gitleaksAllowSignature) {
 			logger.Trace().
 				Str("finding", finding.Secret).
 				Msg("skipping finding: 'gitleaks:allow' signature")
@@ -487,7 +516,7 @@ MatchLoop:
 					allowlistChecks = append(allowlistChecks, commitAllowed)
 				}
 				if len(a.Paths) > 0 {
-					pathAllowed = a.PathAllowed(fragment.FilePath)
+					pathAllowed = a.PathAllowed(fragment.FilePath) || (fragment.WindowsFilePath != "" && a.PathAllowed(fragment.WindowsFilePath))
 					allowlistChecks = append(allowlistChecks, pathAllowed)
 				}
 				if len(a.Regexes) > 0 {
diff --git a/detect/directory.go b/detect/directory.go
index c691083..cd9d9f4 100644
--- a/detect/directory.go
+++ b/detect/directory.go
@@ -5,6 +5,7 @@ import (
 	"bytes"
 	"io"
 	"os"
+	"path/filepath"
 	"strings"
 
 	"github.com/h2non/filetype"
@@ -83,13 +84,20 @@ func (d *Detector) DetectFiles(paths <-chan sources.ScanTarget) ([]report.Findin
 					linesInChunk := strings.Count(chunk, "\n")
 					totalLines += linesInChunk
 					fragment := Fragment{
-						Raw:      chunk,
-						Bytes:    peekBuf.Bytes(),
-						FilePath: pa.Path,
+						Raw:   chunk,
+						Bytes: peekBuf.Bytes(),
 					}
 					if pa.Symlink != "" {
 						fragment.SymlinkFile = pa.Symlink
 					}
+					if isWindows {
+						fragment.FilePath = filepath.ToSlash(pa.Path)
+						fragment.SymlinkFile = filepath.ToSlash(fragment.SymlinkFile)
+						fragment.WindowsFilePath = pa.Path
+					} else {
+						fragment.FilePath = pa.Path
+					}
+
 					for _, finding := range d.Detect(fragment) {
 						// need to add 1 since line counting starts at 1
 						finding.StartLine += (totalLines - linesInChunk) + 1
diff --git a/sources/directory.go b/sources/directory.go
index 7500faa..ab34c79 100644
--- a/sources/directory.go
+++ b/sources/directory.go
@@ -4,6 +4,7 @@ import (
 	"io/fs"
 	"os"
 	"path/filepath"
+	"runtime"
 
 	"github.com/fatih/semgroup"
 
@@ -15,6 +16,8 @@ type ScanTarget struct {
 	Symlink string
 }
 
+var isWindows = runtime.GOOS == "windows"
+
 func DirectoryTargets(source string, s *semgroup.Group, followSymlinks bool, shouldSkip func(string) bool) (<-chan ScanTarget, error) {
 	paths := make(chan ScanTarget)
 	s.Go(func() error {
@@ -63,7 +66,10 @@ func DirectoryTargets(source string, s *semgroup.Group, followSymlinks bool, sho
 				}
 
 				// TODO: Also run this check against the resolved symlink?
-				skip := shouldSkip(path)
+				skip := shouldSkip(path) ||
+					// TODO: Remove this in v9.
+					// This is an awkward hack to mitigate https://github.com/gitleaks/gitleaks/issues/1641.
+					(isWindows && shouldSkip(filepath.ToSlash(path)))
 				if fInfo.IsDir() {
 					// Directory
 					if skip {


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 05303a8..a8e7a54 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -10,20 +10,28 @@ on:
 
 jobs:
   test:
-    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        platform: [ ubuntu-latest, windows-latest ]
+    runs-on: ${{ matrix.platform }}
     steps:
       - uses: actions/checkout@v3
 
       - name: Set up Go
         uses: actions/setup-go@v2
         with:
-          go-version: 1.22
+          go-version: 1.23
 
       - name: Build
         run: go build -v ./...
 
+      - name: Set up gotestsum
+        run: |
+          go install gotest.tools/gotestsum@latest
+
       - name: Test
-        run: make test
+        run: |
+          gotestsum --raw-command -- go test -json ./... --race
 
       - name: Validate Config
         run: go generate ./... && git diff --exit-code
diff --git a/detect/detect_test.go b/detect/detect_test.go
index 3587423..fa029ef 100644
--- a/detect/detect_test.go
+++ b/detect/detect_test.go
@@ -4,17 +4,20 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
+	"runtime"
 	"strings"
 	"testing"
 
 	"github.com/google/go-cmp/cmp"
-
+	"github.com/rs/zerolog"
 	"github.com/spf13/viper"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"golang.org/x/exp/maps"
 
 	"github.com/zricethezav/gitleaks/v8/cmd/scm"
 	"github.com/zricethezav/gitleaks/v8/config"
+	"github.com/zricethezav/gitleaks/v8/logging"
 	"github.com/zricethezav/gitleaks/v8/regexp"
 	"github.com/zricethezav/gitleaks/v8/report"
 	"github.com/zricethezav/gitleaks/v8/sources"
@@ -497,6 +500,12 @@ func TestDetect(t *testing.T) {
 
 // TestFromGit tests the FromGit function
 func TestFromGit(t *testing.T) {
+	// TODO: Fix this test on windows.
+	if runtime.GOOS == "windows" {
+		t.Skipf("TODO: this fails on Windows: [git] fatal: bad object refs/remotes/origin/main?")
+		return
+	}
+
 	tests := []struct {
 		cfgName          string
 		source           string
@@ -517,6 +526,7 @@ func TestFromGit(t *testing.T) {
 					Line:        "\n    awsToken := \"AKIALALEMEL33243OLIA\"",
 					Secret:      "AKIALALEMEL33243OLIA",
 					Match:       "AKIALALEMEL33243OLIA",
+					Entropy:     3.0841837,
 					File:        "main.go",
 					Date:        "2021-11-02T23:37:53Z",
 					Commit:      "1b6da43b82b22e4eaa10bcf8ee591e91abbfc587",
@@ -524,7 +534,6 @@ func TestFromGit(t *testing.T) {
 					Email:       "zricer@protonmail.com",
 					Message:     "Accidentally add a secret",
 					Tags:        []string{"key", "AWS"},
-					Entropy:     3.0841837,
 					Fingerprint: "1b6da43b82b22e4eaa10bcf8ee591e91abbfc587:main.go:aws-access-key:20",
 					Link:        "https://github.com/gitleaks/test/blob/1b6da43b82b22e4eaa10bcf8ee591e91abbfc587/main.go#L20",
 				},
@@ -585,7 +594,7 @@ func TestFromGit(t *testing.T) {
 	defer moveDotGit(t, ".git", "dotGit")
 
 	for _, tt := range tests {
-		t.Run(strings.Join([]string{tt.cfgName, tt.logOpts}, "/"), func(t *testing.T) {
+		t.Run(strings.Join([]string{tt.cfgName, tt.source, tt.logOpts}, "/"), func(t *testing.T) {
 			viper.AddConfigPath(configPath)
 			viper.SetConfigName("simple")
 			viper.SetConfigType("toml")
@@ -625,6 +634,7 @@ func TestFromGit(t *testing.T) {
 		})
 	}
 }
+
 func TestFromGitStaged(t *testing.T) {
 	tests := []struct {
 		cfgName          string
@@ -667,7 +677,6 @@ func TestFromGitStaged(t *testing.T) {
 
 	moveDotGit(t, "dotGit", ".git")
 	defer moveDotGit(t, ".git", "dotGit")
-
 	for _, tt := range tests {
 
 		viper.AddConfigPath(configPath)
@@ -709,17 +718,17 @@ func TestFromFiles(t *testing.T) {
 			cfgName: "simple",
 			expectedFindings: []report.Finding{
 				{
+					RuleID:      "aws-access-key",
 					Description: "AWS Access Key",
 					StartLine:   20,
 					EndLine:     20,
 					StartColumn: 16,
 					EndColumn:   35,
+					Line:        "\n\tawsToken := \"AKIALALEMEL33243OLIA\"",
 					Match:       "AKIALALEMEL33243OLIA",
 					Secret:      "AKIALALEMEL33243OLIA",
-					Line:        "\n\tawsToken := \"AKIALALEMEL33243OLIA\"",
 					File:        "../testdata/repos/nogit/main.go",
 					SymlinkFile: "",
-					RuleID:      "aws-access-key",
 					Tags:        []string{"key", "AWS"},
 					Entropy:     3.0841837,
 					Fingerprint: "../testdata/repos/nogit/main.go:aws-access-key:20",
@@ -731,16 +740,16 @@ func TestFromFiles(t *testing.T) {
 			cfgName: "simple",
 			expectedFindings: []report.Finding{
 				{
+					RuleID:      "aws-access-key",
 					Description: "AWS Access Key",
 					StartLine:   20,
 					EndLine:     20,
 					StartColumn: 16,
 					EndColumn:   35,
+					Line:        "\n\tawsToken := \"AKIALALEMEL33243OLIA\"",
 					Match:       "AKIALALEMEL33243OLIA",
 					Secret:      "AKIALALEMEL33243OLIA",
-					Line:        "\n\tawsToken := \"AKIALALEMEL33243OLIA\"",
 					File:        "../testdata/repos/nogit/main.go",
-					RuleID:      "aws-access-key",
 					Tags:        []string{"key", "AWS"},
 					Entropy:     3.0841837,
 					Fingerprint: "../testdata/repos/nogit/main.go:aws-access-key:20",
@@ -757,16 +766,16 @@ func TestFromFiles(t *testing.T) {
 			cfgName: "generic",
 			expectedFindings: []report.Finding{
 				{
+					RuleID:      "generic-api-key",
 					Description: "Generic API Key",
 					StartLine:   4,
 					EndLine:     4,
 					StartColumn: 5,
 					EndColumn:   35,
+					Line:        "\nDB_PASSWORD=8ae31cacf141669ddfb5da",
 					Match:       "PASSWORD=8ae31cacf141669ddfb5da",
 					Secret:      "8ae31cacf141669ddfb5da",
-					Line:        "\nDB_PASSWORD=8ae31cacf141669ddfb5da",
 					File:        "../testdata/repos/nogit/.env.prod",
-					RuleID:      "generic-api-key",
 					Tags:        []string{},
 					Entropy:     3.5383105,
 					Fingerprint: "../testdata/repos/nogit/.env.prod:generic-api-key:4",
@@ -776,39 +785,64 @@ func TestFromFiles(t *testing.T) {
 	}
 
 	for _, tt := range tests {
-		viper.AddConfigPath(configPath)
-		viper.SetConfigName(tt.cfgName)
-		viper.SetConfigType("toml")
-		err := viper.ReadInConfig()
-		require.NoError(t, err)
+		t.Run(tt.cfgName+" - "+tt.source, func(t *testing.T) {
+			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, _ := vc.Translate()
-		detector := NewDetector(cfg)
+			var vc config.ViperConfig
+			err = viper.Unmarshal(&vc)
+			require.NoError(t, err)
 
-		var ignorePath string
-		info, err := os.Stat(tt.source)
-		require.NoError(t, err)
+			cfg, _ := vc.Translate()
+			detector := NewDetector(cfg)
 
-		if info.IsDir() {
-			ignorePath = filepath.Join(tt.source, ".gitleaksignore")
-		} else {
-			ignorePath = filepath.Join(filepath.Dir(tt.source), ".gitleaksignore")
-		}
-		err = detector.AddGitleaksIgnore(ignorePath)
-		require.NoError(t, err)
-		detector.FollowSymlinks = true
-		paths, err := sources.DirectoryTargets(tt.source, detector.Sema, true, cfg.Allowlist.PathAllowed)
-		require.NoError(t, err)
-		findings, err := detector.DetectFiles(paths)
-		require.NoError(t, err)
-		assert.ElementsMatch(t, tt.expectedFindings, findings)
+			info, err := os.Stat(tt.source)
+			require.NoError(t, err)
+
+			var ignorePath string
+			if info.IsDir() {
+				ignorePath = filepath.Join(tt.source, ".gitleaksignore")
+			} else {
+				ignorePath = filepath.Join(filepath.Dir(tt.source), ".gitleaksignore")
+			}
+			err = detector.AddGitleaksIgnore(ignorePath)
+			require.NoError(t, err)
+
+			detector.FollowSymlinks = true
+			paths, err := sources.DirectoryTargets(tt.source, detector.Sema, true, cfg.Allowlist.PathAllowed)
+			require.NoError(t, err)
+
+			findings, err := detector.DetectFiles(paths)
+			require.NoError(t, err)
+
+			// TODO: Temporary mitigation.
+			// https://github.com/gitleaks/gitleaks/issues/1641
+			normalizedFindings := make([]report.Finding, len(findings))
+			for i, f := range findings {
+				if strings.HasSuffix(f.Line, "\r") {
+					f.Line = strings.ReplaceAll(f.Line, "\r", "")
+				}
+				if strings.HasSuffix(f.Match, "\r") {
+					f.EndColumn = f.EndColumn - 1
+					f.Match = strings.ReplaceAll(f.Match, "\r", "")
+				}
+				normalizedFindings[i] = f
+			}
+			assert.ElementsMatch(t, tt.expectedFindings, normalizedFindings)
+		})
 	}
 }
 
 func TestDetectWithSymlinks(t *testing.T) {
+	// TODO: Fix this test on windows.
+	if runtime.GOOS == "windows" {
+		t.Skipf("TODO: this returns no results on windows, I'm not sure why.")
+		return
+	}
+
 	tests := []struct {
 		cfgName          string
 		source           string
@@ -819,6 +853,7 @@ func TestDetectWithSymlinks(t *testing.T) {
 			cfgName: "simple",
 			expectedFindings: []report.Finding{
 				{
+					RuleID:      "apkey",
 					Description: "Asymmetric Private Key",
 					StartLine:   1,
 					EndLine:     1,
@@ -829,7 +864,6 @@ func TestDetectWithSymlinks(t *testing.T) {
 					Line:        "-----BEGIN OPENSSH PRIVATE KEY-----",
 					File:        "../testdata/repos/symlinks/source_file/id_ed25519",
 					SymlinkFile: "../testdata/repos/symlinks/file_symlink/symlinked_id_ed25519",
-					RuleID:      "apkey",
 					Tags:        []string{"key", "AsymmetricPrivateKey"},
 					Entropy:     3.587164,
 					Fingerprint: "../testdata/repos/symlinks/source_file/id_ed25519:apkey:1",
@@ -848,11 +882,13 @@ func TestDetectWithSymlinks(t *testing.T) {
 		var vc config.ViperConfig
 		err = viper.Unmarshal(&vc)
 		require.NoError(t, err)
+
 		cfg, _ := vc.Translate()
 		detector := NewDetector(cfg)
 		detector.FollowSymlinks = true
 		paths, err := sources.DirectoryTargets(tt.source, detector.Sema, true, cfg.Allowlist.PathAllowed)
 		require.NoError(t, err)
+
 		findings, err := detector.DetectFiles(paths)
 		require.NoError(t, err)
 		assert.ElementsMatch(t, tt.expectedFindings, findings)
@@ -1117,3 +1153,325 @@ func moveDotGit(t *testing.T, from, to string) {
 		require.NoError(t, err)
 	}
 }
+
+// region Windows-specific tests[]
+func TestNormalizeGitleaksIgnorePaths(t *testing.T) {
+	d, err := NewDetectorDefaultConfig()
+	require.NoError(t, err)
+
+	err = d.AddGitleaksIgnore("../testdata/gitleaksignore/.windowspaths")
+	require.NoError(t, err)
+
+	assert.Len(t, d.gitleaksIgnore, 3)
+	expected := map[string]struct{}{
+		"foo/bar/gitleaks-false-positive.yaml:aws-access-token:4":                                                 {},
+		"foo/bar/gitleaks-false-positive.yaml:aws-access-token:5":                                                 {},
+		"b55d88dc151f7022901cda41a03d43e0e508f2b7:test_data/test_local_repo_three_leaks.json:aws-access-token:73": {},
+	}
+	assert.ElementsMatch(t, maps.Keys(d.gitleaksIgnore), maps.Keys(expected))
+}
+
+func TestWindowsFileSeparator_RulePath(t *testing.T) {
+	logging.Logger = logging.Logger.Level(zerolog.TraceLevel)
+	unixRule := config.Rule{
+		RuleID: "test-rule",
+		Path:   regexp.MustCompile(`(^|/)\.m2/settings\.xml`),
+	}
+	windowsRule := config.Rule{
+		RuleID: "test-rule",
+		Path:   regexp.MustCompile(`(^|\\)\.m2\\settings\.xml`),
+	}
+	expected := []report.Finding{
+		{
+			RuleID: "test-rule",
+			Match:  "file detected: .m2/settings.xml",
+			File:   ".m2/settings.xml",
+		},
+	}
+	tests := map[string]struct {
+		fragment Fragment
+		rule     config.Rule
+		expected []report.Finding
+	}{
+		// unix rule
+		"unix rule - unix path separator": {
+			fragment: Fragment{
+				FilePath: `.m2/settings.xml`,
+			},
+			rule:     unixRule,
+			expected: expected,
+		},
+		"unix rule - windows path separator": {
+			fragment: Fragment{
+				FilePath:        `.m2/settings.xml`,
+				WindowsFilePath: `.m2\settings.xml`,
+			},
+			rule:     unixRule,
+			expected: expected,
+		},
+		"unix regex+path rule - windows path separator": {
+			fragment: Fragment{
+				Raw:      `<password>s3cr3t</password>`,
+				FilePath: `.m2/settings.xml`,
+			},
+			rule: config.Rule{
+				RuleID: "test-rule",
+				Regex:  regexp.MustCompile(`<password>(.+?)</password>`),
+				Path:   regexp.MustCompile(`(^|/)\.m2/settings\.xml`),
+			},
+			expected: []report.Finding{
+				{
+					RuleID:      "test-rule",
+					StartColumn: 1,
+					EndColumn:   27,
+					Line:        "<password>s3cr3t</password>",
+					Match:       "<password>s3cr3t</password>",
+					Secret:      "s3cr3t",
+					Entropy:     2.251629114151001,
+					File:        ".m2/settings.xml",
+				},
+			},
+		},
+
+		// windows rule
+		"windows rule - unix path separator": {
+			fragment: Fragment{
+				FilePath: `.m2/settings.xml`,
+			},
+			rule: windowsRule,
+			// This never worked, and continues not to work.
+			// Paths should be normalized to use Unix file separators.
+			expected: nil,
+		},
+		"windows rule - windows path separator": {
+			fragment: Fragment{
+				FilePath:        `.m2/settings.xml`,
+				WindowsFilePath: `.m2\settings.xml`,
+			},
+			rule:     windowsRule,
+			expected: expected,
+		},
+		"windows regex+path rule - windows path separator": {
+			fragment: Fragment{
+				Raw:             `<password>s3cr3t</password>`,
+				FilePath:        `.m2/settings.xml`,
+				WindowsFilePath: `.m2\settings.xml`,
+			},
+			rule: config.Rule{
+				RuleID: "test-rule",
+				Regex:  regexp.MustCompile(`<password>(.+?)</password>`),
+				Path:   regexp.MustCompile(`(^|\\)\.m2\\settings\.xml`),
+			},
+			expected: []report.Finding{
+				{
+					RuleID:      "test-rule",
+					StartColumn: 1,
+					EndColumn:   27,
+					Line:        "<password>s3cr3t</password>",
+					Match:       "<password>s3cr3t</password>",
+					Secret:      "s3cr3t",
+					Entropy:     2.251629114151001,
+					File:        ".m2/settings.xml",
+				},
+			}},
+	}
+
+	d, err := NewDetectorDefaultConfig()
+	require.NoError(t, err)
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			actual := d.detectRule(test.fragment, test.fragment.Raw, test.rule, []EncodedSegment{})
+			if diff := cmp.Diff(test.expected, actual); diff != "" {
+				t.Errorf("diff: (-want +got)\n%s", diff)
+			}
+		})
+	}
+}
+
+func TestWindowsFileSeparator_RuleAllowlistPaths(t *testing.T) {
+	tests := map[string]struct {
+		fragment Fragment
+		rule     config.Rule
+		expected []report.Finding
+	}{
+		// unix
+		"unix path separator - unix rule - OR allowlist path-only": {
+			fragment: Fragment{
+				Raw:      `value: "s3cr3t"`,
+				FilePath: `ignoreme/unix.txt`,
+			},
+			rule: config.Rule{
+				RuleID: "unix-rule",
+				Regex:  regexp.MustCompile(`s3cr3t`),
+				Allowlists: []config.Allowlist{
+					{
+						Paths: []*regexp.Regexp{regexp.MustCompile(`(^|/)ignoreme(/.*)?$`)},
+					},
+				},
+			},
+			expected: nil,
+		},
+		"unix path separator - windows rule - OR allowlist path-only": {
+			fragment: Fragment{
+				Raw:      `value: "s3cr3t"`,
+				FilePath: `ignoreme/unix.txt`,
+			},
+			rule: config.Rule{
+				RuleID: "windows-rule",
+				Regex:  regexp.MustCompile(`s3cr3t`),
+				Allowlists: []config.Allowlist{
+					{
+						Paths: []*regexp.Regexp{regexp.MustCompile(`(^|\\)ignoreme(\\.*)?$`)},
+					},
+				},
+			},
+			// Windows separators in regex don't work for unix.
+			expected: []report.Finding{
+				{
+					RuleID:      "windows-rule",
+					StartColumn: 9,
+					EndColumn:   14,
+					Line:        `value: "s3cr3t"`,
+					Match:       `s3cr3t`,
+					Secret:      `s3cr3t`,
+					File:        "ignoreme/unix.txt",
+					Entropy:     2.251629114151001,
+				},
+			},
+		},
+		"unix path separator - unix rule - AND allowlist path+stopwords": {
+			fragment: Fragment{
+				Raw:      `value: "f4k3s3cr3t"`,
+				FilePath: `ignoreme/unix.txt`,
+			},
+			rule: config.Rule{
+				RuleID: "unix-rule",
+				Regex:  regexp.MustCompile(`value: "[^"]+"`),
+				Allowlists: []config.Allowlist{
+					{
+						MatchCondition: config.AllowlistMatchAnd,
+						Paths:          []*regexp.Regexp{regexp.MustCompile(`(^|/)ignoreme(/.*)?$`)},
+						StopWords:      []string{"f4k3"},
+					},
+				},
+			},
+			expected: nil,
+		},
+		"unix path separator - windows rule - AND allowlist path+stopwords": {
+			fragment: Fragment{
+				Raw:      `value: "f4k3s3cr3t"`,
+				FilePath: `ignoreme/unix.txt`,
+			},
+			rule: config.Rule{
+				RuleID: "windows-rule",
+				Regex:  regexp.MustCompile(`value: "[^"]+"`),
+				Allowlists: []config.Allowlist{
+					{
+						MatchCondition: config.AllowlistMatchAnd,
+						Paths:          []*regexp.Regexp{regexp.MustCompile(`(^|\\)ignoreme(\\.*)?$`)},
+						StopWords:      []string{"f4k3"},
+					},
+				},
+			},
+			expected: []report.Finding{
+				{
+					RuleID:      "windows-rule",
+					StartColumn: 1,
+					EndColumn:   19,
+					Line:        `value: "f4k3s3cr3t"`,
+					Match:       `value: "f4k3s3cr3t"`,
+					Secret:      `value: "f4k3s3cr3t"`,
+					File:        "ignoreme/unix.txt",
+					Entropy:     3.892407178878784,
+				},
+			},
+		},
+
+		// windows
+		"windows path separator - unix rule - OR allowlist path-only": {
+			fragment: Fragment{
+				Raw:             `value: "s3cr3t"`,
+				FilePath:        `ignoreme/windows.txt`,
+				WindowsFilePath: `ignoreme\windows.txt`,
+			},
+			rule: config.Rule{
+				RuleID: "unix-rule",
+				Regex:  regexp.MustCompile(`s3cr3t`),
+				Allowlists: []config.Allowlist{
+					{
+						Paths: []*regexp.Regexp{regexp.MustCompile(`(^|/)ignoreme(/.*)?$`)},
+					},
+				},
+			},
+			expected: nil,
+		},
+		"windows path separator - windows rule - OR allowlist path-only": {
+			fragment: Fragment{
+				Raw:             `value: "s3cr3t"`,
+				FilePath:        `ignoreme/windows.txt`,
+				WindowsFilePath: `ignoreme\windows.txt`,
+			},
+			rule: config.Rule{
+				RuleID: "windows-rule",
+				Regex:  regexp.MustCompile(`s3cr3t`),
+				Allowlists: []config.Allowlist{
+					{
+						Paths: []*regexp.Regexp{regexp.MustCompile(`(^|\\)ignoreme(\\.*)?$`)},
+					},
+				},
+			},
+			expected: nil,
+		},
+		"windows path separator - unix rule - AND allowlist path+stopwords": {
+			fragment: Fragment{
+				Raw:             `value: "f4k3s3cr3t"`,
+				FilePath:        `ignoreme/unix.txt`,
+				WindowsFilePath: `ignoreme\windows.txt`,
+			},
+			rule: config.Rule{
+				RuleID: "unix-rule",
+				Regex:  regexp.MustCompile(`value: "[^"]+"`),
+				Allowlists: []config.Allowlist{
+					{
+						MatchCondition: config.AllowlistMatchAnd,
+						Paths:          []*regexp.Regexp{regexp.MustCompile(`(^|/)ignoreme(/.*)?$`)},
+						StopWords:      []string{"f4k3"},
+					},
+				},
+			},
+			expected: nil,
+		},
+		"windows path separator - windows rule - AND allowlist path+stopwords": {
+			fragment: Fragment{
+				Raw:             `value: "f4k3s3cr3t"`,
+				FilePath:        `ignoreme/unix.txt`,
+				WindowsFilePath: `ignoreme\windows.txt`,
+			},
+			rule: config.Rule{
+				RuleID: "windows-rule",
+				Regex:  regexp.MustCompile(`value: "[^"]+"`),
+				Allowlists: []config.Allowlist{
+					{
+						MatchCondition: config.AllowlistMatchAnd,
+						Paths:          []*regexp.Regexp{regexp.MustCompile(`(^|\\)ignoreme(\\.*)?$`)},
+						StopWords:      []string{"f4k3"},
+					},
+				},
+			},
+			expected: nil,
+		},
+	}
+
+	d, err := NewDetectorDefaultConfig()
+	require.NoError(t, err)
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			actual := d.detectRule(test.fragment, test.fragment.Raw, test.rule, []EncodedSegment{})
+			if diff := cmp.Diff(test.expected, actual); diff != "" {
+				t.Errorf("diff: (-want +got)\n%s", diff)
+			}
+		})
+	}
+}
+
+//endregion
diff --git a/report/csv_test.go b/report/csv_test.go
index 9ad0322..2a7c3b3 100644
--- a/report/csv_test.go
+++ b/report/csv_test.go
@@ -53,6 +53,7 @@ func TestWriteCSV(t *testing.T) {
 		t.Run(test.testReportName, func(t *testing.T) {
 			tmpfile, err := os.Create(filepath.Join(t.TempDir(), test.testReportName+".csv"))
 			require.NoError(t, err)
+			defer tmpfile.Close()
 
 			err = reporter.Write(tmpfile, test.findings)
 			require.NoError(t, err)
@@ -67,7 +68,10 @@ func TestWriteCSV(t *testing.T) {
 
 			want, err := os.ReadFile(test.expected)
 			require.NoError(t, err)
-			assert.Equal(t, string(want), string(got))
+
+			wantStr := lineEndingReplacer.Replace(string(want))
+			gotStr := lineEndingReplacer.Replace(string(got))
+			assert.Equal(t, wantStr, gotStr)
 		})
 	}
 }
diff --git a/report/json_test.go b/report/json_test.go
index 863b204..9c45905 100644
--- a/report/json_test.go
+++ b/report/json_test.go
@@ -53,18 +53,25 @@ func TestWriteJSON(t *testing.T) {
 		t.Run(test.testReportName, func(t *testing.T) {
 			tmpfile, err := os.Create(filepath.Join(t.TempDir(), test.testReportName+".json"))
 			require.NoError(t, err)
+			defer tmpfile.Close()
+
 			err = reporter.Write(tmpfile, test.findings)
 			require.NoError(t, err)
 			assert.FileExists(t, tmpfile.Name())
+
 			got, err := os.ReadFile(tmpfile.Name())
 			require.NoError(t, err)
 			if test.wantEmpty {
 				assert.Empty(t, got)
 				return
 			}
+
 			want, err := os.ReadFile(test.expected)
 			require.NoError(t, err)
-			assert.Equal(t, string(want), string(got))
+
+			wantStr := lineEndingReplacer.Replace(string(want))
+			gotStr := lineEndingReplacer.Replace(string(got))
+			assert.Equal(t, wantStr, gotStr)
 		})
 	}
 }
diff --git a/report/junit_test.go b/report/junit_test.go
index 96e724b..44555a5 100644
--- a/report/junit_test.go
+++ b/report/junit_test.go
@@ -69,6 +69,7 @@ func TestWriteJunit(t *testing.T) {
 	for _, test := range tests {
 		tmpfile, err := os.Create(filepath.Join(t.TempDir(), test.testReportName+".xml"))
 		require.NoError(t, err)
+		defer tmpfile.Close()
 
 		err = reporter.Write(tmpfile, test.findings)
 		require.NoError(t, err)
@@ -83,6 +84,9 @@ func TestWriteJunit(t *testing.T) {
 
 		want, err := os.ReadFile(test.expected)
 		require.NoError(t, err)
-		assert.Equal(t, string(want), string(got))
+
+		wantStr := lineEndingReplacer.Replace(string(want))
+		gotStr := lineEndingReplacer.Replace(string(got))
+		assert.Equal(t, wantStr, gotStr)
 	}
 }
diff --git a/report/report_test.go b/report/report_test.go
index 7b9da4d..3537dec 100644
--- a/report/report_test.go
+++ b/report/report_test.go
@@ -2,6 +2,7 @@ package report
 
 import (
 	"bytes"
+	"strings"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -9,7 +10,6 @@ import (
 )
 
 const expectPath = "../testdata/expected/"
-const configPath = "../testdata/config/"
 const templatePath = "../testdata/report/"
 
 func TestWriteStdout(t *testing.T) {
@@ -40,3 +40,10 @@ type testWriter struct {
 func (t testWriter) Close() error {
 	return nil
 }
+
+// lineEndingReplacer normalizes CRLF to LF so tests pass on Windows.
+var lineEndingReplacer = strings.NewReplacer(
+	"\\r\\n", "\\n",
+	"\r", "",
+	"\\r", "",
+)
diff --git a/report/sarif_test.go b/report/sarif_test.go
index 68aa1c2..7c8f458 100644
--- a/report/sarif_test.go
+++ b/report/sarif_test.go
@@ -49,6 +49,7 @@ func TestWriteSarif(t *testing.T) {
 		t.Run(test.cfgName, func(t *testing.T) {
 			tmpfile, err := os.Create(filepath.Join(t.TempDir(), test.testReportName+".json"))
 			require.NoError(t, err)
+			defer tmpfile.Close()
 
 			reporter := SarifReporter{
 				OrderedRules: []config.Rule{
@@ -76,7 +77,10 @@ func TestWriteSarif(t *testing.T) {
 
 			want, err := os.ReadFile(test.expected)
 			require.NoError(t, err)
-			assert.Equal(t, string(want), string(got))
+
+			wantStr := lineEndingReplacer.Replace(string(want))
+			gotStr := lineEndingReplacer.Replace(string(got))
+			assert.Equal(t, wantStr, gotStr)
 		})
 	}
 }
diff --git a/report/template_test.go b/report/template_test.go
index 7a0ec3b..2cd3f17 100644
--- a/report/template_test.go
+++ b/report/template_test.go
@@ -74,6 +74,7 @@ func TestWriteTemplate(t *testing.T) {
 
 			tmpfile, err := os.Create(filepath.Join(t.TempDir(), test.testReportName+filepath.Ext(test.expected)))
 			require.NoError(t, err)
+			defer tmpfile.Close()
 
 			err = reporter.Write(tmpfile, test.findings)
 			require.NoError(t, err)
@@ -88,7 +89,10 @@ func TestWriteTemplate(t *testing.T) {
 
 			want, err := os.ReadFile(test.expected)
 			require.NoError(t, err)
-			assert.Equal(t, string(want), string(got))
+
+			wantStr := lineEndingReplacer.Replace(string(want))
+			gotStr := lineEndingReplacer.Replace(string(got))
+			assert.Equal(t, wantStr, gotStr)
 		})
 	}
 }
diff --git a/testdata/gitleaksignore/.windowspaths b/testdata/gitleaksignore/.windowspaths
new file mode 100644
index 0000000..6c2c01b
--- /dev/null
+++ b/testdata/gitleaksignore/.windowspaths
@@ -0,0 +1,4 @@
+foo\bar\gitleaks-false-positive.yaml:aws-access-token:4
+b55d88dc151f7022901cda41a03d43e0e508f2b7:test_data\test_local_repo_three_leaks.json:aws-access-token:73
+foo/bar/gitleaks-false-positive.yaml:aws-access-token:5
+# comment