Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package trufflehog for openSUSE:Factory checked in at 2026-09-23 18:18:10 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/trufflehog (Old) and /work/SRC/openSUSE:Factory/.trufflehog.new.383539 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "trufflehog" Wed Sep 23 18:18:10 2026 rev:132 rq:1379971 version:3.97.6 Changes: -------- --- /work/SRC/openSUSE:Factory/trufflehog/trufflehog.changes 2026-09-17 17:09:57.816645074 +0200 +++ /work/SRC/openSUSE:Factory/.trufflehog.new.383539/trufflehog.changes 2026-09-23 18:18:12.710180885 +0200 @@ -1,0 +2,11 @@ +Wed Sep 23 04:39:18 UTC 2026 - Johannes Kastl <[email protected]> + +- Update to version 3.97.6: + * analyzer/github: include is_fine_grained metadata and make gist + enumeration non-fatal (#5332) + * fix(gitparse): cheaper low-memory git scan, stop losing + trailing commits (#5331) + * Add git source documentation (#5326) + * Clarify security policy (#5295) + +------------------------------------------------------------------- Old: ---- trufflehog-3.97.5.obscpio New: ---- trufflehog-3.97.6.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ trufflehog.spec ++++++ --- /var/tmp/diff_new_pack.L8bIkn/_old 2026-09-23 18:18:15.145281589 +0200 +++ /var/tmp/diff_new_pack.L8bIkn/_new 2026-09-23 18:18:15.150281795 +0200 @@ -17,7 +17,7 @@ Name: trufflehog -Version: 3.97.5 +Version: 3.97.6 Release: 0 Summary: CLI tool to find exposed secrets in source and archives License: AGPL-3.0-or-later AND MPL-2.0 AND LGPL-3.0-or-later ++++++ _service ++++++ --- /var/tmp/diff_new_pack.L8bIkn/_old 2026-09-23 18:18:15.266286593 +0200 +++ /var/tmp/diff_new_pack.L8bIkn/_new 2026-09-23 18:18:15.276287006 +0200 @@ -2,7 +2,7 @@ <service name="obs_scm" mode="manual"> <param name="url">https://github.com/trufflesecurity/trufflehog.git</param> <param name="scm">git</param> - <param name="revision">refs/tags/v3.97.5</param> + <param name="revision">refs/tags/v3.97.6</param> <param name="match-tag">v*</param> <param name="versionformat">@PARENT_TAG@</param> <param name="versionrewrite-pattern">v(.*)</param> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.L8bIkn/_old 2026-09-23 18:18:15.348289984 +0200 +++ /var/tmp/diff_new_pack.L8bIkn/_new 2026-09-23 18:18:15.361290522 +0200 @@ -1,6 +1,6 @@ <servicedata> <service name="tar_scm"> <param name="url">https://github.com/trufflesecurity/trufflehog.git</param> - <param name="changesrevision">f714bf454f350590f4a24c3ddb1aef02c35bf5b6</param></service></servicedata> + <param name="changesrevision">64d939a56362f519781c53ea09b27f8d1dc0140a</param></service></servicedata> (No newline at EOF) ++++++ trufflehog-3.97.5.obscpio -> trufflehog-3.97.6.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.97.5/SECURITY.md new/trufflehog-3.97.6/SECURITY.md --- old/trufflehog-3.97.5/SECURITY.md 2026-09-16 18:56:14.000000000 +0200 +++ new/trufflehog-3.97.6/SECURITY.md 2026-09-22 15:03:47.000000000 +0200 @@ -1,22 +1,68 @@ -Please report security issues to [email protected] and include `trufflehog` in the subject line. If your vulnerability involves SSRF or outbound requests, please see our policy for that specific class of vulnerability below. +Please report security issues to [email protected] and include +`trufflehog` in the subject line. If your vulnerability involves SSRF or +outbound requests, please see our policy for that specific class of +vulnerability below. + ## Blind SSRF & Outbound Request Policy -Truffle Security treats blind SSRF (the ability to induce outbound requests without data retrieval) as a hardening opportunity rather than a vulnerability. We do not issue CVEs or formal advisories for reports showing outbound interactions unless they demonstrate a tangible security risk to users. -#### Policy Criteria -**Vulnerability (CVE Issued):** We will issue a CVE if a researcher demonstrates a clear exploit chain. For example: -- Credential Exfiltration: Forcing TruffleHog to send third-party secrets (discovered during a scan) or the host's own environment credentials (e.g., IAM metadata) to an attacker-controlled endpoint. -- Internal Exploitation: Using a blind request to trigger secondary vulnerabilities (e.g. RCE) on restricted internal services configured for defense-in-depth. - -**Hardening (No CVE):** We generally will not issue a CVE for: -- Reflected Payloads: Inducing a request to an attacker-controlled URL by inserting a URL into the scan input (i.e., the attacker receiving data back that they already had access to). -- Basic Outbound Control: Demonstrating control over the request URL, Path, or Body, without demonstrating a path to credential leakage or internal system exploitation. -- Service Probing: Simple open/closed port verification or basic interaction with internal services (e.g., triggering a GET request to a local web server) without a demonstrated compromise of data or system integrity. -- Secondary Vulnerability Dependencies: Where the impact relies entirely on the pre-existing lack of authentication, misconfiguration, or known vulnerabilities of a third-party internal service. +As part of its intended operation, TruffleHog makes network requests to +upstream secret providers in order to verify liveness of each secret. + +Truffle Security treats blind SSRF (i.e., the ability to induce outbound +requests without data retrieval) as a hardening opportunity rather than +a vulnerability. + +We do not issue CVEs or formal advisories for reports showing outbound +interactions unless they demonstrate a tangible security risk to users. + +### Policy Criteria + +#### Vulnerability (CVE Issued): +We will issue a CVE if a researcher demonstrates a clear exploit chain. +For example: + +- Credential Exfiltration: Forcing TruffleHog to send third-party + secrets (discovered during a scan) or the host's own environment + credentials (e.g., IAM metadata) to an attacker-controlled endpoint. + +- Internal Exploitation: Using a blind request to trigger secondary + vulnerabilities (e.g. RCE) on restricted internal services configured + for defense-in-depth. + +#### Hardening Opportunity (No CVE): +We will not issue a CVE for: + +- **Reflected Payloads:** Inducing a request to an attacker-controlled endpoint + that requires the attacker to have write access to the scan input (i.e., the + attacker receiving data back that they already had access to). + +- **Basic Outbound Control:** Demonstrating control over the request + URL, Path, or Body, without demonstrating a path to credential leakage + or internal system exploitation. + +- **Service Probing:** Simple open/closed port verification or basic + interaction with internal services (e.g., triggering a GET request to + a local web server) without a demonstrated compromise of data or + system integrity. + +- **Secondary Vulnerability Dependencies:** Where the impact relies + entirely on the pre-existing lack of authentication, misconfiguration, + or known vulnerabilities of a third-party internal service. ### Submission Guidelines + To help us evaluate your report, please specify: -- Level of Control: Which request components are controllable (Method, Host, Path, Headers, or Body)? -- Secret Context: Can you prove that a legitimate secret (not the attacker's payload) is attached to or contained within the outbound request? -- Target Reach: Can the request reach restricted internal IPs (e.g., 127.0.0.1 or 169.254.169.254)? -- Demonstrated Impact: What is the specific risk to a user or environment beyond a simple DNS/HTTP interaction? + +- **Level of Control:** Which request components are controllable (Method, + Host, Path, Headers, or Body)? + +- **Secret Context:** Can you prove that a legitimate secret that the attacker + did not already have access to (i.e., not the attacker's payload) is attached + to or contained within the outbound request? + +- **Target Reach:** Can the request reach restricted internal IPs (e.g., + 127.0.0.1 or 169.254.169.254)? + +- **Demonstrated Impact:** What is the specific risk to a user or environment + beyond a simple DNS/HTTP interaction? diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.97.5/pkg/analyzer/analyzers/github/classic/classictoken.go new/trufflehog-3.97.6/pkg/analyzer/analyzers/github/classic/classictoken.go --- old/trufflehog-3.97.5/pkg/analyzer/analyzers/github/classic/classictoken.go 2026-09-16 18:56:14.000000000 +0200 +++ new/trufflehog-3.97.6/pkg/analyzer/analyzers/github/classic/classictoken.go 2026-09-22 15:03:47.000000000 +0200 @@ -95,9 +95,11 @@ } } + // Gist enumeration is supplemental -- a rate-limit or transient failure + // should not discard the already-gathered metadata, repos, and permissions. gists, err := common.GetAllGistsForUser(client) if err != nil { - return nil, fmt.Errorf("enumerating gists for classic PAT: %w", err) + gists = nil } return &common.SecretInfo{ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.97.5/pkg/analyzer/analyzers/github/finegrained/finegrained.go new/trufflehog-3.97.6/pkg/analyzer/analyzers/github/finegrained/finegrained.go --- old/trufflehog-3.97.5/pkg/analyzer/analyzers/github/finegrained/finegrained.go 2026-09-16 18:56:14.000000000 +0200 +++ new/trufflehog-3.97.6/pkg/analyzer/analyzers/github/finegrained/finegrained.go 2026-09-22 15:03:47.000000000 +0200 @@ -1447,9 +1447,11 @@ return nil, err } + // Gist enumeration is supplemental -- a rate-limit or transient failure + // should not discard the already-gathered metadata, repos, and permissions. allGists, err := common.GetAllGistsForUser(client) if err != nil { - return nil, err + allGists = nil } accessibleRepos := make([]*gh.Repository, 0) for _, repo := range allRepos { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.97.5/pkg/analyzer/analyzers/github/github.go new/trufflehog-3.97.6/pkg/analyzer/analyzers/github/github.go --- old/trufflehog-3.97.5/pkg/analyzer/analyzers/github/github.go 2026-09-16 18:56:14.000000000 +0200 +++ new/trufflehog-3.97.6/pkg/analyzer/analyzers/github/github.go 2026-09-22 15:03:47.000000000 +0200 @@ -50,9 +50,10 @@ result := &analyzers.AnalyzerResult{ AnalyzerType: analyzers.AnalyzerTypeGitHub, Metadata: map[string]any{ - "owner": info.Metadata.User.Login, - "type": info.Metadata.Type, - "expiration": info.Metadata.Expiration, + "owner": info.Metadata.User.Login, + "type": info.Metadata.Type, + "expiration": info.Metadata.Expiration, + "is_fine_grained": info.Metadata.FineGrained, }, } result.Bindings = append(result.Bindings, secretInfoToUserBindings(info)...) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.97.5/pkg/analyzer/analyzers/github/github_test.go new/trufflehog-3.97.6/pkg/analyzer/analyzers/github/github_test.go --- old/trufflehog-3.97.5/pkg/analyzer/analyzers/github/github_test.go 2026-09-16 18:56:14.000000000 +0200 +++ new/trufflehog-3.97.6/pkg/analyzer/analyzers/github/github_test.go 2026-09-22 15:03:47.000000000 +0200 @@ -432,7 +432,8 @@ "Metadata": { "owner": "sirdetectsalot", "expiration": "2026-03-24T15:27:38+05:00", - "type": "Fine-Grained GitHub Personal Access Token" + "type": "Fine-Grained GitHub Personal Access Token", + "is_fine_grained": true } }`, }, @@ -480,7 +481,8 @@ "Metadata": { "owner": "truffle-sandbox", "expiration": "0001-01-01T00:00:00Z", - "type": "Classic GitHub Personal Access Token" + "type": "Classic GitHub Personal Access Token", + "is_fine_grained": false } }`, wantErr: false, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.97.5/pkg/gitparse/README.md new/trufflehog-3.97.6/pkg/gitparse/README.md --- old/trufflehog-3.97.5/pkg/gitparse/README.md 1970-01-01 01:00:00.000000000 +0100 +++ new/trufflehog-3.97.6/pkg/gitparse/README.md 2026-09-22 15:03:47.000000000 +0200 @@ -0,0 +1,123 @@ +# gitparse + +`gitparse` turns a git repository's history into a stream of `*Diff` values, one for each +changed file in each commit. It is the front door for the git based sources: everything +TruffleHog scans out of a repository's history comes through this package. + +It works by running the `git` binary and reading its text output, rather than reading +git's object files directly. Parsing text is not pretty, but `git log --patch` already +handles renames, binary files, path filters and merges, and rewriting all of that +ourselves would be a much bigger thing to get wrong. + +## What comes out + +A `Diff` is one file's added content inside one commit, along with the details of that +commit: + +```go +diffChan, err := parser.RepoPath(ctx, repoPath, "", true, nil, false) +for diff := range diffChan { + diff.Commit.Hash // the commit this change belongs to + diff.PathB // the path of the changed file + diff.LineStart // where in the file the hunk starts + diff.ReadCloser() // the added lines, and nothing else +} +``` + +Only added lines are kept. Removed and unchanged lines are thrown away, because a secret +that was deleted was already there in the commit that added it, and that commit is in the +stream too. + +Commit details are attached to every diff. A commit with no file changes at all, such as +a merge or an empty commit, is still sent once on its own, because its message, author and +notes can hold a secret even when no file changed. + +Diff content goes to one of two writers, chosen by the caller. `buffer_writer` keeps it in +memory and is the default. `buffered_file_writer` spills to disk past a size and is turned +on with `UseCustomContentWriter`. Repositories with big files want the second one. + +## How the log is produced + +There are two ways, and the caller picks. + +**The normal way** is one `git log --patch` for the whole repository. Simple, fast, and +fine for most repositories. + +**The lower memory way** is turned on with `UseLowMemoryScan`, which the CLI exposes as +`--git-low-memory-scan`. It exists because one `git log --patch` keeps a little state for +every commit it walks and never gives any of it back, so on a repository with millions of +commits it grows until the machine kills it. + +That mode splits the work in two: + +1. **List the commits.** `git rev-list` prints the hashes we want and nothing else. The + hashes come back in groups, so the next step can start before the whole history has + been walked. + +2. **Make the patches.** Each group of hashes is handed to its own short lived `git log` + process, which reads them on standard input. Each process only holds state for its own + group and then exits, so this step costs the same no matter how long the history is. + +The results of all those processes are joined back into the single channel the caller +sees, so nothing above this package needs to know which way was used. + +## Things that look like details but are not + +Each of these came from a test or a measurement that said otherwise. + +**Listing uses `git rev-list`, not `git log`.** They can both print hashes, but `git log` +sets up git's diff machinery as soon as a diff option is present, and then compares files +in every commit just to decide which commits to print. `rev-list` never does that. This is +the step that uses the most memory on long histories, so the difference matters, and it is +large. + +**Diff options stay out of the listing step.** Options like `--diff-filter=AM` live with +the patch options, not the commit picking options. `rev-list` rejects them anyway. Leaving +them out means the listing picks up a few extra commits whose changes are all filtered +away, and the `git log` that makes the patches drops those commits itself, exactly as the +single command form does. The end result is the same commits, worked out in the cheaper +place. + +**Patches come from `git log --no-walk=unsorted`, not `git show`.** They print the same +thing for ordinary commits, but only `git log` applies `--diff-filter` to whole commits, +so using `git show` here would scan more commits than the normal way does. `unsorted` +matters too: plain `--no-walk` re-sorts each group by commit date, which scrambles the +order across groups. + +**Hashes go in on standard input, not as arguments.** A command line has a length limit, +and on Windows it is short enough to cap a group at a few dozen commits. Standard input +has no such limit, so groups can be thousands of commits and full hashes can be used +instead of shortened ones. Fewer, bigger groups mean fewer git processes to start. + +**Path filters go to both steps.** The listing needs them to pick the same commits the +single command would have shown, and the patch step needs them to decide which files show +up. + +**The last commit of a stream needs finishing off.** The parser normally completes a +commit when it sees the next one begin. The last commit never gets that, so it is +completed at the end of the stream instead. Without this, a commit with no file changes +was dropped whenever it landed at the end, which used to be rare with one long stream and +became common once the log was split into groups. + +## Stopping early + +A scan can stop before the diffs run out, when it hits its maximum depth or reaches its +base commit. A Go channel cannot tell you that its reader has gone away, so the scan +cancels its context on the way out, and that is what shuts down the git processes still +producing diffs. + +Cancelling also keeps the listing step short. It only ever runs a few groups ahead of what +is being consumed, because the channels between the steps are unbuffered, so a scan that +stops early never walks the whole history. + +## Tests + +```sh +go test ./pkg/gitparse/ +``` + +The tests in `lowmemory_test.go` hold the lower memory mode to one rule: it must produce +exactly what the single command form produces, with the same commits, paths and content, +wherever the group boundaries land. They run with tiny group sizes on purpose, since a +group size of one puts every commit at the end of a stream at once, which is where the +awkward cases live. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.97.5/pkg/gitparse/gitparse.go new/trufflehog-3.97.6/pkg/gitparse/gitparse.go --- old/trufflehog-3.97.5/pkg/gitparse/gitparse.go 2026-09-16 18:56:14.000000000 +0200 +++ new/trufflehog-3.97.6/pkg/gitparse/gitparse.go 2026-09-22 15:03:47.000000000 +0200 @@ -4,6 +4,7 @@ "bufio" "bytes" "cmp" + "errors" "fmt" "io" "os" @@ -36,13 +37,13 @@ // defaultWaitDelay is the default time to wait after context cancellation before forcefully killing git processes. defaultWaitDelay = 5 * time.Second - // abbrevCommit is the git sha abbreviation length to use for `git show` invocations in the lower-memory scan mode. - abbrevCommit = 20 - - // showGroupSize is the number of commits per `git show` in the lower-memory scan mode. + // logGroupSize is the number of commits per `git log` in the lower-memory scan mode. // - // Windows has a command length limit of 32767, so at these values we should only be using a tiny part of of that for the commit list ((abbrevCommit + 1) * showGroupSize). We don't target any platforms with shorter limits. - showGroupSize = 75 + // The hashes are fed in on stdin rather than as arguments, so a group is not + // limited by how long a command line may be and we can use full hashes. Bigger + // groups mean fewer git processes to start; each one still only holds state for + // its own group, which is what keeps memory flat however long the history is. + logGroupSize = 5000 ) // contentWriter defines a common interface for writing, reading, and managing diff content. @@ -141,6 +142,11 @@ useCustomContentWriter bool lowMemoryScan bool + + // groupSize is how many commits go to each `git log` in the lower-memory scan. + // Zero means logGroupSize. Only the tests set it, so they can put a group + // boundary wherever they need one. + groupSize int } type ParseState int @@ -286,7 +292,7 @@ } func (c *Parser) repoPathLowMemory(ctx context.Context, args gitArgs) (chan *Diff, error) { - commitGroups, err := c.gatherGitLog(ctx, args) + commitGroups, err := c.enumerateCommits(ctx, args) if err != nil { return nil, err } @@ -295,7 +301,7 @@ // different goroutine after the command finishes, but we're not // running a single command anymore. we'll use a channel of channels to // reduce back to one channel we return to our caller. Unbuffered so - // we have at most one git show running and one git show draining. + // we have at most one git log running and one git log draining. diffGroups := make(chan chan *Diff) go func() { defer common.RecoverWithExit(ctx) @@ -306,20 +312,36 @@ return } - showCmd := exec.CommandContext(ctx, + // `git log` over an explicit list, not `git show`. The two print the + // same thing for ordinary commits, but only log applies --diff-filter + // to whole commits, so this is what keeps the set of scanned commits + // the same as the single-command form. + logCmd := exec.CommandContext(ctx, "git", - slices.Concat(args.global, []string{"show"}, args.show, group, args.paths)..., + slices.Concat( + args.global, []string{"log"}, args.show, + []string{ + // Keep the commits in the order rev-list gave them. Plain + // --no-walk would re-sort each group by commit date, which + // scrambles the order across groups. + "--no-walk=unsorted", + // Hashes come in on stdin, so the group size is ours to pick. + "--stdin", + }, + args.paths, + )..., ) - showCmd.Env = args.env + logCmd.Env = args.env + logCmd.Stdin = strings.NewReader(strings.Join(group, "\n") + "\n") - diffGroup, err := c.executeCommand(ctx, showCmd, false) + diffGroup, err := c.executeCommand(ctx, logCmd, false) if err != nil { - ctx.Logger().Error(err, "Error executing git show for commit group.") + ctx.Logger().Error(err, "Error executing git log for commit group.") return } err = common.CancellableWrite(ctx, diffGroups, diffGroup) if err != nil { - ctx.Logger().Error(err, "git show interation cancelled") + ctx.Logger().Error(err, "git log iteration cancelled") return } } @@ -346,71 +368,101 @@ return diffChan, nil } -// Ask git for a list of all relevant commit hashes but only hashes. Git takes -// on the work of linearizing history for us, then we work through the commit -// list. Returns a channel of groups of commit IDs, so scanning can start asap -// even if git log is taking a bit for large repos. -func (c *Parser) gatherGitLog(ctx context.Context, args gitArgs) (chan []string, error) { +// enumerateCommits asks git for the hashes of the commits we mean to scan, and +// nothing else. It returns them in groups, so patch generation can start before the +// whole history has been walked. +// +// This uses `git rev-list` rather than `git log`. rev-list is the plumbing command for +// listing commits and never sets up git's diff machinery, which `git log` does as soon +// as a diff option is present. On this repository that is the difference between 43 MB +// and 3.4 MB of peak memory for a phase whose only job is to print hashes, and this is +// the phase that sets the peak on long histories. +// commitGroupSize is how many commits each `git log` gets. +func (c *Parser) commitGroupSize() int { + // A size of zero or less would mean a group that never fills, so it falls back. + if c.groupSize <= 0 { + return logGroupSize + } + return c.groupSize +} + +func (c *Parser) enumerateCommits(ctx context.Context, args gitArgs) (chan []string, error) { + // args.log holds only the options that choose commits, so it can go to rev-list + // as-is. Diff options live in args.show and rev-list would reject them. cmd := exec.CommandContext(ctx, "git", slices.Concat( - args.global, []string{"log"}, - args.log, []string{ - // https://git-scm.com/docs/git-log#_pretty_formats - "--pretty=format:%h", - // https://git-scm.com/docs/git-log#Documentation/git-log.txt---abbrevn - fmt.Sprintf("--abbrev=%d", abbrevCommit), - }, + args.global, []string{"rev-list"}, + args.log, args.paths, )...) cmd.WaitDelay = c.waitDelay cmd.Env = args.env + // Keep stderr, because it carries the reason a bad ref or a broken repo failed. + var stderr strings.Builder + cmd.Stderr = &stderr + stdOut, err := cmd.StdoutPipe() if err != nil { return nil, err } - err = cmd.Start() - if err != nil { - return nil, fmt.Errorf("failed to execute git log: %w", err) + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("failed to execute git rev-list: %w", err) + } + + // Wait for the first byte before returning. A bad revision makes rev-list fail + // immediately, and this is the last moment we can hand that back to the caller as + // an error. Reporting it any later means closing the channel instead, and a typo in + // a branch name then looks exactly like an empty repository. + reader := bufio.NewReader(stdOut) + if _, err := reader.Peek(1); err != nil { + if waitErr := cmd.Wait(); waitErr != nil { + return nil, fmt.Errorf("failed to list commits: %w: %s", waitErr, strings.TrimSpace(stderr.String())) + } + if !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("failed to read commit list: %w", err) + } + + // git was happy and printed nothing, so there is genuinely nothing to scan. + empty := make(chan []string) + close(empty) + return empty, nil } commitGroups := make(chan []string) go func() { defer close(commitGroups) defer func() { - err := cmd.Wait() - if err != nil { - ctx.Logger().Error(err, "git log exited with error", "stderr", cmd.Stderr) + if err := cmd.Wait(); err != nil { + ctx.Logger().Error(err, "git rev-list exited with error", "stderr", strings.TrimSpace(stderr.String())) } }() - s := bufio.NewScanner(stdOut) - commitGroup := make([]string, 0, showGroupSize) - - var err error + s := bufio.NewScanner(reader) + groupSize := c.commitGroupSize() + commitGroup := make([]string, 0, groupSize) for s.Scan() && !common.IsDone(ctx) { commitGroup = append(commitGroup, s.Text()) - - if len(commitGroup) == showGroupSize { - err = common.CancellableWrite(ctx, commitGroups, commitGroup) - if err != nil { - ctx.Logger().Error(err, "git log stopping early") - return - } - commitGroup = make([]string, 0, showGroupSize) + if len(commitGroup) < groupSize { + continue + } + if err := common.CancellableWrite(ctx, commitGroups, commitGroup); err != nil { + ctx.Logger().Error(err, "git rev-list stopping early") + return } + commitGroup = make([]string, 0, groupSize) } + + // The last group is almost never exactly full. if len(commitGroup) != 0 { - err = common.CancellableWrite(ctx, commitGroups, commitGroup) - if err != nil { - ctx.Logger().Error(err, "failed to flush last git log group") + if err := common.CancellableWrite(ctx, commitGroups, commitGroup); err != nil { + ctx.Logger().Error(err, "failed to flush last commit group") } - } if err := s.Err(); err != nil { - ctx.Logger().Error(err, "error reading git log") + ctx.Logger().Error(err, "error reading commit list") } }() @@ -440,8 +492,13 @@ } if abbreviatedLog { + // Only in show. args.log holds the options that choose commits, and it is + // also what the lower-memory scan hands to `git rev-list`, which rejects diff + // options outright. Leaving it out costs nothing: rev-list then lists a few + // commits whose diffs are all filtered away, and the `git log` that generates + // the patches drops those commits itself, exactly as the single-command form + // does. // https://git-scm.com/docs/git-show#Documentation/git-show.txt---diff-filterACDMRTUXB - args.log = append(args.log, "--diff-filter=AM") args.show = append(args.show, "--diff-filter=AM") } @@ -1178,10 +1235,25 @@ if currentDiff != nil && (currentDiff.Len() > 0 || currentDiff.IsBinary) { currentDiff.Commit = currentCommit diffChan <- currentDiff - } - if currentCommit != nil { - if totalLogSize != nil { - *totalLogSize += currentCommit.Size + if currentCommit != nil { + currentCommit.hasDiffs = true } } + + if currentCommit == nil { + return + } + if totalLogSize != nil { + *totalLogSize += currentCommit.Size + } + + // A commit is normally finished off in the loop above, when the next commit line + // shows up. The last commit in the stream never gets that, so it is finished here + // instead, and it needs the same rule: a commit with no diffs of its own still has + // a message, an author and notes worth scanning, so it goes out on its own. + // + // Staged diffs come through here too and carry no commit, hence the hash check. + if !currentCommit.hasDiffs && currentCommit.Hash != "" { + diffChan <- &Diff{Commit: currentCommit} + } } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.97.5/pkg/gitparse/gitparse_test.go new/trufflehog-3.97.6/pkg/gitparse/gitparse_test.go --- old/trufflehog-3.97.5/pkg/gitparse/gitparse_test.go 2026-09-16 18:56:14.000000000 +0200 +++ new/trufflehog-3.97.6/pkg/gitparse/gitparse_test.go 2026-09-22 15:03:47.000000000 +0200 @@ -36,8 +36,10 @@ // head assert.Contains(t, args.log, "branchname") assert.NotContains(t, args.log, "--all") - // abbreviatedLog - assert.Contains(t, args.log, "--diff-filter=AM") + // abbreviatedLog. Only show carries the diff filter: args.log holds the options + // that choose commits and is what the lower-memory scan gives to `git rev-list`, + // which rejects diff options. + assert.NotContains(t, args.log, "--diff-filter=AM") assert.Contains(t, args.show, "--diff-filter=AM") // excludedGlobs assert.Contains(t, args.paths, "--") diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.97.5/pkg/gitparse/lowmemory_test.go new/trufflehog-3.97.6/pkg/gitparse/lowmemory_test.go --- old/trufflehog-3.97.5/pkg/gitparse/lowmemory_test.go 1970-01-01 01:00:00.000000000 +0100 +++ new/trufflehog-3.97.6/pkg/gitparse/lowmemory_test.go 2026-09-22 15:03:47.000000000 +0200 @@ -0,0 +1,282 @@ +package gitparse + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/trufflesecurity/trufflehog/v3/pkg/context" +) + +// The lower-memory scan splits one `git log` into a `git rev-list` that lists commits +// and a `git log` per group that generates their patches. These tests hold it to the +// only thing that really matters: it must find exactly what the single-command form +// finds, wherever the group boundaries happen to land. + +// collectDiffs drains a diff channel into something comparable. Content is included, +// since a group boundary in the wrong place could keep the commit and lose its patch. +func collectDiffs(t *testing.T, diffChan chan *Diff) []string { + t.Helper() + + var out []string + for diff := range diffChan { + content := "" + // A commit with no diffs arrives with nothing written, and asking such a diff + // for its content fails, so check before reading. + if diff.contentWriter != nil && diff.Len() > 0 { + got, err := diff.contentWriter.String() + if err != nil { + t.Fatalf("reading diff content: %v", err) + } + content = got + } + out = append(out, strings.Join([]string{diff.Commit.Hash, diff.PathB, content}, "\x00")) + } + return out +} + +func TestLowMemoryScanMatchesSingleProcess(t *testing.T) { + repo := testRepoRoot(t) + + // abbreviatedLog is the caller's BaseHash == "", so both values are real + // configurations and they take different paths through git. With it on, git drops + // commits whose diffs are all filtered away; with it off, those commits stay. + // + // The group sizes go down to 1 on purpose. Every group ends a stream, and a commit + // with no diffs used to be dropped when it landed at the end of one, so a size of 1 + // puts every commit in that spot at once. Before cleanupParse learned to finish off + // the last commit, this repository lost 84 diffs at size 1 and 1 at size 75. + for _, tc := range []struct { + name string + abbreviated bool + }{ + {"abbreviated", true}, + {"full", false}, + } { + abbreviated, name := tc.abbreviated, tc.name + + t.Run(name, func(t *testing.T) { + ctx := context.Background() + + single := NewParser() + singleChan, err := single.RepoPath(ctx, repo, "", abbreviated, nil, false) + if err != nil { + t.Fatalf("single-process RepoPath: %v", err) + } + want := collectDiffs(t, singleChan) + if len(want) == 0 { + t.Fatal("single-process scan produced no diffs") + } + + // Group sizes small enough that this repository crosses boundaries, which + // is the only place the two forms can drift apart. + for _, groupSize := range []int{1, 2, 7, 500} { + low := NewParser(UseLowMemoryScan()) + low.groupSize = groupSize + + lowChan, err := low.RepoPath(ctx, repo, "", abbreviated, nil, false) + if err != nil { + t.Fatalf("group size %d: RepoPath: %v", groupSize, err) + } + got := collectDiffs(t, lowChan) + + if len(got) != len(want) { + t.Fatalf("group size %d: got %d diffs, want %d", groupSize, len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("group size %d: diff %d differs\n got: %q\nwant: %q", + groupSize, i, got[i], want[i]) + } + } + } + }) + } +} + +// TestLowMemoryScanExcludedGlobs checks the path filters reach both commands. They +// decide which commits rev-list lists and which files the patches contain, so sending +// them to only one of the two would quietly change what gets scanned. +func TestLowMemoryScanExcludedGlobs(t *testing.T) { + repo := testRepoRoot(t) + ctx := context.Background() + globs := []string{"*.go"} + + single := NewParser() + singleChan, err := single.RepoPath(ctx, repo, "", true, globs, false) + if err != nil { + t.Fatalf("single-process RepoPath: %v", err) + } + want := collectDiffs(t, singleChan) + + low := NewParser(UseLowMemoryScan()) + low.groupSize = 13 + lowChan, err := low.RepoPath(ctx, repo, "", true, globs, false) + if err != nil { + t.Fatalf("low-memory RepoPath: %v", err) + } + got := collectDiffs(t, lowChan) + + if len(got) != len(want) { + t.Fatalf("got %d diffs, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("diff %d differs\n got: %q\nwant: %q", i, got[i], want[i]) + } + } +} + +// TestLowMemoryScanUnknownHead covers the error the old form threw away. A bad revision +// used to be logged and the channel closed, so a typo in a branch name looked exactly +// like an empty repository. +func TestLowMemoryScanUnknownHead(t *testing.T) { + repo := testRepoRoot(t) + + parser := NewParser(UseLowMemoryScan()) + if _, err := parser.RepoPath(context.Background(), repo, "no-such-ref-exists", true, nil, false); err == nil { + t.Fatal("expected an error for an unknown head, got nil") + } +} + +// TestLowMemoryScanEmptyRepo checks a repository with no commits ends the scan cleanly +// rather than failing. rev-list prints nothing and exits zero, which has to be told +// apart from rev-list printing nothing because it failed. +func TestLowMemoryScanEmptyRepo(t *testing.T) { + dir := t.TempDir() + runTestGit(t, dir, "init", "-q") + + parser := NewParser(UseLowMemoryScan()) + diffChan, err := parser.RepoPath(context.Background(), dir, "", true, nil, false) + if err != nil { + t.Fatalf("RepoPath on an empty repo: %v", err) + } + if n := len(collectDiffs(t, diffChan)); n != 0 { + t.Fatalf("got %d diffs from an empty repo, want 0", n) + } +} + +// TestLowMemoryScanAbandonedByConsumer covers a caller that stops reading early, which +// is what a depth-limited scan does. Reading one diff and walking away must not leave +// the scan wedged; the cancel in ScanCommits is what releases it in production, so the +// same cancel stands in for it here. +func TestLowMemoryScanAbandonedByConsumer(t *testing.T) { + repo := testRepoRoot(t) + ctx, cancel := context.WithCancel(context.Background()) + + parser := NewParser(UseLowMemoryScan()) + parser.groupSize = 2 + + diffChan, err := parser.RepoPath(ctx, repo, "", true, nil, false) + if err != nil { + t.Fatalf("RepoPath: %v", err) + } + + // Take a single diff, then stop, the way the scan loop does at max depth. + if _, ok := <-diffChan; !ok { + t.Fatal("expected at least one diff") + } + cancel() + + // Draining to the end must finish. If it hangs the test times out, which is the + // failure we are looking for. + for range diffChan { //nolint:revive // draining + } +} + +func TestCommitGroupSize(t *testing.T) { + // A size of zero or less would mean a group that never fills, so it falls back. + for _, tc := range []struct{ set, want int }{ + {0, logGroupSize}, + {-1, logGroupSize}, + {1, 1}, + {99, 99}, + } { + parser := NewParser() + parser.groupSize = tc.set + if got := parser.commitGroupSize(); got != tc.want { + t.Errorf("groupSize %d: got %d, want %d", tc.set, got, tc.want) + } + } +} + +// testRepoRoot returns the root of the git checkout the tests run from. The real +// history has the merges, binaries, renames and uneven commit sizes that a hand-built +// fixture does not, which is where a batching mistake actually shows up. +func testRepoRoot(tb testing.TB) string { + tb.Helper() + + out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() + if err != nil { + tb.Skip("not running from a git checkout") + } + root := strings.TrimSpace(string(out)) + if _, err := os.Stat(filepath.Join(root, ".git")); err != nil { + tb.Skip("no .git directory") + } + return root +} + +func runTestGit(tb testing.TB, dir string, args ...string) { + tb.Helper() + + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + tb.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } +} + +// TestTrailingCommitWithoutDiffsIsReported pins down the rule that a commit is finished +// off at the end of a stream, not only when the next commit line arrives. +// +// A commit with no diffs of its own still carries a message, an author and notes, and +// those are worth scanning. The main loop has always sent such a commit on when it saw +// the next one start, but the end of the stream had no equivalent, so whichever commit +// happened to be last was dropped. One `git log` for a whole repository made that nearly +// harmless, since only the oldest commit in the history was ever in that spot. Splitting +// the log into groups puts a different commit there for every group. +func TestTrailingCommitWithoutDiffsIsReported(t *testing.T) { + dir := t.TempDir() + runTestGit(t, dir, "init", "-q") + runTestGit(t, dir, "config", "user.email", "[email protected]") + runTestGit(t, dir, "config", "user.name", "Test") + + if err := os.WriteFile(filepath.Join(dir, "f.txt"), []byte("hello\n"), 0o600); err != nil { + t.Fatal(err) + } + runTestGit(t, dir, "add", "-A") + runTestGit(t, dir, "commit", "-qm", "adds a file") + + // An empty commit has a message but changes nothing, which is the shape that used + // to disappear. + runTestGit(t, dir, "commit", "-q", "--allow-empty", "-m", "empty commit worth scanning") + + // Group size 1 puts the empty commit at the end of its own stream, which is exactly + // where it used to be lost. + parser := NewParser(UseLowMemoryScan()) + parser.groupSize = 1 + + diffChan, err := parser.RepoPath(context.Background(), dir, "", false, nil, false) + if err != nil { + t.Fatalf("RepoPath: %v", err) + } + + var messages []string + for diff := range diffChan { + messages = append(messages, diff.Commit.Message.String()) + } + + var found bool + for _, msg := range messages { + if strings.Contains(msg, "empty commit worth scanning") { + found = true + break + } + } + if !found { + t.Fatalf("the empty commit was never reported; got %d diffs with messages %q", len(messages), messages) + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.97.5/pkg/sources/git/README.md new/trufflehog-3.97.6/pkg/sources/git/README.md --- old/trufflehog-3.97.5/pkg/sources/git/README.md 1970-01-01 01:00:00.000000000 +0100 +++ new/trufflehog-3.97.6/pkg/sources/git/README.md 2026-09-22 15:03:47.000000000 +0200 @@ -0,0 +1,350 @@ +# Git Source + +## Overview + +The Git source lets TruffleHog scan Git repositories for secrets, credentials, and sensitive data. It reads the full commit history of a repository, not just the files as they look today, so secrets that were committed once and later removed are still found. + +## Git Fundamentals + +### What does this source scan? + +A Git repository keeps every version of every file it has ever tracked. A secret that was committed and then deleted in a later commit still lives in the history. This source walks that history commit by commit and scans what changed in each one. + +### Key Git Terminology + +| Term | Description | +|------|-------------| +| **Commit** | A saved snapshot of changes, identified by a 40 character hash | +| **Diff** | The lines that changed in a file between one commit and the one before it | +| **Branch** | A named pointer to a commit, such as `main` | +| **Ref** | Any named pointer to a commit, which covers branches, tags, and remote tracking names | +| **Clone** | A local copy of a remote repository | +| **Bare repository** | A repository with no working copy of the files, holding only the Git data itself | +| **Mirror clone** | A bare clone that copies every ref from the remote, not only the default branch | +| **Staged changes** | Changes added with `git add` but not committed yet | +| **Merge base** | The commit where two branches last shared history | + +## Features + +- **Full History Scanning**: Walks every commit reachable from every ref, so deleted secrets are still found +- **Commit Metadata Scanning**: Scans the author email, committer, and commit message, not only file changes +- **Multiple Sources**: Scan a remote URL over HTTPS or SSH, or a repository already on disk +- **Multiple Authentication Methods**: Unauthenticated, username and password or token, or SSH +- **Scan Range Control**: Limit the scan to one branch, to commits after a given commit, or to a maximum number of commits +- **Path Filtering**: Include or exclude files by regex, or exclude them by glob at the `git log` level +- **Staged Change Scanning**: Scans changes that are staged but not committed yet, which makes pre commit hook use possible +- **Binary File Handling**: Reads binary files in full through `git cat-file` instead of reading the diff, or skips them +- **Clone Retries**: Retries a failed clone when the failure looks like a network problem or a rate limit + +## Requirements + +The `git` command must be installed and on your `PATH`. The version must be 2.20.0 or newer, and below 3.0.0. TruffleHog checks this when the source starts and fails with a clear message if it is not met. + +## Configuration + +### Repository Location + +The CLI takes the repository as a positional argument, not a flag. The URL must have a scheme, since a plain path like `/home/user/repo` is rejected as an unsupported URI: + +```bash +# HTTPS (http is accepted the same way) +trufflehog git https://github.com/trufflesecurity/test_keys.git + +# SSH +trufflehog git ssh://[email protected]/trufflesecurity/test_keys.git + +# A repository already on disk +trufflehog git file:///path/to/local/repo +``` + +A remote repository is cloned first, then scanned. A `file://` path is also cloned into a separate directory, so the original copy is never written to. + +In the YAML config, use `repositories` for remote URLs to clone, and `directories` for repositories already on disk, which are scanned where they are. + +### Authentication Methods + +#### 1. Unauthenticated + +For public repositories. + +**CLI Usage:** +```bash +trufflehog git https://github.com/trufflesecurity/test_keys.git +``` + +**YAML Configuration:** +```yaml +sources: +- connection: + '@type': type.googleapis.com/sources.Git + unauthenticated: {} + repositories: + - https://github.com/trufflesecurity/test_keys.git + name: git-scan + type: SOURCE_TYPE_GIT + verify: true +``` + +--- + +#### 2. Basic Authentication + +For private repositories that need a username and a password or token. + +**CLI Usage:** + +There is no separate flag for this. Put the credentials in the URL: + +```bash +trufflehog git https://myuser:[email protected]/myorg/private-repo.git +``` + +**YAML Configuration:** +```yaml +sources: +- connection: + '@type': type.googleapis.com/sources.Git + basic_auth: + username: myuser + password: mytoken + repositories: + - https://github.com/myorg/private-repo.git + name: git-scan + type: SOURCE_TYPE_GIT + verify: true +``` + +--- + +#### 3. SSH + +Uses the SSH keys already set up on the machine. There is no key or passphrase field to fill in. + +**CLI Usage:** +```bash +trufflehog git ssh://[email protected]/myorg/private-repo.git +``` + +**YAML Configuration:** +```yaml +sources: +- connection: + '@type': type.googleapis.com/sources.Git + ssh_auth: {} + repositories: + - ssh://[email protected]/myorg/private-repo.git + name: git-scan + type: SOURCE_TYPE_GIT + verify: true +``` + +### Limiting the Scan Range + +**Scanning One Branch** + +By default every ref in the repository is scanned. Pass a branch name to scan only that branch. + +```bash +trufflehog git https://github.com/myorg/myrepo.git --branch main +``` + +**Scanning Since a Commit** + +Scan only the commits made after the given commit. If the repository is on `github.com`, TruffleHog looks up the date of that commit through the GitHub API and does a shallow clone from that date, which makes the clone much smaller. For any other host it falls back to a normal clone and stops walking once it reaches that commit. + +```bash +trufflehog git https://github.com/myorg/myrepo.git --since-commit a1b2c3d4 +``` + +If a `GITHUB_TOKEN` environment variable is set, it is used for that commit lookup, which is needed for private repositories. + +**Limiting Commit Depth** + +Stop after this many commits. + +```bash +trufflehog git https://github.com/myorg/myrepo.git --max-depth 100 +``` + +### Filtering What Gets Scanned + +**Include or Exclude Paths** + +Both flags take a path to a file that holds one regex per line. Blank lines and lines starting with `#` are ignored, so the file can hold comments. + +```bash +trufflehog git https://github.com/myorg/myrepo.git --include-paths ./include.txt +``` + +```bash +trufflehog git https://github.com/myorg/myrepo.git --exclude-paths ./exclude.txt +``` + +**Exclude Globs** + +Takes a comma separated list of globs. This filter is applied at the `git log` level, so the excluded files are never read at all, which makes the scan faster than filtering afterwards. + +```bash +trufflehog git https://github.com/myorg/myrepo.git --exclude-globs "*.min.js,vendor/*" +``` + +### Clone Location and Cleanup + +By default a repository is cloned into a temporary directory and that directory is deleted after the scan. Use `--clone-path` to clone somewhere else, and `--no-cleanup` to keep the clone afterwards. + +```bash +trufflehog git https://github.com/myorg/myrepo.git --clone-path /tmp/my-clones --no-cleanup +``` + +`--no-cleanup` only works together with `--clone-path`, and the path given to `--clone-path` must already exist and be a directory. Each clone gets its own directory inside it, so disk use grows with every repository and every run. + +Warning: cleanup deletes the directory that was scanned, and it is keyed on `--clone-path` being set rather than on whether that directory was actually cloned by TruffleHog. So when `--clone-path` is set and `--no-cleanup` is not, a repository that was scanned in place gets deleted too. That applies to `--trust-local-git-config` on the CLI, and to `directories` entries in the YAML config. Do not combine either of those with `--clone-path` or `clone_path`. + +### Other Options + +**Bare Repository** + +Scan a repository that has no working copy, which is useful in a pre receive hook. + +```bash +trufflehog git file:///path/to/repo.git --bare +``` + +**Trust Local Git Config** + +For a `file://` path, scan the repository where it already is instead of cloning it first. This makes TruffleHog read the local Git config of that repository. + +```bash +trufflehog git file:///path/to/local/repo --trust-local-git-config +``` + +Do not pass `--clone-path` alongside this flag, for the reason given in the cleanup warning above. + +## How Scanning Works + +### Scanning Process + +1. **Git Check**: Confirms the `git` command is installed and its version is supported. +2. **Clone or Open**: A remote URL is cloned into a temporary directory or into `--clone-path`. A repository already on disk is opened where it is. +3. **Commit Walk**: Runs `git log` over the repository with full history, across every ref by default, or over one branch when `--branch` is given. +4. **Commit Metadata Chunk**: For each commit, the author email, the committer, and the commit message are sent to the detection engine as their own chunk. +5. **File Diff Chunks**: For each changed file in the commit, the diff is sent as a chunk tagged with the commit hash, file name, author email, timestamp, repository, and line number. A diff larger than the chunk size is split into several chunks line by line. +6. **Binary Files**: A binary file is not read from the diff. Its full contents are pulled with `git cat-file` and passed through the file handlers, which also unpack archives. +7. **Staged Changes**: If the repository is not bare, `git diff --cached` is scanned as well, so changes staged but not committed are covered. +8. **Cleanup**: The clone is deleted unless `--no-cleanup` was used with `--clone-path`. + +### Clone Retries + +A clone that fails because of a network problem or what looks like a rate limit is retried, up to 3 attempts in total, each from a fresh directory. Network failures wait 5 seconds times the attempt number, and rate limit failures wait 60 seconds times the attempt number, since those take longer to clear. Any other failure, such as a bad password or a missing repository, is returned right away without retrying. + +### What Gets Scanned + +- The diff of every added or modified file in every commit. When `--since-commit` is used, deletions and renames are included as well +- Commit metadata: author email, committer, and commit message +- Binary files, read in full rather than as a diff +- Files inside archives found in the repository +- Staged changes, when the repository is not bare + +### What Doesn't Get Scanned + +- Files excluded by the include paths, exclude paths, or exclude globs filters +- Commits past `--max-depth`, or older than the commit given to `--since-commit` +- Refs other than the one named by `--branch`, when that flag is used +- Binary files, when `--force-skip-binaries` is used +- Binary files whose extension TruffleHog already skips, such as common image, audio, video, and font types +- Files inside archives, when `--force-skip-archives` is used +- Staged changes in a bare repository, since a bare repository has nothing staged + +## Usage Examples + +### Scanning a Public Repository + +```bash +trufflehog git https://github.com/trufflesecurity/test_keys.git +``` + +### Scanning a Local Repository + +```bash +trufflehog git file:///path/to/local/repo +``` + +### Scanning One Branch Only + +```bash +trufflehog git https://github.com/myorg/myrepo.git --branch main +``` + +### Scanning Only Recent History + +```bash +trufflehog git https://github.com/myorg/myrepo.git --max-depth 50 +``` + +### Scanning a Private Repository Over SSH + +```bash +trufflehog git ssh://[email protected]/myorg/private-repo.git +``` + +### Keeping the Clone After the Scan + +```bash +trufflehog git https://github.com/myorg/myrepo.git --clone-path /tmp/my-clones --no-cleanup +``` + +## Pre Commit Hook Use + +TruffleHog notices when it is being run as a pre commit hook and changes some settings on its own: + +- Local Git config is trusted +- Only staged changes are scanned +- Only verified and unknown results are shown +- The run fails if anything is found, which stops the commit + +It detects this from these environment variables: + +| Variable | Set by | +|----------|--------| +| `PRE_COMMIT=1` | The pre-commit framework | +| `HUSKY=1` | Husky, modern versions | +| `HUSKY_GIT_PARAMS` | Husky, versions below 4.0 | +| `TRUFFLEHOG_PRE_COMMIT=1` | Set by hand in a plain Git hook script | + +For a plain Git hook with no framework, export the variable yourself in `.git/hooks/pre-commit`: + +```bash +export TRUFFLEHOG_PRE_COMMIT=1 +``` + +## Troubleshooting + +### Common Issues + +**Issue**: `'git' command not found in $PATH` +**Solution**: Install Git and make sure it is on your `PATH`. The version must be 2.20.0 or newer and below 3.0.0. + +--- + +**Issue**: Authentication failures when cloning a private repository +**Solution**: For HTTPS, check the username and token in the URL or in the config, and that the token can read the repository. For SSH, check that the key on the machine is loaded and accepted by the host. + +--- + +**Issue**: `--no-cleanup can only be used together with --clone-path` +**Solution**: `--no-cleanup` keeps the clone in place, so a path to keep it in is needed. Pass `--clone-path` as well, pointing at a directory that already exists. + +--- + +**Issue**: Running out of disk space during a scan +**Solution**: Every repository is cloned in full, so a large history needs a lot of space. Drop `--no-cleanup` so clones are deleted after each scan, or use `--since-commit` on a `github.com` repository so the clone is shallow. + +--- + +**Issue**: The scan is slow on a large repository +**Solution**: Narrow it with `--branch`, `--max-depth`, or `--since-commit`. Use `--exclude-globs` rather than `--exclude-paths`, since globs are filtered inside `git log` and those files are never read. + +--- + +**Issue**: Clones keep failing on a host that rate limits +**Solution**: TruffleHog already retries a rate limited clone 3 times, waiting longer between each try. If it still fails, wait and scan fewer repositories at once. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.97.5/pkg/sources/git/git.go new/trufflehog-3.97.6/pkg/sources/git/git.go --- old/trufflehog-3.97.5/pkg/sources/git/git.go 2026-09-16 18:56:14.000000000 +0200 +++ new/trufflehog-3.97.6/pkg/sources/git/git.go 2026-09-22 15:03:47.000000000 +0200 @@ -856,6 +856,13 @@ repoCtx = ctx } + // The scan can stop before the diff channel is drained, on max depth or on + // reaching the base commit. Nothing else tells the parser that, so cancelling on + // the way out is what shuts down the git processes still producing diffs. Without + // it they sit blocked on a channel nobody is reading until the whole scan ends. + repoCtx, cancel := context.WithCancel(repoCtx) + defer cancel() + logger := repoCtx.Logger() var logValues []any if scanOptions.BaseHash != "" { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/trufflehog-3.97.5/pkg/sources/git/git_test.go new/trufflehog-3.97.6/pkg/sources/git/git_test.go --- old/trufflehog-3.97.5/pkg/sources/git/git_test.go 2026-09-16 18:56:14.000000000 +0200 +++ new/trufflehog-3.97.6/pkg/sources/git/git_test.go 2026-09-22 15:03:47.000000000 +0200 @@ -115,6 +115,62 @@ assert.False(t, isRetryableCloneError(nil)) } +func TestCloneRetryDelay(t *testing.T) { + rateLimitErr := errors.New("The requested URL returned error: 429") + networkErr := errors.New("fatal: early EOF") + + // Rate limits take longer to clear, so they back off from a much larger + // base than transient network errors. + assert.Equal(t, cloneRateLimitBackoff, cloneRetryDelay(rateLimitErr, 1)) + assert.Equal(t, 2*cloneRateLimitBackoff, cloneRetryDelay(rateLimitErr, 2)) + + assert.Equal(t, cloneRetryBackoff, cloneRetryDelay(networkErr, 1)) + assert.Equal(t, 2*cloneRetryBackoff, cloneRetryDelay(networkErr, 2)) +} + +func TestStripPassword(t *testing.T) { + tests := []struct { + name string + url string + wantURL string + wantPassword string + }{ + { + name: "username and password are removed", + url: "https://user:[email protected]/org/repo.git", + wantURL: "https://github.com/org/repo.git", + wantPassword: "pass", + }, + { + name: "username without a password is removed", + url: "https://[email protected]/org/repo.git", + wantURL: "https://github.com/org/repo.git", + wantPassword: "", + }, + { + name: "url without credentials is unchanged", + url: "https://github.com/org/repo.git", + wantURL: "https://github.com/org/repo.git", + wantPassword: "", + }, + { + name: "scp style git@ url is returned as is", + url: "[email protected]:org/repo.git", + wantURL: "[email protected]:org/repo.git", + wantPassword: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotURL, gotPassword, err := stripPassword(tt.url) + assert.NoError(t, err) + assert.Equal(t, tt.wantURL, gotURL) + assert.Equal(t, tt.wantPassword, gotPassword) + }) + } +} + func TestCreateClonePath(t *testing.T) { t.Run("temp dir when clonePath is empty", func(t *testing.T) { path, err := createClonePath("https://github.com/org/repo.git", "") ++++++ trufflehog.obsinfo ++++++ --- /var/tmp/diff_new_pack.L8bIkn/_old 2026-09-23 18:18:19.846476007 +0200 +++ /var/tmp/diff_new_pack.L8bIkn/_new 2026-09-23 18:18:19.859476545 +0200 @@ -1,5 +1,5 @@ name: trufflehog -version: 3.97.5 -mtime: 1789577774 -commit: f714bf454f350590f4a24c3ddb1aef02c35bf5b6 +version: 3.97.6 +mtime: 1790082227 +commit: 64d939a56362f519781c53ea09b27f8d1dc0140a ++++++ vendor.tar.gz ++++++ /work/SRC/openSUSE:Factory/trufflehog/vendor.tar.gz /work/SRC/openSUSE:Factory/.trufflehog.new.383539/vendor.tar.gz differ: char 134, line 1
