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
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.
204 lines
6.7 KiB
Go
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
|
|
}
|