engine: route OAuth2-authenticated verification through OAuthVerifier

Add VerifierAuth to engine.Config so callers can pass pre-built token
sources keyed by detector name. During engine init, the custom verifier
filter attaches each token source to its detector's EndpointSetter via
SetOAuth2TokenSource.

In detectChunk, when a detector satisfies OAuthVerifier and has a
token source configured, verification bypasses the built-in path
entirely: the detector runs with verify=false to extract candidates,
then each result is verified via OAuth2-authenticated POST through
OAuthVerify in oauth_verifier.go.

OAuthVerify builds an authenticated HTTP client, POSTs the result
using a customer-defined request body template with $token references,
and interprets the HTTP status code against successRanges/rotatedRanges.
All log messages inherit the oauth2_trace correlation ID from the token
source via TracedTokenSource.EnrichContext.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Dave Hubbard
2026-09-17 16:33:38 -04:00
co-authored by Cursor
parent 4ff164ee9d
commit 9c1b855f71
2 changed files with 151 additions and 10 deletions
+62 -10
View File
@@ -110,6 +110,16 @@ type Config struct {
ExcludeDetectors string
CustomVerifiersOnly bool
VerifierEndpoints map[string]string
// VerifierAuth holds OAuth2 token sources for custom verifiers,
// keyed by detector name (matching VerifierEndpoints keys). When
// 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.OAuth2TokenSource
// NoIgnoreTag disables the "trufflehog:ignore" tag. If set to true, results are
// reported even when the line they were found on carries the tag.
NoIgnoreTag bool
// Verify determines whether the scanner will verify candidate secrets.
Verify bool
@@ -126,10 +136,6 @@ type Config struct {
FilterUnverified bool
ShouldScanEntireChunk bool
// NoIgnoreTag disables the "trufflehog:ignore" tag. If set to true, results are
// reported even when the line they were found on carries the tag.
NoIgnoreTag bool
Dispatcher ResultsDispatcher
// SourceManager orchestrates source enumeration and concurrent chunk production.
@@ -326,6 +332,13 @@ func NewEngine(ctx context.Context, cfg *Config) (*Engine, error) {
return false
}
// If the custom verifier has OAuth2 auth configured, attach
// the token source to the detector. The scan loop checks
// OAuthVerifier to route verification through OAuth2.
if ts, hasAuth := getWithDetectorID(d, cfg.VerifierAuth); hasAuth {
customizer.SetOAuth2TokenSource(ts)
}
return true
})
}
@@ -1170,12 +1183,51 @@ func (e *Engine) detectChunk(ctx context.Context, data detectableChunk) {
t := time.AfterFunc(detectionTimeout+1*time.Second, func() {
ctx.Logger().Error(nil, "a detector ignored the context timeout")
})
results, err := e.verificationCache.FromData(
ctx,
data.detector.Detector,
data.verify,
data.chunk.SecretID != 0,
matchBytes)
// When the detector has OAuth2 auth on its custom verifier,
// bypass the built-in verification path entirely: detect only,
// then verify each result via OAuth2-authenticated POST. This
// intentionally skips verificationCache — the built-in cache
// wraps FromData with verify=true, which would run the
// detector's unauthenticated verification. The OAuth path
// replaces that with its own authenticated verification loop.
// Token caching is handled separately by oauth2.ReuseTokenSource.
var results []detectors.Result
var err error
if oauthV, ok := data.detector.Detector.(detectors.OAuthVerifier); ok && oauthV.HasOAuth2() {
// Extract the trace from the token source and set it on
// the context once. All downstream logs inherit it.
oauthCtx := ctx
if ts, ok := oauthV.OAuth2TokenSource().(*detectors.TracedTokenSource); ok {
oauthCtx = ts.EnrichContext(ctx)
}
oauthCtx.Logger().Info("using OAuth2 verification path")
results, err = data.detector.FromData(ctx, false, matchBytes)
if err == nil && data.verify && len(results) > 0 {
// Build VerifyEndpoints from the detector's endpoint
// list. Callers can populate headers, ranges, and
// body templates on the config; until then, endpoints
// use the default (200 = verified).
configs := make([]OAuthVerifyConfig, len(oauthV.Endpoints()))
for i, ep := range oauthV.Endpoints() {
configs[i] = OAuthVerifyConfig{Endpoint: ep}
}
for i := range results {
verified, verifyErr := OAuthVerify(
oauthCtx, nil, oauthV.OAuth2TokenSource(), configs, &results[i])
results[i].Verified = verified
results[i].SetVerificationError(verifyErr, string(results[i].Raw))
}
}
} else {
results, err = e.verificationCache.FromData(
ctx,
data.detector.Detector,
data.verify,
data.chunk.SecretID != 0,
matchBytes)
}
t.Stop()
cancel()
if err != nil {
+89
View File
@@ -0,0 +1,89 @@
// oauth_verifier.go adapts the engine's per-detector OAuth2 config into
// the shared VerifyCredential loop. The engine scan loop calls OAuthVerify
// when a detector has a custom verifier with OAuth2 auth configured.
//
// All core verification logic (body template resolution, header
// application, OAuth client wrapping, status code interpretation,
// endpoint fallback, response capture, outcome logging) lives in the
// shared VerifyCredential function in pkg/custom_detectors/verify.go.
package engine
import (
"context"
"fmt"
"net/http"
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
logContext "github.com/trufflesecurity/trufflehog/v3/pkg/context"
custom_detectors "github.com/trufflesecurity/trufflehog/v3/pkg/custom_detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
)
var defaultOAuthBaseClient = common.SaneHttpClient()
// OAuthVerifyConfig holds the per-endpoint settings the engine needs
// to perform OAuth2-authenticated verification. It mirrors the
// relevant fields from VerifierConfig without importing the proto
// directly into the detectors package.
type OAuthVerifyConfig struct {
Endpoint string
SuccessRanges []string
RotatedRanges []string
// RequestBody is the customer-defined body template with $token
// references. Nil means use a default body with just the secret.
RequestBody map[string]string
Headers []string
}
// OAuthVerify converts the engine's OAuthVerifyConfig list into the
// shared VerifyEndpoint format and delegates to VerifyCredential.
// The caller is expected to set "oauth2_trace" on the context's logger
// before calling so all log messages carry the correlation ID.
func OAuthVerify(
ctx context.Context,
baseClient *http.Client,
ts detectors.OAuth2TokenSource,
configs []OAuthVerifyConfig,
result *detectors.Result,
) (bool, error) {
logger := logContext.AddLogger(ctx).Logger()
if len(configs) == 0 {
logger.Error(nil, "no verification endpoints configured",
"detector_type", result.DetectorType.String(),
"detector_name", result.DetectorName,
)
return false, fmt.Errorf("no verification endpoints configured")
}
if baseClient == nil {
baseClient = defaultOAuthBaseClient
}
// Convert engine configs to the shared VerifyEndpoint format.
// All endpoints share the same token source in the engine path.
endpoints := make([]custom_detectors.VerifyEndpoint, len(configs))
for i, cfg := range configs {
endpoints[i] = custom_detectors.VerifyEndpoint{
URL: cfg.Endpoint,
Headers: cfg.Headers,
SuccessRanges: cfg.SuccessRanges,
RotatedRanges: cfg.RotatedRanges,
Body: cfg.RequestBody,
TokenSource: ts,
}
}
vars := map[string]string{
"$secret": string(result.Raw),
"$detector_type": result.DetectorType.String(),
"$detector_name": result.DetectorName,
}
outcome := custom_detectors.VerifyCredential(ctx, baseClient, endpoints, result.Raw, vars)
if !outcome.Definitive && outcome.RangesInEffect {
return false, fmt.Errorf("all verification endpoints exhausted; no status code matched configured ranges")
}
return outcome.Verified, nil
}