wip - oauth2 refactoring
This commit is contained in:
@@ -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
@@ -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,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 }
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user