wip - oauth2 refactoring

This commit is contained in:
Dave Hubbard
2026-09-15 15:22:32 -04:00
parent c61ed3b193
commit a3c45c493c
5 changed files with 92 additions and 160 deletions
+48 -110
View File
@@ -5,17 +5,14 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"net/http"
"net/url"
"regexp" //nolint:depguard // used instead of github.com/wasilibs/go-re2 due to differences in utf-8 handling
"slices"
"strings"
"sync"
"time"
"golang.org/x/oauth2"
"golang.org/x/sync/errgroup"
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
@@ -31,122 +28,64 @@ const maxTotalMatches = 100
// ─── OAuth2 token acquisition ────────────────────────────────────────
// tokenExpiryDelta is subtracted from the token's expiry time to
// avoid race conditions where the token expires between the check
// and the HTTP request.
const tokenExpiryDelta = 10 * time.Second
// ropcTokenSource implements TokenSource for the Resource Owner
// Password Credentials grant (RFC 6749 Section 4.3). It caches the
// current token and only contacts the token endpoint when the cached
// token is missing or about to expire.
// ropcTokenSource implements oauth2.TokenSource for the Resource Owner
// Password Credentials grant (RFC 6749 Section 4.3). Caching and
// expiry are handled by oauth2.ReuseTokenSource; this type only
// performs the token exchange.
type ropcTokenSource struct {
tokenEndpoint string
username string
password string
clientID string
clientSecret string
scope string
mu sync.Mutex
token string
expiry time.Time
conf *oauth2.Config
username string
password string
// httpCtx carries the HTTP client for TLS and timeout settings
// via the oauth2.HTTPClient context key. Stored at construction
// time so PasswordCredentialsToken uses our SaneHttpClient.
httpCtx context.Context
}
// newROPCTokenSource builds a token source from the proto config.
func newROPCTokenSource(auth *custom_detectorspb.VerifierAuth, ropc *custom_detectorspb.ROPCConfig) *ropcTokenSource {
return &ropcTokenSource{
tokenEndpoint: auth.GetTokenEndpoint(),
username: ropc.GetUsername(),
password: ropc.GetPassword(),
clientID: ropc.GetClientId(),
clientSecret: ropc.GetClientSecret(),
scope: ropc.GetScope(),
}
func (s *ropcTokenSource) Token() (*oauth2.Token, error) {
return s.conf.PasswordCredentialsToken(s.httpCtx, s.username, s.password)
}
// ropcTokenResponse is the standard OAuth2 token response body
// (RFC 6749 Section 5.1).
type ropcTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
func (s *ropcTokenSource) Token(ctx context.Context) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
// Return the cached token if it's still valid.
if s.token != "" && time.Now().Before(s.expiry) {
return s.token, nil
// newROPCTokenSource builds an oauth2.TokenSource for the ROPC grant,
// wrapped in ReuseTokenSource for automatic caching with a 10-second
// expiry buffer.
func newROPCTokenSource(auth *custom_detectorspb.VerifierAuth, ropc *custom_detectorspb.ROPCConfig) oauth2.TokenSource {
conf := &oauth2.Config{
ClientID: ropc.GetClientId(),
ClientSecret: ropc.GetClientSecret(),
Endpoint: oauth2.Endpoint{
TokenURL: auth.GetTokenEndpoint(),
AuthStyle: oauth2.AuthStyleInParams,
},
}
// POST form-encoded ROPC body per RFC 6749 Section 4.3.2.
// Only username, password, and grant_type are required.
// client_id and client_secret are conditional: included only when
// configured, supporting both public clients (no secret) and
// servers that use HTTP Basic auth for client authentication.
form := url.Values{
"grant_type": {"password"},
"username": {s.username},
"password": {s.password},
if scope := ropc.GetScope(); scope != "" {
conf.Scopes = strings.Split(scope, " ")
}
if s.clientID != "" {
form.Set("client_id", s.clientID)
// Inject SaneHttpClient so the token endpoint request uses our
// TLS configuration and timeout settings.
httpCtx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient)
base := &ropcTokenSource{
conf: conf,
username: ropc.GetUsername(),
password: ropc.GetPassword(),
httpCtx: httpCtx,
}
if s.clientSecret != "" {
form.Set("client_secret", s.clientSecret)
}
if s.scope != "" {
form.Set("scope", s.scope)
}
req, err := http.NewRequestWithContext(ctx, "POST", s.tokenEndpoint, strings.NewReader(form.Encode()))
if err != nil {
return "", fmt.Errorf("building ROPC token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("ROPC token request to %s: %w", s.tokenEndpoint, err)
}
defer func() {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("ROPC token endpoint returned %d: %s", resp.StatusCode, string(body))
}
var tokenResp ropcTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return "", fmt.Errorf("decoding ROPC token response: %w", err)
}
s.token = tokenResp.AccessToken
// Cache with a safety margin so we don't send an about-to-expire token.
s.expiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn)*time.Second - tokenExpiryDelta)
return s.token, nil
return oauth2.ReuseTokenSource(nil, base)
}
// customDetectorVerifier binds a VerifierConfig to an optional
// TokenSource. This avoids parallel slices and ensures the auth
// OAuth2TokenSource. This avoids parallel slices and ensures the auth
// config can never get out of sync with its verifier.
type customDetectorVerifier struct {
config *custom_detectorspb.VerifierConfig
tokenSource detectors.TokenSource // nil when no auth is configured
tokenSource detectors.OAuth2TokenSource // nil when no auth is configured
}
// BuildTokenSource creates the appropriate TokenSource for a
// BuildTokenSource creates the appropriate OAuth2TokenSource for a
// VerifierConfig's auth block, or returns nil if no auth is set.
// Exported so the enterprise pipeline can build token sources from
// proto config without duplicating grant-type logic.
func BuildTokenSource(auth *custom_detectorspb.VerifierAuth) detectors.TokenSource {
func BuildTokenSource(auth *custom_detectorspb.VerifierAuth) detectors.OAuth2TokenSource {
if auth == nil {
return nil
}
@@ -459,18 +398,17 @@ func (c *CustomRegexWebhook) createResults(ctx context.Context, match map[string
req.Header.Set("Content-Type", "application/json")
}
// If this verifier has OAuth2 auth, acquire a bearer token
// and attach it to the request.
// If this verifier has OAuth2 auth, build an authenticated client
// that transparently adds the Bearer token to each request.
client := httpClient
if v.tokenSource != nil {
token, err := v.tokenSource.Token(ctx)
if err != nil {
// Token acquisition failed — skip this verifier.
continue
}
req.Header.Set("Authorization", "Bearer "+token)
client = oauth2.NewClient(
context.WithValue(ctx, oauth2.HTTPClient, httpClient),
v.tokenSource,
)
}
resp, err := httpClient.Do(req)
resp, err := client.Do(req)
if err != nil {
continue
}
+10 -11
View File
@@ -11,6 +11,8 @@ import (
"strings"
"unicode"
"golang.org/x/oauth2"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/source_metadatapb"
@@ -90,17 +92,14 @@ type EndpointCustomizer interface {
SetCloudEndpoint(string)
UseCloudEndpoint(bool)
UseFoundEndpoints(bool)
SetTokenSource(TokenSource)
SetOAuth2TokenSource(OAuth2TokenSource)
}
// TokenSource abstracts OAuth2 token acquisition. Each grant type
// implements this interface with its own credential exchange and
// caching logic. Used by custom detector inline verification and by
// the engine's OAuth2 custom verifier path.
type TokenSource interface {
// Token returns a valid access token, refreshing it if necessary.
Token(ctx context.Context) (string, error)
}
// OAuth2TokenSource is the standard oauth2 token source interface,
// named here to make the OAuth2 scope explicit. Other auth mechanisms
// (API key rotation, mTLS, etc.) would define their own interfaces
// rather than being shoehorned into this one.
type OAuth2TokenSource = oauth2.TokenSource
// OAuthVerifier is satisfied by any detector whose EndpointSetter has
// an OAuth2 token source configured. The engine checks this interface
@@ -108,8 +107,8 @@ type TokenSource interface {
// through an OAuth2-authenticated POST instead of the detector's
// built-in verification logic.
type OAuthVerifier interface {
HasTokenSource() bool
GetTokenSource() TokenSource
HasOAuth2() bool
OAuth2TokenSource() OAuth2TokenSource
Endpoints(foundEndpoints ...string) []string
}
+9 -9
View File
@@ -9,14 +9,14 @@ import (
// EndpointSetter implements a sensible default for the SetEndpoints function
// of the EndpointCustomizer interface. A detector can embed this struct to
// gain the functionality. When a custom verifier has OAuth2 auth configured,
// the engine sets a TokenSource here so the scan loop can route verification
// the engine sets a token source here so the scan loop can route verification
// through the OAuthVerifier interface instead of the detector's built-in path.
type EndpointSetter struct {
configuredEndpoints []string
cloudEndpoint string
useCloudEndpoint bool
useFoundEndpoints bool
tokenSource TokenSource
oauth2Source OAuth2TokenSource
}
func (e *EndpointSetter) SetConfiguredEndpoints(userConfiguredEndpoints ...string) error {
@@ -54,15 +54,15 @@ func (e *EndpointSetter) Endpoints(foundEndpoints ...string) []string {
return endpoints
}
// SetTokenSource configures an OAuth2 token source for this detector's
// custom verifier. When set, the engine uses OAuthVerifier-based
// SetOAuth2TokenSource configures an OAuth2 token source for this
// detector's custom verifier. When set, the engine uses OAuthVerifier-based
// verification instead of the detector's built-in verification logic.
func (e *EndpointSetter) SetTokenSource(ts TokenSource) { e.tokenSource = ts }
func (e *EndpointSetter) SetOAuth2TokenSource(ts OAuth2TokenSource) { e.oauth2Source = ts }
// HasTokenSource reports whether OAuth2 auth is configured for this
// HasOAuth2 reports whether OAuth2 auth is configured for this
// detector's custom verifier endpoint.
func (e *EndpointSetter) HasTokenSource() bool { return e.tokenSource != nil }
func (e *EndpointSetter) HasOAuth2() bool { return e.oauth2Source != nil }
// GetTokenSource returns the configured OAuth2 token source, or nil
// OAuth2TokenSource returns the configured OAuth2 token source, or nil
// if no auth is configured.
func (e *EndpointSetter) GetTokenSource() TokenSource { return e.tokenSource }
func (e *EndpointSetter) OAuth2TokenSource() OAuth2TokenSource { return e.oauth2Source }
+4 -4
View File
@@ -115,7 +115,7 @@ type Config struct {
// a detector has a matching entry here, its token source is set
// on the detector's EndpointSetter so the engine can perform
// OAuth2-authenticated verification via the OAuthVerifier interface.
VerifierAuth map[config.DetectorID]detectors.TokenSource
VerifierAuth map[config.DetectorID]detectors.OAuth2TokenSource
// Verify determines whether the scanner will verify candidate secrets.
Verify bool
@@ -330,7 +330,7 @@ func NewEngine(ctx context.Context, cfg *Config) (*Engine, error) {
// OAuthVerifier to route verification through OAuth2.
// feature fm-oauth2: custom verifier OAuth2 verification
if ts, hasAuth := getWithDetectorID(d, cfg.VerifierAuth); hasAuth {
customizer.SetTokenSource(ts)
customizer.SetOAuth2TokenSource(ts)
}
return true
@@ -1184,12 +1184,12 @@ func (e *Engine) detectChunk(ctx context.Context, data detectableChunk) {
// feature fm-oauth2: custom verifier OAuth2 verification
var results []detectors.Result
var err error
if oauthV, ok := data.detector.Detector.(detectors.OAuthVerifier); ok && oauthV.HasTokenSource() {
if oauthV, ok := data.detector.Detector.(detectors.OAuthVerifier); ok && oauthV.HasOAuth2() {
results, err = data.detector.FromData(ctx, false, matchBytes)
if err == nil && data.verify && len(results) > 0 {
for i := range results {
verified, verifyErr := OAuthVerify(
ctx, nil, oauthV.GetTokenSource(), oauthV.Endpoints(), &results[i])
ctx, nil, oauthV.OAuth2TokenSource(), oauthV.Endpoints(), &results[i])
results[i].Verified = verified
results[i].SetVerificationError(verifyErr, string(results[i].Raw))
}
+21 -26
View File
@@ -1,7 +1,8 @@
// oauth_verifier.go provides the standalone OAuth2 verification function
// used by the engine scan loop when a detector has a custom verifier with
// OAuth2 auth configured. The function acquires a Bearer token and POSTs
// the detected credential to the custom verifier endpoint.
// OAuth2 auth configured. The function builds an authenticated HTTP client
// via oauth2.NewClient and POSTs the detected credential to the custom
// verifier endpoint.
//
// This file contains only HTTP/verification logic. Configuration state
// (token source, endpoints) lives on EndpointSetter; orchestration lives
@@ -19,6 +20,8 @@ import (
"io"
"net/http"
"golang.org/x/oauth2"
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
)
@@ -40,29 +43,34 @@ type oauthVerifyResponse struct {
Verified bool `json:"verified"`
}
var defaultOAuthHTTPClient = common.SaneHttpClient()
var defaultOAuthBaseClient = common.SaneHttpClient()
// OAuthVerify acquires a Bearer token from the given TokenSource and
// POSTs the credential to each endpoint until one returns a definitive
// answer. Response protocol:
// OAuthVerify builds an OAuth2-authenticated HTTP client and POSTs
// the credential to each endpoint until one returns a definitive
// answer. The token source handles caching and refresh via
// oauth2.ReuseTokenSource; the client adds the Bearer header
// transparently via oauth2.Transport. Response protocol:
//
// 200 {"verified": true} — credential is valid
// 200 {"verified": false} — credential checked, not valid
// 401 — token rejected, stop immediately
// other — transient/unexpected, try next endpoint
func OAuthVerify(ctx context.Context, client *http.Client, ts detectors.TokenSource, endpoints []string, result *detectors.Result) (bool, error) {
func OAuthVerify(ctx context.Context, baseClient *http.Client, ts detectors.OAuth2TokenSource, endpoints []string, result *detectors.Result) (bool, error) {
if len(endpoints) == 0 {
return false, fmt.Errorf("no verification endpoints configured")
}
if client == nil {
client = defaultOAuthHTTPClient
if baseClient == nil {
baseClient = defaultOAuthBaseClient
}
token, err := ts.Token(ctx)
if err != nil {
return false, fmt.Errorf("acquiring OAuth2 token for verification: %w", err)
}
// Build an authenticated client. oauth2.NewClient wraps the base
// client's transport with oauth2.Transport, which calls
// ts.Token() per request and sets the Authorization header.
client := oauth2.NewClient(
context.WithValue(ctx, oauth2.HTTPClient, baseClient),
ts,
)
reqBody := oauthVerifyRequest{
DetectorType: result.DetectorType.String(),
@@ -76,19 +84,16 @@ func OAuthVerify(ctx context.Context, client *http.Client, ts detectors.TokenSou
// Try each endpoint until we get a definitive answer.
var lastErr error
var retried bool
for _, endpoint := range endpoints {
if common.IsDone(ctx) {
return false, ctx.Err()
}
retryWithNewToken:
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(bodyBytes))
if err != nil {
lastErr = err
continue
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
@@ -108,16 +113,6 @@ func OAuthVerify(ctx context.Context, client *http.Client, ts detectors.TokenSou
}
return parsed.Verified, nil
case http.StatusUnauthorized:
// Token may have expired. Re-acquire once and retry
// the same endpoint if we get a different token.
if !retried {
retried = true
newToken, err := ts.Token(ctx)
if err == nil && newToken != token {
token = newToken
goto retryWithNewToken
}
}
return false, fmt.Errorf("OAuth2 token rejected by verifier (401)")
default:
lastErr = fmt.Errorf("verifier returned HTTP %d: %s", resp.StatusCode, string(respBody))