瀏覽代碼

feat(git): include link in report (#1698)

Richard Gomez 1 年之前
父節點
當前提交
3c7f3f0e08

+ 35 - 46
cmd/detect.go

@@ -24,6 +24,8 @@ import (
 
 	"github.com/spf13/cobra"
 
+	"github.com/zricethezav/gitleaks/v8/cmd/scm"
+	"github.com/zricethezav/gitleaks/v8/detect"
 	"github.com/zricethezav/gitleaks/v8/logging"
 	"github.com/zricethezav/gitleaks/v8/report"
 	"github.com/zricethezav/gitleaks/v8/sources"
@@ -36,6 +38,7 @@ func init() {
 	detectCmd.Flags().Bool("follow-symlinks", false, "scan files that are symlinks to other files")
 	detectCmd.Flags().StringP("source", "s", ".", "path to source")
 	detectCmd.Flags().String("log-opts", "", "git log options")
+	detectCmd.Flags().String("platform", "", "the target platform used to generate links (github, gitlab)")
 }
 
 var detectCmd = &cobra.Command{
@@ -46,84 +49,70 @@ var detectCmd = &cobra.Command{
 }
 
 func runDetect(cmd *cobra.Command, args []string) {
-	source, err := cmd.Flags().GetString("source")
-	if err != nil {
-		logging.Fatal().Err(err).Msg("could not get source")
-	}
-	initConfig(source)
-
-	var findings []report.Finding
+	// start timer
+	start := time.Now()
+	source := mustGetStringFlag(cmd, "source")
 
 	// setup config (aka, the thing that defines rules)
+	initConfig(source)
 	cfg := Config(cmd)
 
-	// start timer
-	start := time.Now()
-
+	// create detector
 	detector := Detector(cmd, cfg, source)
 
-	// set follow symlinks flag
-	if detector.FollowSymlinks, err = cmd.Flags().GetBool("follow-symlinks"); err != nil {
-		logging.Fatal().Err(err).Msg("")
-	}
-	// set exit code
-	exitCode, err := cmd.Flags().GetInt("exit-code")
-	if err != nil {
-		logging.Fatal().Err(err).Msg("could not get exit code")
-	}
+	// parse flags
+	detector.FollowSymlinks = mustGetBoolFlag(cmd, "follow-symlinks")
+	exitCode := mustGetIntFlag(cmd, "exit-code")
+	noGit := mustGetBoolFlag(cmd, "no-git")
+	fromPipe := mustGetBoolFlag(cmd, "pipe")
 
 	// determine what type of scan:
 	// - git: scan the history of the repo
 	// - no-git: scan files by treating the repo as a plain directory
-	noGit, err := cmd.Flags().GetBool("no-git")
-	if err != nil {
-		logging.Fatal().Err(err).Msg("could not call GetBool() for no-git")
-	}
-	fromPipe, err := cmd.Flags().GetBool("pipe")
-	if err != nil {
-		logging.Fatal().Err(err).Msg("could not call GetBool() for pipe")
-	}
-
-	// start the detector scan
+	var (
+		findings []report.Finding
+		err      error
+	)
 	if noGit {
-		var paths <-chan sources.ScanTarget
-		paths, err = sources.DirectoryTargets(
+		paths, err := sources.DirectoryTargets(
 			source,
 			detector.Sema,
 			detector.FollowSymlinks,
 			detector.Config.Allowlist.PathAllowed,
 		)
 		if err != nil {
-			logging.Fatal().Err(err)
+			logging.Fatal().Err(err).Send()
 		}
 
-		findings, err = detector.DetectFiles(paths)
-		if err != nil {
+		if findings, err = detector.DetectFiles(paths); err != nil {
 			// don't exit on error, just log it
 			logging.Error().Err(err).Msg("failed scan directory")
 		}
 	} else if fromPipe {
-		findings, err = detector.DetectReader(os.Stdin, 10)
-		if err != nil {
+		if findings, err = detector.DetectReader(os.Stdin, 10); err != nil {
 			// log fatal to exit, no need to continue since a report
 			// will not be generated when scanning from a pipe...for now
 			logging.Fatal().Err(err).Msg("failed scan input from stdin")
 		}
 	} else {
 		var (
-			gitCmd  *sources.GitCmd
-			logOpts string
+			gitCmd      *sources.GitCmd
+			scmPlatform scm.Platform
+			remote      *detect.RemoteInfo
+			logOpts     string
 		)
-		logOpts, err = cmd.Flags().GetString("log-opts")
-		if err != nil {
-			logging.Fatal().Err(err).Msg("could not call GetString() for log-opts")
-		}
-		gitCmd, err = sources.NewGitLogCmd(source, logOpts)
-		if err != nil {
+		if gitCmd, err = sources.NewGitLogCmd(source, logOpts); err != nil {
 			logging.Fatal().Err(err).Msg("could not create Git cmd")
 		}
-		findings, err = detector.DetectGit(gitCmd)
-		if err != nil {
+		if scmPlatform, err = scm.PlatformFromString(mustGetStringFlag(cmd, "platform")); err != nil {
+			logging.Fatal().Err(err).Send()
+		}
+		if remote, err = detect.NewRemoteInfo(scmPlatform, source); err != nil {
+			logging.Fatal().Err(err).Msg("failed to scan Git repository")
+		}
+		logOpts = mustGetStringFlag(cmd, "log-opts")
+
+		if findings, err = detector.DetectGit(gitCmd, remote); err != nil {
 			// don't exit on error, just log it
 			logging.Error().Err(err).Msg("failed to scan Git repository")
 		}

+ 2 - 1
cmd/generate/config/base/config.go

@@ -82,6 +82,7 @@ func CreateGlobalConfig() config.Config {
 				regexp.MustCompile(`(^|/)bower_components(/.*)?$`),
 				// TODO: Add more common static assets, such as swagger-ui.
 				regexp.MustCompile(`(^|/)(angular|bootstrap|jquery(-?ui)?|plotly|swagger-?ui)[a-zA-Z0-9.-]*(\.min)?\.js(\.map)?$`),
+				regexp.MustCompile(`(^|/)javascript\.json$`),
 
 				// ----------- Python files -----------
 				// Dependencies and lock files.
@@ -99,7 +100,7 @@ func CreateGlobalConfig() config.Config {
 				// Misc
 				regexp.MustCompile(`verification-metadata\.xml`),
 				regexp.MustCompile(`Database.refactorlog`),
-				//regexp.MustCompile(`vendor`),
+				// regexp.MustCompile(`vendor`),
 			},
 			StopWords: []string{
 				"abcdefghijklmnopqrstuvwxyz", // character range

+ 29 - 37
cmd/git.go

@@ -5,6 +5,8 @@ import (
 
 	"github.com/spf13/cobra"
 
+	"github.com/zricethezav/gitleaks/v8/cmd/scm"
+	"github.com/zricethezav/gitleaks/v8/detect"
 	"github.com/zricethezav/gitleaks/v8/logging"
 	"github.com/zricethezav/gitleaks/v8/report"
 	"github.com/zricethezav/gitleaks/v8/sources"
@@ -12,6 +14,7 @@ import (
 
 func init() {
 	rootCmd.AddCommand(gitCmd)
+	gitCmd.Flags().String("platform", "", "the target platform used to generate links (github, gitlab)")
 	gitCmd.Flags().Bool("staged", false, "scan staged commits (good for pre-commit)")
 	gitCmd.Flags().Bool("pre-commit", false, "scan using git diff")
 	gitCmd.Flags().String("log-opts", "", "git log options")
@@ -25,10 +28,8 @@ var gitCmd = &cobra.Command{
 }
 
 func runGit(cmd *cobra.Command, args []string) {
-	var (
-		findings []report.Finding
-		err      error
-	)
+	// start timer
+	start := time.Now()
 
 	// grab source
 	source := "."
@@ -39,55 +40,46 @@ func runGit(cmd *cobra.Command, args []string) {
 		}
 	}
 
-	initConfig(source)
-
 	// setup config (aka, the thing that defines rules)
+	initConfig(source)
 	cfg := Config(cmd)
 
-	// start timer
-	start := time.Now()
-
-	// grab source
+	// create detector
 	detector := Detector(cmd, cfg, source)
 
-	// set exit code
-	exitCode, err := cmd.Flags().GetInt("exit-code")
-	if err != nil {
-		logging.Fatal().Err(err).Msg("could not get exit code")
-	}
+	// parse flags
+	exitCode := mustGetIntFlag(cmd, "exit-code")
+	logOpts := mustGetStringFlag(cmd, "log-opts")
+	staged := mustGetBoolFlag(cmd, "staged")
+	preCommit := mustGetBoolFlag(cmd, "pre-commit")
 
 	var (
-		gitCmd    *sources.GitCmd
-		logOpts   string
-		preCommit bool
-		staged    bool
-	)
-	logOpts, err = cmd.Flags().GetString("log-opts")
-	if err != nil {
-		logging.Fatal().Err(err).Msg("could not call GetString() for log-opts")
-	}
-	staged, err = cmd.Flags().GetBool("staged")
-	if err != nil {
-		logging.Fatal().Err(err).Msg("could not call GetBool() for staged")
-	}
-	preCommit, err = cmd.Flags().GetBool("pre-commit")
-	if err != nil {
-		logging.Fatal().Err(err).Msg("could not call GetBool() for pre-commit")
-	}
+		findings []report.Finding
+		err      error
 
+		gitCmd      *sources.GitCmd
+		scmPlatform scm.Platform
+		remote      *detect.RemoteInfo
+	)
 	if preCommit || staged {
-		gitCmd, err = sources.NewGitDiffCmd(source, staged)
-		if err != nil {
+		if gitCmd, err = sources.NewGitDiffCmd(source, staged); err != nil {
 			logging.Fatal().Err(err).Msg("could not create Git diff cmd")
 		}
+		// Remote info + links are irrelevant for staged changes.
+		remote = &detect.RemoteInfo{Platform: scm.NoPlatform}
 	} else {
-		gitCmd, err = sources.NewGitLogCmd(source, logOpts)
-		if err != nil {
+		if gitCmd, err = sources.NewGitLogCmd(source, logOpts); err != nil {
 			logging.Fatal().Err(err).Msg("could not create Git log cmd")
 		}
+		if scmPlatform, err = scm.PlatformFromString(mustGetStringFlag(cmd, "platform")); err != nil {
+			logging.Fatal().Err(err).Send()
+		}
+		if remote, err = detect.NewRemoteInfo(scmPlatform, source); err != nil {
+			logging.Fatal().Err(err).Msg("failed to scan Git repository")
+		}
 	}
 
-	findings, err = detector.DetectGit(gitCmd)
+	findings, err = detector.DetectGit(gitCmd, remote)
 	if err != nil {
 		// don't exit on error, just log it
 		logging.Error().Err(err).Msg("failed to scan Git repository")

+ 26 - 13
cmd/protect.go

@@ -5,6 +5,8 @@ import (
 
 	"github.com/spf13/cobra"
 
+	"github.com/zricethezav/gitleaks/v8/cmd/scm"
+	"github.com/zricethezav/gitleaks/v8/detect"
 	"github.com/zricethezav/gitleaks/v8/logging"
 	"github.com/zricethezav/gitleaks/v8/report"
 	"github.com/zricethezav/gitleaks/v8/sources"
@@ -25,27 +27,38 @@ var protectCmd = &cobra.Command{
 }
 
 func runProtect(cmd *cobra.Command, args []string) {
-	source, err := cmd.Flags().GetString("source")
-	if err != nil {
-		logging.Fatal().Err(err).Msg("could not get source")
-	}
-	initConfig(source)
+	// start timer
+	start := time.Now()
+	source := mustGetStringFlag(cmd, "source")
 
 	// setup config (aka, the thing that defines rules)
+	initConfig(source)
 	cfg := Config(cmd)
 
-	exitCode, _ := cmd.Flags().GetInt("exit-code")
-	staged, _ := cmd.Flags().GetBool("staged")
-	start := time.Now()
+	// create detector
 	detector := Detector(cmd, cfg, source)
 
+	// parse flags
+	exitCode := mustGetIntFlag(cmd, "exit-code")
+	staged := mustGetBoolFlag(cmd, "staged")
+
 	// start git scan
-	var findings []report.Finding
-	gitCmd, err := sources.NewGitDiffCmd(source, staged)
-	if err != nil {
-		logging.Fatal().Err(err).Msg("")
+	var (
+		findings []report.Finding
+		err      error
+
+		gitCmd *sources.GitCmd
+		remote *detect.RemoteInfo
+	)
+
+	if gitCmd, err = sources.NewGitDiffCmd(source, staged); err != nil {
+		logging.Fatal().Err(err).Msg("could not create Git diff cmd")
 	}
-	findings, err = detector.DetectGit(gitCmd)
+	remote = &detect.RemoteInfo{Platform: scm.NoPlatform}
 
+	if findings, err = detector.DetectGit(gitCmd, remote); err != nil {
+		// don't exit on error, just log it
+		logging.Error().Err(err).Msg("failed to scan Git repository")
+	}
 	findingSummaryAndExit(detector, findings, exitCode, start, err)
 }

+ 27 - 11
cmd/root.go

@@ -276,7 +276,7 @@ func Detector(cmd *cobra.Command, cfg config.Config, source string) *detect.Dete
 	}
 
 	// Validate report settings.
-	reportPath := mustGetStringFlag("report-path")
+	reportPath := mustGetStringFlag(cmd, "report-path")
 	if reportPath != "" {
 		if reportPath != report.StdoutReportPath {
 			// Ensure the path is writable.
@@ -291,8 +291,8 @@ func Detector(cmd *cobra.Command, cfg config.Config, source string) *detect.Dete
 		// Build report writer.
 		var (
 			reporter       report.Reporter
-			reportFormat   = mustGetStringFlag("report-format")
-			reportTemplate = mustGetStringFlag("report-template")
+			reportFormat   = mustGetStringFlag(cmd, "report-format")
+			reportTemplate = mustGetStringFlag(cmd, "report-template")
 		)
 		switch strings.TrimSpace(strings.ToLower(reportFormat)) {
 		case "csv":
@@ -325,14 +325,6 @@ func Detector(cmd *cobra.Command, cfg config.Config, source string) *detect.Dete
 	return detector
 }
 
-func mustGetStringFlag(name string) string {
-	reportPath, err := rootCmd.Flags().GetString(name)
-	if err != nil {
-		logging.Fatal().Msg(err.Error())
-	}
-	return reportPath
-}
-
 func bytesConvert(bytes uint64) string {
 	unit := ""
 	value := float32(bytes)
@@ -442,3 +434,27 @@ func FormatDuration(d time.Duration) string {
 	}
 	return d.Round(scale / 100).String()
 }
+
+func mustGetBoolFlag(cmd *cobra.Command, name string) bool {
+	value, err := cmd.Flags().GetBool(name)
+	if err != nil {
+		logging.Fatal().Err(err).Msgf("could not get flag: %s", name)
+	}
+	return value
+}
+
+func mustGetIntFlag(cmd *cobra.Command, name string) int {
+	value, err := cmd.Flags().GetInt(name)
+	if err != nil {
+		logging.Fatal().Err(err).Msgf("could not get flag: %s", name)
+	}
+	return value
+}
+
+func mustGetStringFlag(cmd *cobra.Command, name string) string {
+	value, err := cmd.Flags().GetString(name)
+	if err != nil {
+		logging.Fatal().Err(err).Msgf("could not get flag: %s", name)
+	}
+	return value
+}

+ 36 - 0
cmd/scm/scm.go

@@ -0,0 +1,36 @@
+package scm
+
+import (
+	"fmt"
+	"strings"
+)
+
+type Platform int
+
+const (
+	NoPlatform Platform = iota
+	GitHubPlatform
+	GitLabPlatform
+	// TODO: Add others.
+)
+
+func (p Platform) String() string {
+	return [...]string{
+		"none",
+		"github",
+		"gitlab",
+	}[p]
+}
+
+func PlatformFromString(s string) (Platform, error) {
+	switch strings.ToLower(s) {
+	case "", "none":
+		return NoPlatform, nil
+	case "github":
+		return GitHubPlatform, nil
+	case "gitlab":
+		return GitLabPlatform, nil
+	default:
+		return NoPlatform, fmt.Errorf("invalid scm platform value: %s", s)
+	}
+}

+ 8 - 15
cmd/stdin.go

@@ -7,7 +7,6 @@ import (
 	"github.com/spf13/cobra"
 
 	"github.com/zricethezav/gitleaks/v8/logging"
-	"github.com/zricethezav/gitleaks/v8/report"
 )
 
 func init() {
@@ -20,27 +19,21 @@ var stdInCmd = &cobra.Command{
 	Run:   runStdIn,
 }
 
-func runStdIn(cmd *cobra.Command, args []string) {
-	initConfig(".")
-	var (
-		findings []report.Finding
-		err      error
-	)
+func runStdIn(cmd *cobra.Command, _ []string) {
+	// start timer
+	start := time.Now()
 
 	// setup config (aka, the thing that defines rules)
+	initConfig(".")
 	cfg := Config(cmd)
 
-	// start timer
-	start := time.Now()
+	// create detector
 	detector := Detector(cmd, cfg, "")
 
-	// set exit code
-	exitCode, err := cmd.Flags().GetInt("exit-code")
-	if err != nil {
-		logging.Fatal().Err(err).Msg("could not get exit code")
-	}
+	// parse flag(s)
+	exitCode := mustGetIntFlag(cmd, "exit-code")
 
-	findings, err = detector.DetectReader(os.Stdin, 10)
+	findings, err := detector.DetectReader(os.Stdin, 10)
 	if err != nil {
 		// log fatal to exit, no need to continue since a report
 		// will not be generated when scanning from a pipe...for now

+ 1 - 0
config/gitleaks.toml

@@ -41,6 +41,7 @@ paths = [
     '''(^|/)(npm-shrinkwrap\.json|package-lock\.json|pnpm-lock\.yaml|yarn\.lock)$''',
     '''(^|/)bower_components(/.*)?$''',
     '''(^|/)(angular|bootstrap|jquery(-?ui)?|plotly|swagger-?ui)[a-zA-Z0-9.-]*(\.min)?\.js(\.map)?$''',
+    '''(^|/)javascript\.json$''',
     '''(^|/)(Pipfile|poetry)\.lock$''',
     '''(?i)/?(v?env|virtualenv)/lib(64)?(/.*)?$''',
     '''(?i)(^|/)(lib(64)?/python[23](\.\d{1,2})+|python/[23](\.\d{1,2})+/lib(64)?)(/.*)?$''',

+ 48 - 35
detect/detect_test.go

@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
+	"strings"
 	"testing"
 
 	"github.com/google/go-cmp/cmp"
@@ -13,6 +14,7 @@ import (
 	"github.com/stretchr/testify/require"
 	regexp "github.com/wasilibs/go-re2"
 
+	"github.com/zricethezav/gitleaks/v8/cmd/scm"
 	"github.com/zricethezav/gitleaks/v8/config"
 	"github.com/zricethezav/gitleaks/v8/report"
 	"github.com/zricethezav/gitleaks/v8/sources"
@@ -503,9 +505,10 @@ func TestFromGit(t *testing.T) {
 	}{
 		{
 			source:  filepath.Join(repoBasePath, "small"),
-			cfgName: "simple",
+			cfgName: "simple", // the remote url is `git@github.com:gitleaks/test.git`
 			expectedFindings: []report.Finding{
 				{
+					RuleID:      "aws-access-key",
 					Description: "AWS Access Key",
 					StartLine:   20,
 					EndLine:     20,
@@ -520,12 +523,13 @@ func TestFromGit(t *testing.T) {
 					Author:      "Zachary Rice",
 					Email:       "zricer@protonmail.com",
 					Message:     "Accidentally add a secret",
-					RuleID:      "aws-access-key",
 					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",
 				},
 				{
+					RuleID:      "aws-access-key",
 					Description: "AWS Access Key",
 					StartLine:   9,
 					EndLine:     9,
@@ -540,10 +544,10 @@ func TestFromGit(t *testing.T) {
 					Author:      "Zach Rice",
 					Email:       "zricer@protonmail.com",
 					Message:     "adding foo package with secret",
-					RuleID:      "aws-access-key",
 					Tags:        []string{"key", "AWS"},
 					Entropy:     3.0841837,
 					Fingerprint: "491504d5a31946ce75e22554cc34203d8e5ff3ca:foo/foo.go:aws-access-key:9",
+					Link:        "https://github.com/gitleaks/test/blob/491504d5a31946ce75e22554cc34203d8e5ff3ca/foo/foo.go#L9",
 				},
 			},
 		},
@@ -553,6 +557,7 @@ func TestFromGit(t *testing.T) {
 			cfgName: "simple",
 			expectedFindings: []report.Finding{
 				{
+					RuleID:      "aws-access-key",
 					Description: "AWS Access Key",
 					StartLine:   9,
 					EndLine:     9,
@@ -567,10 +572,10 @@ func TestFromGit(t *testing.T) {
 					Author:      "Zach Rice",
 					Email:       "zricer@protonmail.com",
 					Message:     "adding foo package with secret",
-					RuleID:      "aws-access-key",
 					Tags:        []string{"key", "AWS"},
 					Entropy:     3.0841837,
 					Fingerprint: "491504d5a31946ce75e22554cc34203d8e5ff3ca:foo/foo.go:aws-access-key:9",
+					Link:        "https://github.com/gitleaks/test/blob/491504d5a31946ce75e22554cc34203d8e5ff3ca/foo/foo.go#L9",
 				},
 			},
 		},
@@ -580,41 +585,46 @@ 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) {
+			viper.AddConfigPath(configPath)
+			viper.SetConfigName("simple")
+			viper.SetConfigType("toml")
+			err := viper.ReadInConfig()
+			require.NoError(t, err)
 
-		viper.AddConfigPath(configPath)
-		viper.SetConfigName("simple")
-		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()
+			require.NoError(t, err)
+			detector := NewDetector(cfg)
 
-		var vc config.ViperConfig
-		err = viper.Unmarshal(&vc)
-		require.NoError(t, err)
-		cfg, err := vc.Translate()
-		require.NoError(t, err)
-		detector := NewDetector(cfg)
+			var ignorePath string
+			info, err := os.Stat(tt.source)
+			require.NoError(t, err)
 
-		var ignorePath string
-		info, err := os.Stat(tt.source)
-		require.NoError(t, err)
+			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)
 
-		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)
+			gitCmd, err := sources.NewGitLogCmd(tt.source, tt.logOpts)
+			require.NoError(t, err)
 
-		gitCmd, err := sources.NewGitLogCmd(tt.source, tt.logOpts)
-		require.NoError(t, err)
-		findings, err := detector.DetectGit(gitCmd)
-		require.NoError(t, err)
+			remote, err := NewRemoteInfo(scm.NoPlatform, tt.source)
+			require.NoError(t, err)
 
-		for _, f := range findings {
-			f.Match = "" // remove lines cause copying and pasting them has some wack formatting
-		}
-		assert.ElementsMatch(t, tt.expectedFindings, findings)
+			findings, err := detector.DetectGit(gitCmd, remote)
+			require.NoError(t, err)
+
+			for _, f := range findings {
+				f.Match = "" // remove lines cause copying and pasting them has some wack formatting
+			}
+			assert.ElementsMatch(t, tt.expectedFindings, findings)
+		})
 	}
 }
 func TestFromGitStaged(t *testing.T) {
@@ -629,6 +639,7 @@ func TestFromGitStaged(t *testing.T) {
 			cfgName: "simple",
 			expectedFindings: []report.Finding{
 				{
+					RuleID:      "aws-access-key",
 					Description: "AWS Access Key",
 					StartLine:   7,
 					EndLine:     7,
@@ -649,8 +660,8 @@ func TestFromGitStaged(t *testing.T) {
 						"key",
 						"AWS",
 					},
-					RuleID:      "aws-access-key",
 					Fingerprint: "api/api.go:aws-access-key:7",
+					Link:        "",
 				},
 			},
 		},
@@ -677,7 +688,9 @@ func TestFromGitStaged(t *testing.T) {
 		require.NoError(t, err)
 		gitCmd, err := sources.NewGitDiffCmd(tt.source, true)
 		require.NoError(t, err)
-		findings, err := detector.DetectGit(gitCmd)
+		remote, err := NewRemoteInfo(scm.NoPlatform, tt.source)
+		require.NoError(t, err)
+		findings, err := detector.DetectGit(gitCmd, remote)
 		require.NoError(t, err)
 
 		for _, f := range findings {

+ 98 - 5
detect/git.go

@@ -1,17 +1,28 @@
 package detect
 
 import (
+	"bytes"
+	"errors"
+	"fmt"
+	"net/url"
+	"os/exec"
+	"regexp"
+	"strings"
+
 	"github.com/gitleaks/go-gitdiff/gitdiff"
 
+	"github.com/zricethezav/gitleaks/v8/cmd/scm"
 	"github.com/zricethezav/gitleaks/v8/logging"
 	"github.com/zricethezav/gitleaks/v8/report"
 	"github.com/zricethezav/gitleaks/v8/sources"
 )
 
-func (d *Detector) DetectGit(gitCmd *sources.GitCmd) ([]report.Finding, error) {
-	defer gitCmd.Wait()
-	diffFilesCh := gitCmd.DiffFilesCh()
-	errCh := gitCmd.ErrCh()
+func (d *Detector) DetectGit(cmd *sources.GitCmd, remote *RemoteInfo) ([]report.Finding, error) {
+	defer cmd.Wait()
+	var (
+		diffFilesCh = cmd.DiffFilesCh()
+		errCh       = cmd.ErrCh()
+	)
 
 	// loop to range over both DiffFiles (stdout) and ErrCh (stderr)
 	for diffFilesCh != nil || errCh != nil {
@@ -50,7 +61,7 @@ func (d *Detector) DetectGit(gitCmd *sources.GitCmd) ([]report.Finding, error) {
 					}
 
 					for _, finding := range d.Detect(fragment) {
-						d.addFinding(augmentGitFinding(finding, textFragment, gitdiffFile))
+						d.addFinding(augmentGitFinding(remote.Platform, remote.Url, finding, textFragment, gitdiffFile))
 					}
 				}
 				return nil
@@ -72,3 +83,85 @@ func (d *Detector) DetectGit(gitCmd *sources.GitCmd) ([]report.Finding, error) {
 	logging.Debug().Msg("Note: this number might be smaller than expected due to commits with no additions")
 	return d.findings, nil
 }
+
+type RemoteInfo struct {
+	Platform scm.Platform
+	Url      string
+}
+
+func NewRemoteInfo(platform scm.Platform, source string) (*RemoteInfo, error) {
+	var (
+		remoteUrl string
+		err       error
+	)
+	if remoteUrl, err = getRemoteUrl(source); err != nil {
+		if strings.Contains(err.Error(), "No remote configured") {
+			logging.Debug().Msg("skipping finding links: repository has no configured remote.")
+			platform = scm.NoPlatform
+			goto End
+		}
+		return nil, fmt.Errorf("unable to get remote URL: %w", err)
+	}
+	if platform == scm.NoPlatform {
+		parsedUrl, err := url.Parse(remoteUrl)
+		if err != nil {
+			return nil, fmt.Errorf("unable to parse remote URL: %w", err)
+		}
+
+		platform = platformFromHost(parsedUrl)
+		if platform == scm.NoPlatform {
+			logging.Info().
+				Str("host", parsedUrl.Hostname()).
+				Msg("Unknown SCM platform. Use --platform to include links in findings.")
+		} else {
+			logging.Debug().
+				Str("host", parsedUrl.Hostname()).
+				Str("platform", platform.String()).
+				Msg("SCM platform parsed from host")
+		}
+	}
+
+End:
+	return &RemoteInfo{
+		Platform: platform,
+		Url:      remoteUrl,
+	}, nil
+}
+
+var sshUrlpat = regexp.MustCompile(`^git@([a-zA-Z0-9.-]+):([\w/.-]+?)(?:\.git)?$`)
+
+func getRemoteUrl(source string) (string, error) {
+	// This will return the first remote — typically, "origin".
+	cmd := exec.Command("git", "ls-remote", "--quiet", "--get-url")
+	if source != "." {
+		cmd.Dir = source
+	}
+
+	stdout, err := cmd.Output()
+	if err != nil {
+		var exitError *exec.ExitError
+		if errors.As(err, &exitError) {
+			return "", fmt.Errorf("command failed (%d): %w, stderr: %s", exitError.ExitCode(), err, string(bytes.TrimSpace(exitError.Stderr)))
+		}
+		return "", err
+	}
+
+	remoteUrl := string(bytes.TrimSpace(stdout))
+	if matches := sshUrlpat.FindStringSubmatch(remoteUrl); matches != nil {
+		host := matches[1]
+		repo := strings.TrimSuffix(matches[2], ".git")
+		remoteUrl = fmt.Sprintf("https://%s/%s", host, repo)
+	}
+	return remoteUrl, nil
+}
+
+func platformFromHost(u *url.URL) scm.Platform {
+	switch strings.ToLower(u.Hostname()) {
+	case "github.com":
+		return scm.GitHubPlatform
+	case "gitlab.com":
+		return scm.GitLabPlatform
+	default:
+		return scm.NoPlatform
+	}
+}

+ 56 - 4
detect/utils.go

@@ -4,20 +4,21 @@ import (
 	// "encoding/json"
 	"fmt"
 	"math"
+	"path/filepath"
 	"strings"
 	"time"
 
-	"github.com/charmbracelet/lipgloss"
-
+	"github.com/zricethezav/gitleaks/v8/cmd/scm"
 	"github.com/zricethezav/gitleaks/v8/logging"
 	"github.com/zricethezav/gitleaks/v8/report"
 
+	"github.com/charmbracelet/lipgloss"
 	"github.com/gitleaks/go-gitdiff/gitdiff"
 )
 
 // augmentGitFinding updates the start and end line numbers of a finding to include the
 // delta from the git diff
-func augmentGitFinding(finding report.Finding, textFragment *gitdiff.TextFragment, f *gitdiff.File) report.Finding {
+func augmentGitFinding(scmPlatform scm.Platform, remoteUrl string, finding report.Finding, textFragment *gitdiff.TextFragment, f *gitdiff.File) report.Finding {
 	if !strings.HasPrefix(finding.Match, "file detected") {
 		finding.StartLine += int(textFragment.NewPosition)
 		finding.EndLine += int(textFragment.NewPosition)
@@ -25,16 +26,64 @@ func augmentGitFinding(finding report.Finding, textFragment *gitdiff.TextFragmen
 
 	if f.PatchHeader != nil {
 		finding.Commit = f.PatchHeader.SHA
-		finding.Message = f.PatchHeader.Message()
 		if f.PatchHeader.Author != nil {
 			finding.Author = f.PatchHeader.Author.Name
 			finding.Email = f.PatchHeader.Author.Email
 		}
 		finding.Date = f.PatchHeader.AuthorDate.UTC().Format(time.RFC3339)
+		finding.Message = f.PatchHeader.Message()
+		// Results from `git diff` shouldn't have a link.
+		if finding.Commit != "" {
+			finding.Link = createScmLink(scmPlatform, remoteUrl, finding)
+		}
 	}
 	return finding
 }
 
+var linkCleaner = strings.NewReplacer(
+	" ", "%20",
+	"%", "%25",
+)
+
+func createScmLink(scmPlatform scm.Platform, remoteUrl string, finding report.Finding) string {
+	if scmPlatform == scm.NoPlatform {
+		return ""
+	}
+
+	// Clean the path.
+	var (
+		filePath = linkCleaner.Replace(finding.File)
+		ext      = strings.ToLower(filepath.Ext(filePath))
+	)
+
+	switch scmPlatform {
+	case scm.GitHubPlatform:
+		link := fmt.Sprintf("%s/blob/%s/%s", remoteUrl, finding.Commit, filePath)
+		if ext == ".ipynb" || ext == ".md" {
+			link += "?plain=1"
+		}
+		if finding.StartLine != 0 {
+			link += fmt.Sprintf("#L%d", finding.StartLine)
+		}
+		if finding.EndLine != finding.StartLine {
+			link += fmt.Sprintf("-L%d", finding.EndLine)
+		}
+		return link
+	case scm.GitLabPlatform:
+		link := fmt.Sprintf("%s/blob/%s/%s", remoteUrl, finding.Commit, filePath)
+		if finding.StartLine != 0 {
+			link += fmt.Sprintf("#L%d", finding.StartLine)
+		}
+		if finding.EndLine != finding.StartLine {
+			link += fmt.Sprintf("-%d", finding.EndLine)
+		}
+		return link
+	default:
+		// This should never happen.
+		return ""
+	}
+}
+
 // shannonEntropy calculates the entropy of data using the formula defined here:
 // https://en.wiktionary.org/wiki/Shannon_entropy
 // Another way to think about what this is doing is calculating the number of bits
@@ -177,6 +226,9 @@ func printFinding(f report.Finding, noColor bool) {
 	fmt.Printf("%-12s %s\n", "Email:", f.Email)
 	fmt.Printf("%-12s %s\n", "Date:", f.Date)
 	fmt.Printf("%-12s %s\n", "Fingerprint:", f.Fingerprint)
+	if f.Link != "" {
+		fmt.Printf("%-12s %s\n", "Link:", f.Link)
+	}
 	fmt.Println("")
 }
 

+ 101 - 0
detect/utils_test.go

@@ -0,0 +1,101 @@
+package detect
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/zricethezav/gitleaks/v8/cmd/scm"
+	"github.com/zricethezav/gitleaks/v8/report"
+)
+
+func Test_createScmLink(t *testing.T) {
+	tests := map[string]struct {
+		platform scm.Platform
+		url      string
+		finding  report.Finding
+		want     string
+	}{
+		// None
+		"no platform": {
+			platform: scm.NoPlatform,
+			want:     "",
+		},
+
+		// GitHub
+		"github - single line": {
+			platform: scm.GitHubPlatform,
+			url:      "https://github.com/gitleaks/test",
+			finding: report.Finding{
+				Commit:    "20553ad96a4a080c94a54d677db97eed8ce2560d",
+				File:      "metrics/% of sales/.env",
+				StartLine: 25,
+				EndLine:   25,
+			},
+			want: "https://github.com/gitleaks/test/blob/20553ad96a4a080c94a54d677db97eed8ce2560d/metrics/%25%20of%20sales/.env#L25",
+		},
+		"github - multi line": {
+			platform: scm.GitHubPlatform,
+			url:      "https://github.com/gitleaks/test",
+			finding: report.Finding{
+				Commit:    "7bad9f7654cf9701b62400281748c0e8efd97666",
+				File:      "config.json",
+				StartLine: 235,
+				EndLine:   238,
+			},
+			want: "https://github.com/gitleaks/test/blob/7bad9f7654cf9701b62400281748c0e8efd97666/config.json#L235-L238",
+		},
+		"github - markdown": {
+			platform: scm.GitHubPlatform,
+			url:      "https://github.com/gitleaks/test",
+			finding: report.Finding{
+				Commit:    "1fc8961d172f39ffb671766e472aa76f8d713e87",
+				File:      "docs/guides/ecosystem/discordjs.MD",
+				StartLine: 34,
+				EndLine:   34,
+			},
+			want: "https://github.com/gitleaks/test/blob/1fc8961d172f39ffb671766e472aa76f8d713e87/docs/guides/ecosystem/discordjs.MD?plain=1#L34",
+		},
+		"github - jupyter notebook": {
+			platform: scm.GitHubPlatform,
+			url:      "https://github.com/gitleaks/test",
+			finding: report.Finding{
+				Commit:    "8f56bd2369595bcadbb007e88ba294630fb05c7b",
+				File:      "Cloud/IPYNB/Overlapping Recommendation algorithm _OCuLaR_.ipynb",
+				StartLine: 293,
+				EndLine:   293,
+			},
+			want: "https://github.com/gitleaks/test/blob/8f56bd2369595bcadbb007e88ba294630fb05c7b/Cloud/IPYNB/Overlapping%20Recommendation%20algorithm%20_OCuLaR_.ipynb?plain=1#L293",
+		},
+
+		// GitLab
+		"gitlab - single line": {
+			platform: scm.GitLabPlatform,
+			url:      "https://gitlab.com/example-org/example-group/gitleaks",
+			finding: report.Finding{
+				Commit:    "213ffd1c9bfa906eb4c7731771132c58a4ca0139",
+				File:      ".gitlab-ci.yml",
+				StartLine: 41,
+				EndLine:   41,
+			},
+			want: "https://gitlab.com/example-org/example-group/gitleaks/blob/213ffd1c9bfa906eb4c7731771132c58a4ca0139/.gitlab-ci.yml#L41",
+		},
+		"gitlab - multi line": {
+			platform: scm.GitLabPlatform,
+			url:      "https://gitlab.com/example-org/example-group/gitleaks",
+			finding: report.Finding{
+				Commit:    "63410f74e23a4e51e1f60b9feb073b5d325af878",
+				File:      ".vscode/launchSettings.json",
+				StartLine: 6,
+				EndLine:   8,
+			},
+			want: "https://gitlab.com/example-org/example-group/gitleaks/blob/63410f74e23a4e51e1f60b9feb073b5d325af878/.vscode/launchSettings.json#L6-8",
+		},
+	}
+	for name, tt := range tests {
+		t.Run(name, func(t *testing.T) {
+			actual := createScmLink(tt.platform, tt.url, tt.finding)
+			assert.Equal(t, tt.want, actual)
+		})
+	}
+}

+ 19 - 7
report/csv.go

@@ -17,8 +17,11 @@ func (r *CsvReporter) Write(w io.WriteCloser, findings []Finding) error {
 		return nil
 	}
 
-	cw := csv.NewWriter(w)
-	err := cw.Write([]string{"RuleID",
+	var (
+		cw  = csv.NewWriter(w)
+		err error
+	)
+	columns := []string{"RuleID",
 		"Commit",
 		"File",
 		"SymlinkFile",
@@ -34,12 +37,17 @@ func (r *CsvReporter) Write(w io.WriteCloser, findings []Finding) error {
 		"Email",
 		"Fingerprint",
 		"Tags",
-	})
-	if err != nil {
+	}
+	// A miserable attempt at "omitempty" so tests don't yell at me.
+	if findings[0].Link != "" {
+		columns = append(columns, "Link")
+	}
+
+	if err = cw.Write(columns); err != nil {
 		return err
 	}
 	for _, f := range findings {
-		err = cw.Write([]string{f.RuleID,
+		row := []string{f.RuleID,
 			f.Commit,
 			f.File,
 			f.SymlinkFile,
@@ -55,8 +63,12 @@ func (r *CsvReporter) Write(w io.WriteCloser, findings []Finding) error {
 			f.Email,
 			f.Fingerprint,
 			strings.Join(f.Tags, " "),
-		})
-		if err != nil {
+		}
+		if findings[0].Link != "" {
+			row = append(row, f.Link)
+		}
+
+		if err = cw.Write(row); err != nil {
 			return err
 		}
 	}

+ 4 - 3
report/finding.go

@@ -8,7 +8,10 @@ import (
 // Finding contains information about strings that
 // have been captured by a tree-sitter query.
 type Finding struct {
+	// Rule is the name of the rule that was matched
+	RuleID      string
 	Description string
+
 	StartLine   int
 	EndLine     int
 	StartColumn int
@@ -26,6 +29,7 @@ type Finding struct {
 	File        string
 	SymlinkFile string
 	Commit      string
+	Link        string `json:",omitempty"`
 
 	// Entropy is the shannon entropy of Value
 	Entropy float32
@@ -36,9 +40,6 @@ type Finding struct {
 	Message string
 	Tags    []string
 
-	// Rule is the name of the rule that was matched
-	RuleID string
-
 	// unique identifier
 	Fingerprint string
 }

+ 1 - 1
testdata/expected/report/json_simple.json

@@ -1,5 +1,6 @@
 [
  {
+  "RuleID": "test-rule",
   "Description": "",
   "StartLine": 1,
   "EndLine": 2,
@@ -16,7 +17,6 @@
   "Date": "10-19-2003",
   "Message": "opps",
   "Tags": [],
-  "RuleID": "test-rule",
   "Fingerprint": ""
  }
 ]

+ 2 - 2
testdata/expected/report/junit_simple.xml

@@ -2,10 +2,10 @@
 <testsuites>
 	<testsuite failures="2" name="gitleaks" tests="2" time="">
 		<testcase classname="Test Rule" file="auth.py" name="test-rule has detected a secret in file auth.py, line 1, at commit 0000000000000000." time="">
-			<failure message="test-rule has detected a secret in file auth.py, line 1, at commit 0000000000000000." type="Test Rule">{&#xA;&#x9;&#34;Description&#34;: &#34;Test Rule&#34;,&#xA;&#x9;&#34;StartLine&#34;: 1,&#xA;&#x9;&#34;EndLine&#34;: 2,&#xA;&#x9;&#34;StartColumn&#34;: 1,&#xA;&#x9;&#34;EndColumn&#34;: 2,&#xA;&#x9;&#34;Match&#34;: &#34;line containing secret&#34;,&#xA;&#x9;&#34;Secret&#34;: &#34;a secret&#34;,&#xA;&#x9;&#34;File&#34;: &#34;auth.py&#34;,&#xA;&#x9;&#34;SymlinkFile&#34;: &#34;&#34;,&#xA;&#x9;&#34;Commit&#34;: &#34;0000000000000000&#34;,&#xA;&#x9;&#34;Entropy&#34;: 0,&#xA;&#x9;&#34;Author&#34;: &#34;John Doe&#34;,&#xA;&#x9;&#34;Email&#34;: &#34;johndoe@gmail.com&#34;,&#xA;&#x9;&#34;Date&#34;: &#34;10-19-2003&#34;,&#xA;&#x9;&#34;Message&#34;: &#34;opps&#34;,&#xA;&#x9;&#34;Tags&#34;: [],&#xA;&#x9;&#34;RuleID&#34;: &#34;test-rule&#34;,&#xA;&#x9;&#34;Fingerprint&#34;: &#34;&#34;&#xA;}</failure>
+			<failure message="test-rule has detected a secret in file auth.py, line 1, at commit 0000000000000000." type="Test Rule">{&#xA;&#x9;&#34;RuleID&#34;: &#34;test-rule&#34;,&#xA;&#x9;&#34;Description&#34;: &#34;Test Rule&#34;,&#xA;&#x9;&#34;StartLine&#34;: 1,&#xA;&#x9;&#34;EndLine&#34;: 2,&#xA;&#x9;&#34;StartColumn&#34;: 1,&#xA;&#x9;&#34;EndColumn&#34;: 2,&#xA;&#x9;&#34;Match&#34;: &#34;line containing secret&#34;,&#xA;&#x9;&#34;Secret&#34;: &#34;a secret&#34;,&#xA;&#x9;&#34;File&#34;: &#34;auth.py&#34;,&#xA;&#x9;&#34;SymlinkFile&#34;: &#34;&#34;,&#xA;&#x9;&#34;Commit&#34;: &#34;0000000000000000&#34;,&#xA;&#x9;&#34;Entropy&#34;: 0,&#xA;&#x9;&#34;Author&#34;: &#34;John Doe&#34;,&#xA;&#x9;&#34;Email&#34;: &#34;johndoe@gmail.com&#34;,&#xA;&#x9;&#34;Date&#34;: &#34;10-19-2003&#34;,&#xA;&#x9;&#34;Message&#34;: &#34;opps&#34;,&#xA;&#x9;&#34;Tags&#34;: [],&#xA;&#x9;&#34;Fingerprint&#34;: &#34;&#34;&#xA;}</failure>
 		</testcase>
 		<testcase classname="Test Rule" file="auth.py" name="test-rule has detected a secret in file auth.py, line 2." time="">
-			<failure message="test-rule has detected a secret in file auth.py, line 2." type="Test Rule">{&#xA;&#x9;&#34;Description&#34;: &#34;Test Rule&#34;,&#xA;&#x9;&#34;StartLine&#34;: 2,&#xA;&#x9;&#34;EndLine&#34;: 3,&#xA;&#x9;&#34;StartColumn&#34;: 1,&#xA;&#x9;&#34;EndColumn&#34;: 2,&#xA;&#x9;&#34;Match&#34;: &#34;line containing secret&#34;,&#xA;&#x9;&#34;Secret&#34;: &#34;a secret&#34;,&#xA;&#x9;&#34;File&#34;: &#34;auth.py&#34;,&#xA;&#x9;&#34;SymlinkFile&#34;: &#34;&#34;,&#xA;&#x9;&#34;Commit&#34;: &#34;&#34;,&#xA;&#x9;&#34;Entropy&#34;: 0,&#xA;&#x9;&#34;Author&#34;: &#34;&#34;,&#xA;&#x9;&#34;Email&#34;: &#34;&#34;,&#xA;&#x9;&#34;Date&#34;: &#34;&#34;,&#xA;&#x9;&#34;Message&#34;: &#34;&#34;,&#xA;&#x9;&#34;Tags&#34;: [],&#xA;&#x9;&#34;RuleID&#34;: &#34;test-rule&#34;,&#xA;&#x9;&#34;Fingerprint&#34;: &#34;&#34;&#xA;}</failure>
+			<failure message="test-rule has detected a secret in file auth.py, line 2." type="Test Rule">{&#xA;&#x9;&#34;RuleID&#34;: &#34;test-rule&#34;,&#xA;&#x9;&#34;Description&#34;: &#34;Test Rule&#34;,&#xA;&#x9;&#34;StartLine&#34;: 2,&#xA;&#x9;&#34;EndLine&#34;: 3,&#xA;&#x9;&#34;StartColumn&#34;: 1,&#xA;&#x9;&#34;EndColumn&#34;: 2,&#xA;&#x9;&#34;Match&#34;: &#34;line containing secret&#34;,&#xA;&#x9;&#34;Secret&#34;: &#34;a secret&#34;,&#xA;&#x9;&#34;File&#34;: &#34;auth.py&#34;,&#xA;&#x9;&#34;SymlinkFile&#34;: &#34;&#34;,&#xA;&#x9;&#34;Commit&#34;: &#34;&#34;,&#xA;&#x9;&#34;Entropy&#34;: 0,&#xA;&#x9;&#34;Author&#34;: &#34;&#34;,&#xA;&#x9;&#34;Email&#34;: &#34;&#34;,&#xA;&#x9;&#34;Date&#34;: &#34;&#34;,&#xA;&#x9;&#34;Message&#34;: &#34;&#34;,&#xA;&#x9;&#34;Tags&#34;: [],&#xA;&#x9;&#34;Fingerprint&#34;: &#34;&#34;&#xA;}</failure>
 		</testcase>
 	</testsuite>
 </testsuites>