Files
trufflehog/pkg/sources/git/metrics.go
Kashif Khan cc1fe982af
Lint / golangci-lint (push) Waiting to run
Lint / man-page-staleness (push) Waiting to run
Lint / semgrep (push) Waiting to run
Lint / checksecretparts (push) Waiting to run
Scan for secrets / test (push) Waiting to run
Test / test (push) Waiting to run
Test / test-community (push) Waiting to run
Release / mark-latest (push) Canceled after 0s
Release / Release (push) Canceled after 0s
Retry git clone on secondary rate limit (bare 403/429) (#5260)
Clone-time 403s were classified as permanent permission errors and
failed the scan immediately, even though a bare 403 (no auth/remote
denial message) matches GitHub/GitLab secondary rate limiting, which
per their docs returns 403 or 429. Retry those like 429 with backoff.
2026-09-01 20:39:28 +05:00

204 lines
6.7 KiB
Go

package git
import (
"fmt"
"strings"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
)
// metricsCollector defines the interface for recording Git scan metrics.
type metricsCollector interface {
// Clone metrics
RecordCloneOperation(status string, reason string, exitCode int)
// Scan metrics
RecordCommitScanned()
RecordRepoScanned(status string)
}
// Predefined status values
const (
statusSuccess = "success"
statusFailure = "failure"
)
// Predefined clone success reason
const (
cloneSuccess = "success"
)
// Predefined clone failure reasons to avoid high cardinality
const (
// Authentication/redirection errors
cloneFailureAuth = "auth_error"
// Rate limiting errors
cloneFailureRateLimit = "rate_limit"
// Permission errors
cloneFailurePermission = "permission_denied"
// Network/connection errors
cloneFailureNetwork = "network_error"
// Git reference errors
cloneFailureReference = "reference_error"
// Other/unknown errors
cloneFailureOther = "other_error"
)
type collector struct {
cloneOperations *prometheus.CounterVec
commitsScanned prometheus.Counter
reposScanned *prometheus.CounterVec
}
var metricsInstance metricsCollector
func init() {
// These are package-level metrics that are incremented by all git scans across the lifetime of the process.
metricsInstance = &collector{
cloneOperations: promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: common.MetricsNamespace,
Subsystem: common.MetricsSubsystem,
Name: "git_clone_operations_total",
Help: "Total number of git clone operations by status, reason, and exit code",
}, []string{"status", "reason", "exit_code"}),
commitsScanned: promauto.NewCounter(prometheus.CounterOpts{
Namespace: common.MetricsNamespace,
Subsystem: common.MetricsSubsystem,
Name: "git_commits_scanned_total",
Help: "Total number of git commits scanned",
}),
reposScanned: promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: common.MetricsNamespace,
Subsystem: common.MetricsSubsystem,
Name: "git_repos_scanned_total",
Help: "Total number of git repositories scanned by status (success/failure)",
}, []string{"status"}),
}
}
func (c *collector) RecordCloneOperation(status string, reason string, exitCode int) {
c.cloneOperations.WithLabelValues(status, reason, fmt.Sprintf("%d", exitCode)).Inc()
}
func (c *collector) RecordCommitScanned() {
c.commitsScanned.Inc()
}
func (c *collector) RecordRepoScanned(status string) {
c.reposScanned.WithLabelValues(status).Inc()
}
// ClassifyCloneError analyzes the error message and returns the appropriate failure reason
func ClassifyCloneError(errMsg string) string {
switch {
case strings.Contains(errMsg, "unable to update url base from redirection") &&
strings.Contains(errMsg, "redirect:") && strings.Contains(errMsg, "users/sign_in"):
return cloneFailureAuth
// Checked before the generic 403/429 case below: when the git backend
// itself explains a 403/429 with a "remote:" line, that's a permanent
// failure (permission denial, SAML SSO enforcement, org policy, etc.),
// not throttling — the backend only sends an explanatory message once
// the request has been evaluated, whereas secondary-rate-limit responses
// are either a bare 403/429 with no remote explanation or one of the
// small, stable set of known throttling messages. Matching on "any
// unrecognized remote: explanation" avoids having to enumerate every
// provider's denial wording, which changes per provider and per feature
// (SSO, fine-grained tokens, GitHub Apps, ...).
case isRemotePermissionDenial(errMsg):
return cloneFailurePermission
case strings.Contains(errMsg, "The requested URL returned error: 429") ||
strings.Contains(errMsg, "The requested URL returned error: 403") ||
strings.Contains(errMsg, "remote: Retry later"):
// A bare "403" during clone (no accompanying auth/permission message)
// matches GitHub/GitLab secondary rate limiting and abuse-detection
// responses, which are documented to return either 403 or 429.
return cloneFailureRateLimit
case strings.Contains(errMsg, "RPC failed") ||
strings.Contains(errMsg, "unexpected disconnect") ||
strings.Contains(errMsg, "early EOF") ||
strings.Contains(errMsg, "Problem (3) in the Chunked-Encoded data"):
return cloneFailureNetwork
case strings.Contains(errMsg, "cannot process") ||
strings.Contains(errMsg, "multiple updates for ref") ||
strings.Contains(errMsg, "invalid index-pack output"):
return cloneFailureReference
default:
return cloneFailureOther
}
}
// knownRateLimitRemoteMessages are the (small, stable) set of ways GitHub and
// GitLab phrase an actual throttling response in a "remote:" line. Any other
// "remote:" explanation accompanying a 403/429 is treated as a permanent
// failure rather than added here, since provider denial wording (permission,
// SSO, org policy, ...) varies far more than throttling wording does.
var knownRateLimitRemoteMessages = []string{
"retry later",
"secondary rate limit",
"rate limit exceeded",
"abuse detection",
}
// knownBenignRemoteProgressMessages are the small, standardized set of
// progress lines git-upload-pack prints to "remote:" during a normal clone
// (e.g. "remote: Counting objects: 100% (5/5), done."). These can appear
// ahead of an unrelated 403/429 or network failure, so they must not be
// mistaken for a denial explanation.
var knownBenignRemoteProgressMessages = []string{
"enumerating objects",
"counting objects",
"compressing objects",
"writing objects",
"resolving deltas",
"total ",
}
// knownBenignRemoteMessages is the combined set of "remote:" line contents
// that must NOT be treated as a permission denial: known throttling messages
// and known clone-progress output.
var knownBenignRemoteMessages = append(append([]string{}, knownRateLimitRemoteMessages...), knownBenignRemoteProgressMessages...)
// isRemotePermissionDenial reports whether errMsg contains a server-side
// "remote:" line explaining a 403/429 as something other than throttling or
// normal clone progress output, as opposed to a bare curl-level 403/429 with
// no explanation (or an explicit rate-limit message), which may just be
// transient rate limiting.
func isRemotePermissionDenial(errMsg string) bool {
if !strings.Contains(errMsg, "403") && !strings.Contains(errMsg, "429") {
return false
}
for _, line := range strings.Split(errMsg, "\n") {
idx := strings.Index(line, "remote:")
if idx == -1 {
continue
}
remoteMsg := strings.ToLower(line[idx:])
benign := false
for _, marker := range knownBenignRemoteMessages {
if strings.Contains(remoteMsg, marker) {
benign = true
break
}
}
if !benign {
return true
}
}
return false
}