Преглед изворни кода

Merge branch 'feature/usability' of https://github.com/zricethezav/gitleaks into feature/usability

zricethezav пре 8 година
родитељ
комит
648ae701bf
9 измењених фајлова са 922 додато и 478 уклоњено
  1. 5 7
      checks.go
  2. 4 4
      checks_test.go
  3. 0 220
      leaks.go
  4. 29 190
      main.go
  5. 243 57
      options.go
  6. 89 0
      options_test.go
  7. 257 0
      owner.go
  8. 266 0
      repo.go
  9. 29 0
      repo_test.go

+ 5 - 7
checks.go

@@ -6,14 +6,12 @@ import (
 	"strings"
 )
 
-// TODO LOCAL REPO!!!!
-
 // checks Regex and if enabled, entropy and stopwords
-func doChecks(diff string, commit Commit, opts *Options, repo RepoDesc) []LeakElem {
+func doChecks(diff string, commit Commit, repo *Repo) []Leak {
 	var (
 		match string
-		leaks []LeakElem
-		leak  LeakElem
+		leaks []Leak
+		leak  Leak
 	)
 
 	lines := strings.Split(diff, "\n")
@@ -34,7 +32,7 @@ func doChecks(diff string, commit Commit, opts *Options, repo RepoDesc) []LeakEl
 				continue
 			}
 
-			leak = LeakElem{
+			leak = Leak{
 				Line:     line,
 				Commit:   commit.Hash,
 				Offender: match,
@@ -109,7 +107,7 @@ func checkShannonEntropy(target string, opts *Options) bool {
 func containsStopWords(target string) bool {
 	// Convert to lowercase to reduce the number of loops needed.
 	target = strings.ToLower(target)
-	
+
 	for _, stopWord := range stopWords {
 		if strings.Contains(target, stopWord) {
 			return true

+ 4 - 4
checks_test.go

@@ -5,14 +5,14 @@ import (
 )
 
 func TestCheckRegex(t *testing.T) {
-	var results []LeakElem
-	opts := &Options{
+	var results []Leak
+	opts = &Options{
 		Concurrency:      10,
 		B64EntropyCutoff: 70,
 		HexEntropyCutoff: 40,
 		Entropy:          false,
 	}
-	repo := RepoDesc{
+	repo := Repo{
 		url: "someurl",
 	}
 	commit := Commit{}
@@ -22,7 +22,7 @@ func TestCheckRegex(t *testing.T) {
 	}
 
 	for k, v := range checks {
-		results = doChecks(k, commit, opts, repo)
+		results = doChecks(k, commit, &repo)
 		if v != len(results) {
 			t.Errorf("regexCheck failed on string %s", k)
 		}

+ 0 - 220
leaks.go

@@ -1,220 +0,0 @@
-package main
-
-import (
-	"bytes"
-	"encoding/json"
-	"fmt"
-	"io/ioutil"
-	"log"
-	"os"
-	"os/exec"
-	"os/signal"
-	"path/filepath"
-	"strings"
-	"sync"
-	"syscall"
-)
-
-// LeakElem contains the line and commit of a leak
-type LeakElem struct {
-	Line     string `json:"line"`
-	Commit   string `json:"commit"`
-	Offender string `json:"string"`
-	Reason   string `json:"reason"`
-	Msg      string `json:"commitMsg"`
-	Time     string `json:"time"`
-	Author   string `json:"author"`
-	File     string `json:"file"`
-	RepoURL  string `json:"repoURL"`
-}
-
-type Commit struct {
-	Hash   string
-	Author string
-	Time   string
-	Msg    string
-}
-
-func rmTmp(owner *Owner) {
-	if _, err := os.Stat(owner.path); err == nil {
-		err := os.RemoveAll(owner.path)
-		log.Printf("\nCleaning up tmp repos in %s\n", owner.path)
-		if err != nil {
-			log.Printf("failed to properly remove tmp gitleaks dir: %v", err)
-		}
-	}
-	os.Exit(1)
-}
-
-// start
-func start(repos []RepoDesc, owner *Owner, opts *Options) {
-	var report []LeakElem
-	if opts.Tmp {
-		defer rmTmp(owner)
-	}
-
-	// interrupt handling
-	c := make(chan os.Signal, 2)
-	signal.Notify(c, os.Interrupt, syscall.SIGTERM)
-	go func() {
-		<-c
-		if opts.Tmp {
-			rmTmp(owner)
-		}
-		os.Exit(1)
-	}()
-
-	// run checks on repos
-	for _, repo := range repos {
-		dotGitPath := filepath.Join(repo.path, ".git")
-		if _, err := os.Stat(dotGitPath); err == nil {
-			if err := os.Chdir(fmt.Sprintf(repo.path)); err != nil {
-				log.Fatal(err)
-			}
-			// use pre-cloned repo
-			fmt.Printf("Checking \x1b[37;1m%s\x1b[0m...\n", repo.url)
-			err := exec.Command("git", "fetch").Run()
-			if err != nil {
-				log.Printf("failed to fetch repo %v", err)
-				return
-			}
-			report = getLeaks(repo, owner, opts)
-		} else {
-			// no repo present, clone it
-			if err := os.Chdir(fmt.Sprintf(owner.path)); err != nil {
-				log.Fatal(err)
-			}
-			fmt.Printf("Cloning \x1b[37;1m%s\x1b[0m...\n", repo.url)
-			err := exec.Command("git", "clone", repo.url).Run()
-			if err != nil {
-				fmt.Printf("failed to clone repo %v", err)
-				return
-			}
-			report = getLeaks(repo, owner, opts)
-		}
-
-		if len(report) == 0 {
-			fmt.Printf("No Leaks detected for \x1b[35;2m%s\x1b[0m...\n", repo.url)
-		}
-
-		if opts.EnableJSON && len(report) != 0 {
-			outputGitLeaksReport(report, repo, opts)
-		}
-	}
-}
-
-// outputGitLeaksReport
-func outputGitLeaksReport(report []LeakElem, repo RepoDesc, opts *Options) {
-	reportJSON, _ := json.MarshalIndent(report, "", "\t")
-	if _, err := os.Stat(repo.owner.reportPath); os.IsNotExist(err) {
-		os.Mkdir(repo.owner.reportPath, os.ModePerm)
-	}
-
-	reportFileName := fmt.Sprintf("%s_leaks.json", repo.name)
-	reportFile := filepath.Join(repo.owner.reportPath, reportFileName)
-	err := ioutil.WriteFile(reportFile, reportJSON, 0644)
-	if err != nil {
-		log.Fatalf("Can't write to file: %s", err)
-	}
-	fmt.Printf("Report written to %s\n", reportFile)
-}
-
-// getLeaks will attempt to find gitleaks
-func getLeaks(repo RepoDesc, owner *Owner, opts *Options) []LeakElem {
-	var (
-		out               []byte
-		err               error
-		commitWG          sync.WaitGroup
-		gitLeakReceiverWG sync.WaitGroup
-		gitLeaks          = make(chan LeakElem)
-		report            []LeakElem
-	)
-	semaphoreChan := make(chan struct{}, opts.Concurrency)
-
-	go func(commitWG *sync.WaitGroup, gitLeakReceiverWG *sync.WaitGroup) {
-		for gitLeak := range gitLeaks {
-			b, err := json.MarshalIndent(gitLeak, "", "   ")
-			if err != nil {
-				fmt.Println("failed to output leak:", err)
-			}
-			fmt.Println(string(b))
-			report = append(report, gitLeak)
-			gitLeakReceiverWG.Done()
-		}
-	}(&commitWG, &gitLeakReceiverWG)
-
-	if err := os.Chdir(fmt.Sprintf(repo.path)); err != nil {
-		log.Fatal(err)
-	}
-
-	gitFormat := "--format=%H%n%an%n%s%n%ci"
-	out, err = exec.Command("git", "rev-list", "--all",
-		"--remotes", "--topo-order", gitFormat).Output()
-	if err != nil {
-		log.Fatalf("error retrieving commits%v\n", err)
-	}
-
-	revListLines := bytes.Split(out, []byte("\n"))
-	commits := parseFormattedRevList(revListLines)
-
-	for _, commit := range commits {
-		if commit.Hash == "" {
-			continue
-		}
-
-		commitWG.Add(1)
-		go func(currCommit Commit, repoName string, commitWG *sync.WaitGroup,
-			gitLeakReceiverWG *sync.WaitGroup, opts *Options) {
-			defer commitWG.Done()
-			if err := os.Chdir(fmt.Sprintf(repo.path)); err != nil {
-				log.Fatal(err)
-			}
-
-			commitCmp := fmt.Sprintf("%s^!", currCommit.Hash)
-			semaphoreChan <- struct{}{}
-			out, err := exec.Command("git", "diff", commitCmp).Output()
-			<-semaphoreChan
-
-			if err != nil {
-				if strings.Contains(err.Error(), "too many files open") {
-					log.Printf("error retrieving diff for commit %s. Try turning concurrency down. %v\n", currCommit, err)
-				}
-				if opts.Tmp {
-					rmTmp(owner)
-				}
-			}
-
-			leaks := doChecks(string(out), currCommit, opts, repo)
-			if len(leaks) == 0 {
-				return
-			}
-			for _, leak := range leaks {
-				gitLeakReceiverWG.Add(1)
-				gitLeaks <- leak
-			}
-
-		}(commit, repo.name, &commitWG, &gitLeakReceiverWG, opts)
-
-		if commit.Hash == opts.SinceCommit {
-			break
-		}
-	}
-
-	commitWG.Wait()
-	gitLeakReceiverWG.Wait()
-	return report
-}
-
-func parseFormattedRevList(revList [][]byte) []Commit {
-	var commits []Commit
-	for i := 0; i < len(revList)-1; i = i + 5 {
-		commit := Commit{
-			Hash:   string(revList[i+1]),
-			Author: string(revList[i+2]),
-			Msg:    string(revList[i+3]),
-			Time:   string(revList[i+4]),
-		}
-		commits = append(commits, commit)
-	}
-	return commits
-}

+ 29 - 190
main.go

@@ -1,53 +1,39 @@
 package main
 
 import (
-	"context"
-	"github.com/google/go-github/github"
-	"github.com/mitchellh/go-homedir"
-	"golang.org/x/oauth2"
-	"io/ioutil"
-	"log"
-	"net/http"
+	"fmt"
+	_ "fmt"
+	"go.uber.org/zap"
+	_ "io/ioutil"
 	"os"
-	"path"
-	"path/filepath"
 	"regexp"
-	"strings"
+	_ "time"
 )
 
+const EXIT_CLEAN = 0
+const EXIT_FAILURE = 1
+const EXIT_LEAKS = 2
+
+// package globals
 var (
-	regexes            map[string]*regexp.Regexp
-	stopWords          []string
-	base64Chars        string
-	hexChars           string
-	assignRegex        *regexp.Regexp
-	fileDiffRegex      *regexp.Regexp
-	gitLeaksPath       string
-	gitLeaksClonePath  string
-	gitLeaksReportPath string
+	regexes       map[string]*regexp.Regexp
+	stopWords     []string
+	base64Chars   string
+	hexChars      string
+	assignRegex   *regexp.Regexp
+	fileDiffRegex *regexp.Regexp
+	logger        *zap.Logger
+	opts          *Options
 )
 
-type RepoDesc struct {
-	name  string
-	url   string
-	path  string
-	owner *Owner
-}
-
-type Owner struct {
-	name        string
-	url         string
-	accountType string
-	path        string
-	reportPath  string
-}
-
 func init() {
 	base64Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
 	hexChars = "1234567890abcdefABCDEF"
-
 	stopWords = []string{"setting", "info", "env", "environment"}
+	fileDiffRegex = regexp.MustCompile("diff --git a.+b/")
+	assignRegex = regexp.MustCompile(`(=|:|:=|<-)`)
 
+	// TODO Externalize regex... this is tricky making it yml compliant
 	regexes = map[string]*regexp.Regexp{
 		"PKCS8":    regexp.MustCompile("-----BEGIN PRIVATE KEY-----"),
 		"RSA":      regexp.MustCompile("-----BEGIN RSA PRIVATE KEY-----"),
@@ -58,164 +44,17 @@ func init() {
 		"AWS":      regexp.MustCompile("AKIA[0-9A-Z]{16}"),
 		"Reddit":   regexp.MustCompile("(?i)reddit.*['|\"][0-9a-zA-Z]{14}['|\"]"),
 		"Heroku":   regexp.MustCompile("(?i)heroku.*[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}"),
-		// "Custom": regexp.MustCompile(".*")
-	}
-	assignRegex = regexp.MustCompile(`(=|:|:=|<-)`)
-	fileDiffRegex = regexp.MustCompile("diff --git a.+b/")
-	homeDir, err := homedir.Dir()
-	if err != nil {
-		log.Fatal("Cant find home dir")
-	}
-
-	gitLeaksPath = filepath.Join(homeDir, ".gitleaks")
-	if _, err := os.Stat(gitLeaksPath); os.IsNotExist(err) {
-		os.Mkdir(gitLeaksPath, os.ModePerm)
-	}
-	gitLeaksClonePath = filepath.Join(gitLeaksPath, "clones")
-	if _, err := os.Stat(gitLeaksClonePath); os.IsNotExist(err) {
-		os.Mkdir(gitLeaksClonePath, os.ModePerm)
-	}
-	gitLeaksReportPath = filepath.Join(gitLeaksPath, "report")
-	if _, err := os.Stat(gitLeaksReportPath); os.IsNotExist(err) {
-		os.Mkdir(gitLeaksReportPath, os.ModePerm)
-	}
-}
-
-// getOwner
-func getOwner(opts *Options) *Owner {
-	var owner Owner
-	if opts.RepoURL != "" {
-		splitSlashes := strings.Split(opts.RepoURL, "/")
-		owner = Owner{
-			name:        splitSlashes[len(splitSlashes)-2],
-			url:         opts.RepoURL,
-			accountType: "users",
-		}
-
-	} else if opts.UserURL != "" {
-		_, ownerName := path.Split(opts.UserURL)
-		owner = Owner{
-			name:        ownerName,
-			url:         opts.UserURL,
-			accountType: "user",
-		}
-	} else if opts.OrgURL != "" {
-		_, ownerName := path.Split(opts.OrgURL)
-		owner = Owner{
-			name:        ownerName,
-			url:         opts.OrgURL,
-			accountType: "org",
-		}
-	}
-
-	if opts.Tmp {
-		dir, err := ioutil.TempDir("", owner.name)
-		if err != nil {
-			log.Fatal("Cant make temp dir")
-		}
-		owner.path = dir
-	} else {
-		owner.path = filepath.Join(gitLeaksClonePath, owner.name)
-		if _, err := os.Stat(owner.path); os.IsNotExist(err) {
-			os.Mkdir(owner.path, os.ModePerm)
-		}
-	}
-	owner.reportPath = filepath.Join(gitLeaksPath, "report", owner.name)
-	return &owner
-}
-
-// getRepos
-func getRepos(opts *Options, owner *Owner) []RepoDesc {
-	var (
-		allRepos  []*github.Repository
-		repos     []*github.Repository
-		repoDescs []RepoDesc
-		resp      *github.Response
-		ctx       = context.Background()
-		err       error
-	)
-	if opts.RepoURL != "" {
-		_, repoName := path.Split(opts.RepoURL)
-		if strings.HasSuffix(repoName, ".git") {
-			repoName = repoName[:len(repoName)-4]
-		}
-		ownerPath := filepath.Join(owner.path, repoName)
-		repo := RepoDesc{
-			name:  repoName,
-			url:   opts.RepoURL,
-			owner: owner,
-			path:  ownerPath}
-		repoDescs = append(repoDescs, repo)
-		return repoDescs
-	}
-
-	tokenClient := getAccessToken(opts)
-	gitClient := github.NewClient(tokenClient)
-
-	// TODO include fork check
-	orgOpt := &github.RepositoryListByOrgOptions{
-		ListOptions: github.ListOptions{PerPage: 10},
-	}
-	userOpt := &github.RepositoryListOptions{
-		ListOptions: github.ListOptions{PerPage: 10},
-	}
-
-	for {
-		if opts.UserURL != "" {
-			repos, resp, err = gitClient.Repositories.List(
-				ctx, owner.name, userOpt)
-		} else if opts.OrgURL != "" {
-			repos, resp, err = gitClient.Repositories.ListByOrg(
-				ctx, owner.name, orgOpt)
-		}
-		allRepos = append(allRepos, repos...)
-		if resp.NextPage == 0 || err != nil {
-			break
-		}
-
-		for _, repo := range repos {
-			repoPath := filepath.Join(owner.path, *repo.Name)
-			repoDescs = append(repoDescs,
-				RepoDesc{
-					name:  *repo.Name,
-					url:   *repo.CloneURL,
-					owner: owner,
-					path:  repoPath})
-		}
-
-		orgOpt.Page = resp.NextPage
-		userOpt.Page = resp.NextPage
-	}
-
-	return repoDescs
-}
-
-// getAccessToken checks
-// 1. option
-// 2. env var
-// TODO. $HOME/.gitleaks/.creds
-func getAccessToken(opts *Options) *http.Client {
-	var token string
-	if opts.Token != "" {
-		token = opts.Token
-	} else {
-		token = os.Getenv("GITHUB_TOKEN")
-	}
-	if token == "" {
-		return nil
 	}
-
-	tokenService := oauth2.StaticTokenSource(
-		&oauth2.Token{AccessToken: token},
-	)
-	tokenClient := oauth2.NewClient(context.Background(), tokenService)
-	return tokenClient
 }
 
 func main() {
 	args := os.Args[1:]
-	opts := parseOptions(args)
-	owner := getOwner(opts)
-	repos := getRepos(opts, owner)
-	start(repos, owner, opts)
+	opts = newOpts(args)
+	owner := newOwner()
+	os.Exit(owner.auditRepos())
+}
+
+func failF(format string, args ...interface{}) {
+	fmt.Fprintf(os.Stderr, format, args...)
+	os.Exit(EXIT_FAILURE)
 }

+ 243 - 57
options.go

@@ -2,54 +2,92 @@ package main
 
 import (
 	"fmt"
+	"github.com/mitchellh/go-homedir"
+	"go.uber.org/zap"
+	"go.uber.org/zap/zapcore"
 	"os"
+	"path/filepath"
+	"regexp"
 	"strconv"
+	"strings"
 )
 
-const usage = `usage: gitleaks [options] <url>
+const DEBUG = 0
+const INFO = 1
+const ERROR = 2
+
+const usage = `usage: gitleaks [options] <URL>/<path_to_repo>
 
 Options:
- -c --concurrency 	Upper bound on concurrent diffs
- -u --user 		Git user url
- -r --repo 		Git repo url
- -o --org 		Git organization url
- -s --since 		Commit to stop at
- -b --b64Entropy 	Base64 entropy cutoff (default is 70)
- -x --hexEntropy  	Hex entropy cutoff (default is 40)
+Modes
+ -u --user 		Git user mode
+ -r --repo 		Git repo mode
+ -o --org 		Git organization mode
+ -l --local 		Local mode, gitleaks will look for local repo in <path>
+
+Logging
+ -ll <INT> --log=<INT> 	0: Debug, 1: Info, 3: Error
+ -v --verbose 		Verbose mode, will output leaks as gitleaks finds them
+
+Locations
+ --report_path=<STR> 	Report output, default $GITLEAKS_HOME/report
+ --clone_path=<STR>	Gitleaks will clone repos here, default $GITLEAKS_HOME/clones
+
+Other
+ -t --temp 		Clone to temporary directory
+ -c <INT> 		Upper bound on concurrent diffs
+ --since=<STR> 		Commit to stop at
+ --b64Entropy=<INT> 	Base64 entropy cutoff (default is 70)
+ --hexEntropy=<INT>  	Hex entropy cutoff (default is 40)
  -e --entropy		Enable entropy		
- -j --json 		Output gitleaks report
  -h --help 		Display this message
- --token    		Github API token
- --strict 		Enables stopwords
+ --token=<STR>    	Github API token
+ --stopwords  		Enables stopwords
+ --pretty 		Enables pretty printing for humans, otherwise you'll get logs'
+
 `
 
-// Options for gitleaks
+// Options for gitleaks. need to support remote repo/owner
+// and local repo/owner mode
 type Options struct {
+	URL      string
+	RepoPath string
+
+	ClonePath  string
+	ReportPath string
+
 	Concurrency      int
 	B64EntropyCutoff int
 	HexEntropyCutoff int
-	UserURL          string
-	OrgURL           string
-	RepoURL          string
-	Strict           bool
-	Entropy          bool
-	SinceCommit      string
-	Persist          bool
-	IncludeForks     bool
-	Tmp              bool
-	EnableJSON       bool
-	Token            string
-	Verbose          bool
+
+	// MODES
+	UserMode  bool
+	OrgMode   bool
+	RepoMode  bool
+	LocalMode bool
+
+	// OPTS
+	Strict       bool
+	Entropy      bool
+	SinceCommit  string
+	Persist      bool
+	IncludeForks bool
+	Tmp          bool
+	ReportOut    bool
+	Token        string
+
+	// LOGS/REPORT
+	LogLevel    int
+	PrettyPrint bool
 }
 
 // help prints the usage string and exits
 func help() {
 	os.Stderr.WriteString(usage)
-	os.Exit(1)
 }
 
 // optionsNextInt is a parseOptions helper that returns the value (int) of an option if valid
-func optionsNextInt(args []string, i *int) int {
+func (opts *Options) nextInt(args []string, i *int) int {
 	if len(args) > *i+1 {
 		*i++
 	} else {
@@ -57,30 +95,90 @@ func optionsNextInt(args []string, i *int) int {
 	}
 	argInt, err := strconv.Atoi(args[*i])
 	if err != nil {
-		fmt.Printf("Invalid %s option: %s\n", args[*i-1], args[*i])
-		help()
+		opts.failF("Invalid %s option: %s\n", args[*i-1], args[*i])
 	}
 	return argInt
 }
 
 // optionsNextString is a parseOptions helper that returns the value (string) of an option if valid
-func optionsNextString(args []string, i *int) string {
+func (opts *Options) nextString(args []string, i *int) string {
 	if len(args) > *i+1 {
 		*i++
 	} else {
-		fmt.Printf("Invalid %s option: %s\n", args[*i-1], args[*i])
-		help()
+		opts.failF("Invalid %s option: %s\n", args[*i-1], args[*i])
 	}
 	return args[*i]
 }
 
-// parseOptions
-func parseOptions(args []string) *Options {
-	opts := &Options{
+// optInt grabs the string ...
+func (opts *Options) optString(arg string, prefixes ...string) (bool, string) {
+	for _, prefix := range prefixes {
+		if strings.HasPrefix(arg, prefix) {
+			return true, arg[len(prefix):]
+		}
+	}
+	return false, ""
+}
+
+// optInt grabs the int ...
+func (opts *Options) optInt(arg string, prefixes ...string) (bool, int) {
+	for _, prefix := range prefixes {
+		if strings.HasPrefix(arg, prefix) {
+			i, err := strconv.Atoi(arg[len(prefix):])
+			if err != nil {
+				opts.failF("Invalid %s int option\n", prefix)
+			}
+			return true, i
+		}
+	}
+	return false, 0
+}
+
+// newOpts generates opts and parses arguments
+func newOpts(args []string) *Options {
+	opts, err := defaultOptions()
+	if err != nil {
+		opts.failF("%v", err)
+	}
+	err = opts.parseOptions(args)
+	if err != nil {
+		opts.failF("%v", err)
+	}
+	opts.setupLogger()
+	return opts
+}
+
+// deafultOptions provides the default options
+func defaultOptions() (*Options, error) {
+	// default GITLEAKS_HOME is $HOME/.gitleaks
+	// gitleaks will use this location for clones if
+	// no clone-path is provided
+	gitleaksHome := os.Getenv("GITLEAKS_HOME")
+	if gitleaksHome == "" {
+		homeDir, err := homedir.Dir()
+		if err != nil {
+			return nil, fmt.Errorf("could not find system home dir")
+		}
+		gitleaksHome = filepath.Join(homeDir, ".gitleaks")
+	}
+
+	// make sure gitleaks home exists
+	if _, err := os.Stat(gitleaksHome); os.IsNotExist(err) {
+		os.Mkdir(gitleaksHome, os.ModePerm)
+	}
+
+	return &Options{
 		Concurrency:      10,
 		B64EntropyCutoff: 70,
 		HexEntropyCutoff: 40,
-	}
+		LogLevel:         INFO,
+		ClonePath:        filepath.Join(gitleaksHome, "clone"),
+		ReportPath:       filepath.Join(gitleaksHome, "report"),
+	}, nil
+}
+
+// parseOptions
+func (opts *Options) parseOptions(args []string) error {
 
 	if len(args) == 0 {
 		help()
@@ -89,48 +187,136 @@ func parseOptions(args []string) *Options {
 	for i := 0; i < len(args); i++ {
 		arg := args[i]
 		switch arg {
-		case "-s", "--since":
-			opts.SinceCommit = optionsNextString(args, &i)
+		case "-s":
+			opts.SinceCommit = opts.nextString(args, &i)
 		case "--strict":
 			opts.Strict = true
 		case "-b", "--b64Entropy":
-			opts.B64EntropyCutoff = optionsNextInt(args, &i)
+			opts.B64EntropyCutoff = opts.nextInt(args, &i)
 		case "-x", "--hexEntropy":
-			opts.HexEntropyCutoff = optionsNextInt(args, &i)
+			opts.HexEntropyCutoff = opts.nextInt(args, &i)
 		case "-e", "--entropy":
 			opts.Entropy = true
-		case "-c", "--concurrency":
-			opts.Concurrency = optionsNextInt(args, &i)
+		case "-c":
+			opts.Concurrency = opts.nextInt(args, &i)
+
 		case "-o", "--org":
-			opts.OrgURL = optionsNextString(args, &i)
+			opts.OrgMode = true
 		case "-u", "--user":
-			opts.UserURL = optionsNextString(args, &i)
+			opts.UserMode = true
 		case "-r", "--repo":
-			opts.RepoURL = optionsNextString(args, &i)
-		case "-t", "--temporary":
+			opts.RepoMode = true
+		case "-l", "--local":
+			opts.LocalMode = true
+
+		case "--report-out":
+			opts.ReportOut = true
+		case "--pretty":
+			opts.PrettyPrint = true
+
+		case "-t", "--temp":
 			opts.Tmp = true
-		case "--token":
-			opts.Token = optionsNextString(args, &i)
-		case "-j", "--json":
-			opts.EnableJSON = true
+		case "-ll":
+			opts.LogLevel = opts.nextInt(args, &i)
 		case "-h", "--help":
 			help()
-			return nil
+			os.Exit(EXIT_CLEAN)
 		default:
-			if i == len(args)-1 && opts.OrgURL == "" && opts.RepoURL == "" &&
-				opts.UserURL == "" {
-				opts.RepoURL = arg
+			// TARGETS
+			if i == len(args)-1 {
+				if opts.LocalMode {
+					opts.RepoPath = args[i]
+				} else {
+					opts.URL = args[i]
+				}
+			} else if match, value := opts.optString(arg, "--token="); match {
+				opts.Token = value
+			} else if match, value := opts.optString(arg, "--since="); match {
+				opts.SinceCommit = value
+			} else if match, value := opts.optString(arg, "--report-path="); match {
+				opts.ReportPath = value
+			} else if match, value := opts.optString(arg, "--clone-path="); match {
+				opts.ClonePath = value
+			} else if match, value := opts.optInt(arg, "--log="); match {
+				opts.LogLevel = value
+			} else if match, value := opts.optInt(arg, "--b64Entropy="); match {
+				opts.B64EntropyCutoff = value
+			} else if match, value := opts.optInt(arg, "--hexEntropy="); match {
+				opts.HexEntropyCutoff = value
 			} else {
 				fmt.Printf("Unknown option %s\n\n", arg)
 				help()
+				return fmt.Errorf("Unknown option %s\n\n", arg)
 			}
 		}
 	}
+	err := opts.guards()
+	if err != nil {
+		fmt.Printf("%v", err)
+	}
+	return err
+}
+
+// failF prints a failure message out to stderr, displays help
+// and exits with a exit code 2
+func (opts *Options) failF(format string, args ...interface{}) {
+	fmt.Fprintf(os.Stderr, format, args...)
+	help()
+	os.Exit(EXIT_FAILURE)
+}
 
-	// "guards"
-	if opts.Tmp && opts.EnableJSON {
-		fmt.Println("Report generation with temporary clones not supported")
+// guards will prevent gitleaks from continuing if any invalid options
+// are found.
+func (opts *Options) guards() error {
+	if (opts.RepoMode || opts.OrgMode || opts.UserMode) && !isGithubTarget(opts.URL) {
+		return fmt.Errorf("Not valid github target %s\n", opts.URL)
+	} else if (opts.RepoMode || opts.OrgMode || opts.UserMode) && opts.LocalMode {
+		return fmt.Errorf("Cannot run Gitleaks on repo/user/org mode and local mode\n")
+	} else if (opts.RepoMode || opts.UserMode) && opts.OrgMode {
+		return fmt.Errorf("Cannot run Gitleaks on more than one mode\n")
+	} else if (opts.OrgMode || opts.UserMode) && opts.RepoMode {
+		return fmt.Errorf("Cannot run Gitleaks on more than one mode\n")
+	} else if (opts.OrgMode || opts.RepoMode) && opts.UserMode {
+		return fmt.Errorf("Cannot run Gitleaks on more than one mode\n")
+	} else if opts.LocalMode && opts.Tmp {
+		return fmt.Errorf("Cannot run Gitleaks with temp settings and local mode\n")
+	} else if opts.SinceCommit != "" && (opts.OrgMode || opts.UserMode) {
+		return fmt.Errorf("Cannot run Gitleaks with since commit flag and a owner mode\n")
 	}
 
-	return opts
+	return nil
+}
+
+// setupLogger initiates the logger and sets the logging level
+// based on what is set in arguments. Default logging level is
+// INFO
+func (opts *Options) setupLogger() {
+	atom := zap.NewAtomicLevel()
+	encoderCfg := zap.NewProductionEncoderConfig()
+	encoderCfg.TimeKey = ""
+	logger = zap.New(zapcore.NewCore(
+		zapcore.NewJSONEncoder(encoderCfg),
+		zapcore.Lock(os.Stdout),
+		atom,
+	))
+
+	switch opts.LogLevel {
+	case DEBUG:
+		atom.SetLevel(zap.DebugLevel)
+	case INFO:
+		atom.SetLevel(zap.InfoLevel)
+	case ERROR:
+		atom.SetLevel(zap.ErrorLevel)
+	}
+
+	// set to ErrorLevel if pretty printing
+	if opts.PrettyPrint{
+		atom.SetLevel(zap.ErrorLevel)
+	}
+}
+
+// isGithubTarget checks if url is a valid github target
+func isGithubTarget(url string) bool {
+	re := regexp.MustCompile("github.com")
+	return re.MatchString(url)
 }

+ 89 - 0
options_test.go

@@ -0,0 +1,89 @@
+package main
+
+import (
+	"testing"
+)
+
+func TestNextInt(t *testing.T) {
+	args := []string{"-c", "10"}
+	i := 0
+	opts, err := defaultOptions()
+	if err != nil {
+		t.Error()
+	}
+	n := opts.nextInt(args, &i)
+	if n != 10 {
+		t.Error()
+	}
+}
+
+func TestNextString(t *testing.T) {
+	args := []string{"--fake", "flag"}
+	i := 0
+	opts, err := defaultOptions()
+	if err != nil {
+		t.Error()
+	}
+	n := opts.nextString(args, &i)
+	if n != "flag" {
+		t.Error()
+	}
+}
+
+func TestOptString(t *testing.T) {
+	opts, err := defaultOptions()
+	if err != nil {
+		t.Error()
+	}
+	match, n := opts.optString("--fake=flag", "--fake=")
+	if !match || n != "flag" {
+		t.Error()
+	}
+}
+
+func TestOptInt(t *testing.T) {
+	opts, err := defaultOptions()
+	if err != nil {
+		t.Error()
+	}
+	match, n := opts.optInt("--fake=10", "--fake=")
+	if !match || n != 10 {
+		t.Error()
+	}
+}
+
+func TestParseOptions(t *testing.T) {
+	opts, err := defaultOptions()
+	opts.URL = "github.com/sample"
+	if err != nil {
+		t.Error()
+	}
+	opts.RepoMode = false
+	opts.UserMode = true
+	opts.LocalMode = true
+	err = opts.guards()
+	if err == nil {
+		t.Error()
+	}
+
+	opts.RepoMode = true
+	opts.UserMode = false
+	opts.LocalMode = false
+	err = opts.guards()
+	if err != nil {
+		t.Error()
+	}
+}
+
+func TestGithubTarget(t *testing.T) {
+	if !isGithubTarget("github.com"){
+		t.Error()
+	}
+	if !isGithubTarget("https://github.com/"){
+		t.Error()
+	}
+	if !isGithubTarget("git@github.com:zricethezav/gitleaks.git"){
+		t.Error()
+	}
+
+}

+ 257 - 0
owner.go

@@ -0,0 +1,257 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	_ "fmt"
+	"github.com/google/go-github/github"
+	"golang.org/x/oauth2"
+	"io/ioutil"
+	"net/http"
+	"os"
+	"os/signal"
+	"path"
+	"strings"
+)
+
+type Owner struct {
+	name        string
+	url         string
+	accountType string
+	path        string
+	reportPath  string
+	repos       []Repo
+}
+
+// newOwner instantiates an owner and creates any necessary resources for said owner.
+// newOwner returns a Owner struct pointer
+func newOwner() *Owner {
+	name := ownerName()
+	owner := &Owner{
+		name:        name,
+		url:         opts.URL,
+		accountType: ownerType(),
+	}
+
+	// listen for ctrl-c
+	// NOTE: need some help on how to actually shut down gracefully.
+	// On interrupt a repo may still be trying to clone... This has no
+	// actual effect other than extraneous logging.
+	sigC := make(chan os.Signal, 1)
+	signal.Notify(sigC, os.Interrupt, os.Interrupt)
+	go func() {
+		<-sigC
+		owner.rmTmp()
+	}()
+
+	// if running on local repo, just go right to it.
+	if opts.LocalMode {
+		repo := newLocalRepo(opts.RepoPath)
+		owner.repos = append(owner.repos, *repo)
+		return owner
+	}
+
+	err := owner.setupDir()
+	if err != nil {
+		owner.failF("%v", err)
+	}
+
+	err = owner.fetchRepos()
+	if err != nil {
+		owner.failF("%v", err)
+	}
+	return owner
+}
+
+// fetchRepos is used by newOwner and is responsible for fetching one or more
+// of the owner's repos. If opts.RepoURL is not the empty string then fetchRepos will
+// only grab the repo specified in opts.RepoURL. Otherwise, fetchRepos will reach out to
+// github's api and grab all repos associated with owner.
+func (owner *Owner) fetchRepos() error {
+	var err error
+	ctx := context.Background()
+	if owner.accountType == "" {
+		// single repo, ambiguous account type
+		_, repoName := path.Split(opts.URL)
+		repo := newRepo(repoName, opts.URL)
+		owner.repos = append(owner.repos, *repo)
+	} else {
+		// org or user account type, would fail if not valid before
+		tokenClient := githubTokenClient()
+		gitClient := github.NewClient(tokenClient)
+
+		if owner.accountType == "org" {
+			// org account type
+			orgOpt := &github.RepositoryListByOrgOptions{
+				ListOptions: github.ListOptions{PerPage: 10},
+			}
+			err = owner.fetchOrgRepos(orgOpt, gitClient, ctx)
+		} else {
+			// user account type
+			userOpt := &github.RepositoryListOptions{
+				ListOptions: github.ListOptions{PerPage: 10},
+			}
+			err = owner.fetchUserRepos(userOpt, gitClient, ctx)
+		}
+	}
+	return err
+}
+
+// fetchOrgRepos used by fetchRepos is responsible for parsing github's org repo response. If no
+// github token is available then fetchOrgRepos might run into a rate limit in which case owner will
+// log an error and gitleaks will exit. The rate limit for no token is 50 req/hour... not much.
+func (owner *Owner) fetchOrgRepos(orgOpts *github.RepositoryListByOrgOptions, gitClient *github.Client,
+	ctx context.Context) error {
+	var (
+		githubRepos []*github.Repository
+		resp        *github.Response
+		err         error
+	)
+
+	for {
+		githubRepos, resp, err = gitClient.Repositories.ListByOrg(
+			ctx, owner.name, orgOpts)
+		owner.addRepos(githubRepos)
+		if _, ok := err.(*github.RateLimitError); ok {
+			logger.Info("hit rate limit")
+		} else if err != nil {
+			return fmt.Errorf("failed fetching org repos, bad request")
+		} else if resp.NextPage == 0 {
+			break
+		}
+		orgOpts.Page = resp.NextPage
+	}
+	return nil
+}
+
+// fetchUserRepos used by fetchRepos is responsible for parsing github's user repo response. If no
+// github token is available then fetchUserRepos might run into a rate limit in which case owner will
+// log an error and gitleaks will exit. The rate limit for no token is 50 req/hour... not much.
+// sorry for the redundancy
+func (owner *Owner) fetchUserRepos(userOpts *github.RepositoryListOptions, gitClient *github.Client,
+	ctx context.Context) error {
+	var (
+		githubRepos []*github.Repository
+		resp        *github.Response
+		err         error
+	)
+	for {
+		githubRepos, resp, err = gitClient.Repositories.List(
+			ctx, owner.name, userOpts)
+		owner.addRepos(githubRepos)
+		if _, ok := err.(*github.RateLimitError); ok {
+			logger.Info("hit rate limit")
+			break
+		} else if err != nil {
+			return fmt.Errorf("failed fetching user repos, bad request")
+		} else if resp.NextPage == 0 {
+			break
+		}
+		userOpts.Page = resp.NextPage
+	}
+	return nil
+}
+
+// addRepos used by fetchUserRepos and fetchOrgRepos appends new repos from
+// github's org/user response.
+func (owner *Owner) addRepos(githubRepos []*github.Repository) {
+	for _, repo := range githubRepos {
+		owner.repos = append(owner.repos, *newRepo(*repo.Name, *repo.CloneURL))
+	}
+}
+
+// auditRepos
+func (owner *Owner) auditRepos() int {
+	exitCode := EXIT_CLEAN
+	for _, repo := range owner.repos {
+		leaksPst, err := repo.audit(owner)
+		if err != nil {
+			failF("%v\n", err)
+		}
+		if leaksPst {
+			exitCode = EXIT_LEAKS
+		}
+	}
+	return exitCode
+}
+
+// failF prints a failure message out to stderr
+// and exits with a exit code 2
+func (owner *Owner) failF(format string, args ...interface{}) {
+	fmt.Fprintf(os.Stderr, format, args...)
+	os.Exit(EXIT_FAILURE)
+}
+
+// setupDir sets up the owner's directory for clones and reports.
+// If the temporary option is set then a temporary directory will be
+// used for the owner repo clones.
+func (owner *Owner) setupDir() error {
+	if opts.Tmp {
+		dir, err := ioutil.TempDir("", owner.name)
+		if err != nil {
+			fmt.Errorf("unable to create temp directories for cloning")
+		}
+		owner.path = dir
+	} else {
+		if _, err := os.Stat(opts.ClonePath); os.IsNotExist(err) {
+			os.Mkdir(owner.path, os.ModePerm)
+		}
+	}
+	return nil
+}
+
+// rmTmp removes the temporary repo
+func (owner *Owner) rmTmp() {
+	os.RemoveAll(owner.path)
+	os.Exit(EXIT_FAILURE)
+}
+
+// ownerType returns the owner type extracted from opts.
+// If no owner type is provided, gitleaks assumes the owner is ambiguous
+// and the user is running gitleaks on a single repo
+func ownerType() string {
+	if opts.OrgMode {
+		return "org"
+	} else if opts.UserMode {
+		return "user"
+	}
+	return ""
+}
+
+// ownerName returns the owner name extracted from the urls provided in opts.
+// If no RepoURL, OrgURL, or UserURL is provided, then owner will log an error
+// and gitleaks will exit.
+func ownerName() string {
+	if opts.RepoMode {
+		splitSlashes := strings.Split(opts.URL, "/")
+		return splitSlashes[len(splitSlashes)-2]
+	} else if opts.UserMode || opts.OrgMode {
+		_, ownerName := path.Split(opts.URL)
+		return ownerName
+	}
+	// local repo
+	return ""
+}
+
+// githubTokenClient creates an oauth client from your github access token.
+// Gitleaks will attempt to retrieve your github access token from a cli argument
+// or an env var - "GITHUB_TOKEN".
+// Might be good to eventually parse the token from a Config or creds file in
+// $GITLEAKS_HOME
+func githubTokenClient() *http.Client {
+	var token string
+	if opts.Token != "" {
+		token = opts.Token
+	} else {
+		token = os.Getenv("GITHUB_TOKEN")
+	}
+	if token == "" {
+		return nil
+	}
+
+	tokenService := oauth2.StaticTokenSource(
+		&oauth2.Token{AccessToken: token},
+	)
+	tokenClient := oauth2.NewClient(context.Background(), tokenService)
+	return tokenClient
+}

+ 266 - 0
repo.go

@@ -0,0 +1,266 @@
+package main
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"go.uber.org/zap"
+	"io/ioutil"
+	"os"
+	"os/exec"
+	"path"
+	"path/filepath"
+	"sync"
+)
+
+type Repo struct {
+	name   string
+	url    string
+	path   string
+	status string // TODO
+	leaks  []Leak
+}
+
+type Leak struct {
+	Line     string `json:"line"`
+	Commit   string `json:"commit"`
+	Offender string `json:"string"`
+	Reason   string `json:"reason"`
+	Msg      string `json:"commitMsg"`
+	Time     string `json:"time"`
+	Author   string `json:"author"`
+	File     string `json:"file"`
+	RepoURL  string `json:"repoURL"`
+}
+
+type Commit struct {
+	Hash   string
+	Author string
+	Time   string
+	Msg    string
+}
+
+// running gitleaks on local repo
+func newLocalRepo(repoPath string) *Repo {
+	_, name := path.Split(repoPath)
+	repo := &Repo{
+		name: name,
+		path: repoPath,
+	}
+	return repo
+
+}
+
+func newRepo(name string, url string) *Repo {
+	repo := &Repo{
+		name: name,
+		url:  url,
+		// TODO handle existing one
+		path: opts.ClonePath + "/" + name,
+	}
+	return repo
+}
+
+func (repo *Repo) Info(msg string) {
+	// logger should have these infos: msg, repo, owner, time
+	logger.Info(msg,
+		zap.String("repo", repo.name),
+		zap.String("repo_path", repo.path),
+	)
+}
+
+func (repo *Repo) PrettyPrintF(format string, args ...interface{}) {
+	if opts.PrettyPrint {
+		fmt.Fprintf(os.Stderr, format, args...)
+	}
+}
+
+// Audit operates on a single repo and searches the full or partial history of the repo.
+// A semaphore is declared for every repo to bind concurrency. If unbounded, the system will throw a
+// `too many open files` error. Eventually, gitleaks should use src-d/go-git to avoid shelling out
+// commands so that users could opt for doing all clones/diffs in memory.
+// Audit also declares two WaitGroups, one for distributing regex/entropy checks, and one for receiving
+// the leaks if there are any. This could be done a little more elegantly in the future.
+func (repo *Repo) audit(owner *Owner) (bool, error) {
+	var (
+		out               []byte
+		err               error
+		commitWG          sync.WaitGroup
+		gitLeakReceiverWG sync.WaitGroup
+		gitLeaksChan      = make(chan Leak)
+		leaks             []Leak
+		semaphoreChan     = make(chan struct{}, opts.Concurrency)
+		leaksPst          bool
+	)
+
+	dotGitPath := filepath.Join(repo.path, ".git")
+
+	// Navigate to proper location to being audit. Clone repo
+	// if not present, otherwise fetch for new changes.
+	if _, err := os.Stat(dotGitPath); os.IsNotExist(err) {
+		if opts.LocalMode {
+			return false, fmt.Errorf("%s does not exist", repo.path)
+		}
+		// no repo present, clone it
+		repo.Info("cloning")
+		repo.PrettyPrintF("Cloning \x1b[37;1m%s\x1b[0m...\n", repo.url)
+		err = exec.Command("git", "clone", repo.url, repo.path).Run()
+		if err != nil {
+			return false, fmt.Errorf("cannot clone %s into %s", repo.url, repo.path)
+		}
+	} else {
+		repo.Info("fetching")
+		repo.PrettyPrintF("Fetching \x1b[37;1m%s\x1b[0m...\n", repo.url)
+		err = exec.Command("git", "fetch").Run()
+		if err != nil {
+			return false, fmt.Errorf("cannot fetch %s from %s", repo.url, repo.path)
+		}
+	}
+
+	err = os.Chdir(fmt.Sprintf(repo.path))
+	if err != nil {
+		return false, fmt.Errorf("cannot navigate to %s", repo.path)
+	}
+
+	gitFormat := "--format=%H%n%an%n%s%n%ci"
+	out, err = exec.Command("git", "rev-list", "--all",
+		"--remotes", "--topo-order", gitFormat).Output()
+
+	if err != nil {
+		return false, fmt.Errorf("could not retreive rev-list from %s", repo.name)
+	}
+
+	revListLines := bytes.Split(out, []byte("\n"))
+	commits := parseRevList(revListLines)
+
+	for _, commit := range commits {
+		if commit.Hash == "" {
+			continue
+		}
+
+		commitWG.Add(1)
+		go auditDiff(commit, repo, &commitWG, &gitLeakReceiverWG,
+			semaphoreChan, gitLeaksChan)
+
+		if commit.Hash == opts.SinceCommit {
+			break
+		}
+	}
+	go reportAggregator(&gitLeakReceiverWG, gitLeaksChan, &leaks)
+	commitWG.Wait()
+	gitLeakReceiverWG.Wait()
+	if len(leaks) != 0 {
+		leaksPst = true
+	}
+
+	if opts.ReportPath != "" && len(leaks) != 0 {
+		err = repo.writeReport()
+		if err != nil {
+			return leaksPst, fmt.Errorf("could not write report to %s", opts.ReportPath)
+		}
+	}
+	return leaksPst, nil
+}
+
+// Used by audit, writeReport will generate a report and write it out to
+// $GITLEAKS_HOME/report/<owner>/<repo>. No report will be generated if
+// no leaks have been found
+func (repo *Repo) writeReport() error {
+	reportJSON, _ := json.MarshalIndent(repo.leaks, "", "\t")
+
+	if _, err := os.Stat(opts.ReportPath); os.IsNotExist(err) {
+		os.Mkdir(opts.ReportPath, os.ModePerm)
+	}
+
+	reportFileName := fmt.Sprintf("%s_leaks.json", repo.name)
+	reportFile := filepath.Join(opts.ReportPath, reportFileName)
+	err := ioutil.WriteFile(reportFile, reportJSON, 0644)
+	if err != nil {
+		return err
+	}
+	repo.Info(fmt.Sprintf("Report written to %s\n", reportFile))
+	return nil
+}
+
+// parseRevList is responsible for parsing the output of
+// $ `git rev-list --all -remotes --topo-order --format=%H%n%an%n%s%n%ci`
+// sample output from the above command looks like:
+//		...
+// 		SHA
+// 		Author Name
+// 		Commit Msg
+// 		Commit Date
+//		...
+// Used by audit
+func parseRevList(revList [][]byte) []Commit {
+	var commits []Commit
+	for i := 0; i < len(revList)-1; i = i + 5 {
+		commit := Commit{
+			Hash:   string(revList[i+1]),
+			Author: string(revList[i+2]),
+			Msg:    string(revList[i+3]),
+			Time:   string(revList[i+4]),
+		}
+		commits = append(commits, commit)
+	}
+	return commits
+}
+
+// reportAggregator is a go func responsible for ...
+func reportAggregator(gitLeakReceiverWG *sync.WaitGroup, gitLeaks chan Leak, leaks *[]Leak) {
+	for gitLeak := range gitLeaks {
+		logger.Info("leak",
+			zap.String("line", gitLeak.Line),
+			zap.String("commit", gitLeak.Commit),
+			zap.String("offender", gitLeak.Offender),
+			zap.String("Reason", gitLeak.Reason),
+			zap.String("author", gitLeak.Author),
+			zap.String("file", gitLeak.File),
+			zap.String("repoURL", gitLeak.RepoURL),
+			zap.String("timeOfCommit", gitLeak.Time),
+		)
+		*leaks = append(*leaks, gitLeak)
+		if opts.PrettyPrint {
+			b, err := json.MarshalIndent(gitLeak, "", "   ")
+			if err != nil {
+				// handle this?
+				fmt.Println("failed to output leak:", err)
+			}
+			fmt.Println(string(b))
+		}
+		gitLeakReceiverWG.Done()
+	}
+}
+
+// Used by audit, auditDiff is a go func responsible for diffing and auditing a commit.
+// Three channels are input here: 1. a semaphore to bind gitleaks, 2. a leak stream, 3. error handling (TODO)
+// This func performs a diff and runs regexes checks on each line of the diff.
+func auditDiff(currCommit Commit, repo *Repo, commitWG *sync.WaitGroup,
+	gitLeakReceiverWG *sync.WaitGroup, semaphoreChan chan struct{},
+	gitLeaks chan Leak) {
+	// signal to WG this diff is done being audited
+	defer commitWG.Done()
+
+	if err := os.Chdir(fmt.Sprintf(repo.path)); err != nil {
+		// TODO handle this better
+		os.Exit(EXIT_FAILURE)
+	}
+
+	commitCmp := fmt.Sprintf("%s^!", currCommit.Hash)
+	semaphoreChan <- struct{}{}
+	out, err := exec.Command("git", "diff", commitCmp).Output()
+	<-semaphoreChan
+
+	if err != nil {
+		os.Exit(EXIT_FAILURE)
+	}
+
+	leaks := doChecks(string(out), currCommit, repo)
+	if len(leaks) == 0 {
+		return
+	}
+	for _, leak := range leaks {
+		gitLeakReceiverWG.Add(1)
+		gitLeaks <- leak
+	}
+}

+ 29 - 0
repo_test.go

@@ -0,0 +1,29 @@
+package main
+
+import "testing"
+
+func TestNewRepo(t *testing.T) {
+	// TODO
+}
+
+func TestNewLocalRepo(t *testing.T) {
+	// TODO
+}
+
+func TestWriteReport(t *testing.T) {
+	// TODO
+	opts, err := defaultOptions()
+	r := newRepo("fakerepo", "github.com")
+	sampleLeak := Leak{
+		Line: "yoo",
+		Commit: "mycommit",
+		Offender: "oh boy",
+		Reason: "hello",
+		Msg: "msg",
+		Time: "time",
+		Author: "lol",
+		RepoURL: "yooo",
+	}
+	r.leaks = []Leak{sampleLeak, sampleLeak}
+	r.writeReport()
+}