Files
trufflehog/pkg/detectors/duo/duo.go
Muneeb Ullah KhanandAmaan Ullah a7bdcf9522 [INS-312] Duo API Secret Key Detector (#4771)
* created duo detector

* added docs reference

---------

Co-authored-by: Amaan Ullah <[email protected]>
2026-07-16 15:32:49 +05:00

222 lines
6.3 KiB
Go

package duo
import (
"context"
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"net/http"
"strings"
"time"
regexp "github.com/wasilibs/go-re2"
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
)
type Scanner struct {
client *http.Client
detectors.DefaultMultiPartCredentialProvider
}
// Ensure the Scanner satisfies the interface at compile time.
var _ detectors.Detector = (*Scanner)(nil)
var (
defaultClient = common.SaneHttpClient()
// Integration key is of 20 characters with only capital alphabets and digits.
integrationKeyPat = regexp.MustCompile(detectors.PrefixRegex([]string{"duo"}) + `\b(DI[A-Z0-9]{18})\b`)
// Secret key is of 40 characters with only alphabets and digits.
secretKeyPat = regexp.MustCompile(detectors.PrefixRegex([]string{"duo"}) + `\b([A-Za-z0-9]{40})\b`)
// Host is usually a subdomain of duosecurity.com e.g api-21321awda.duosecurity.com
apiHost = regexp.MustCompile(`\b([a-z0-9-]{6,}\.duosecurity\.com)\b`)
)
// Keywords are used for efficiently pre-filtering chunks.
// Use identifiers in the secret preferably, or the provider name.
func (s Scanner) Keywords() []string {
return []string{"duosecurity"}
}
func (s Scanner) getClient() *http.Client {
if s.client != nil {
return s.client
}
return defaultClient
}
// FromData will find and optionally verify DeepSeek secrets in a given set of bytes.
func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
dataStr := string(data)
uniqueHosts := make(map[string]struct{})
uniqueIntKeys := make(map[string]struct{})
uniqueSecretKeys := make(map[string]struct{})
for _, match := range apiHost.FindAllStringSubmatch(dataStr, -1) {
uniqueHosts[match[1]] = struct{}{}
}
for _, match := range integrationKeyPat.FindAllStringSubmatch(dataStr, -1) {
uniqueIntKeys[match[1]] = struct{}{}
}
for _, match := range secretKeyPat.FindAllStringSubmatch(dataStr, -1) {
uniqueSecretKeys[match[1]] = struct{}{}
}
for host := range uniqueHosts {
for apiKey := range uniqueIntKeys {
for apiSecret := range uniqueSecretKeys {
s1 := detectors.Result{
DetectorType: detector_typepb.DetectorType_Duo,
Raw: []byte(apiKey),
RawV2: []byte(fmt.Sprintf("%s:%s:%s", host, apiKey, apiSecret)),
ExtraData: map[string]string{
"application": "Admin API",
},
SecretParts: map[string]string{
"host": host,
"ikey": apiKey,
"skey": apiSecret,
"application": "Admin API",
},
}
if verify {
verified, verificationErr := VerifyAdminToken(ctx, s.getClient(), host, apiKey, apiSecret)
// we will try auth api verification if admin api verification fails without error,
// which is in the case of it returning 401 unauthorized due to invalid credentials.
if !verified && verificationErr == nil {
var authStatusCode int
verified, authStatusCode, verificationErr = VerifyAuthToken(ctx, s.getClient(), host, apiKey, apiSecret)
// The Auth API responds 403 (not 401) when ikey/skey are
// structurally valid Admin API keys, active or not. That still
// counts as verified, but the keys remain Admin API keys, so
// only relabel to Auth API when the check actually succeeded (200).
if authStatusCode != http.StatusForbidden {
s1.ExtraData["application"] = "Auth API"
s1.SecretParts["application"] = "Auth API"
}
}
s1.SetVerificationError(verificationErr, host, apiKey, apiSecret)
s1.Verified = verified
}
results = append(results, s1)
}
}
}
return results, nil
}
// returns verified=true if credentials are valid and belong to auth api, false if creds are invalid, and error if creds belong to auth api or for anything else (e.g., network error).
// The HTTP status code is also returned so callers can distinguish a genuine 200 from a
// 403, which the Auth API returns for structurally valid Admin API keys (active or not).
func VerifyAuthToken(
ctx context.Context,
client *http.Client,
host, ikey, skey string,
) (bool, int, error) {
// Docs: https://duo.com/docs/authapi#check
return verifyDuoRequest(
ctx,
client,
host,
ikey,
skey,
"/auth/v2/check",
)
}
// returns 401 unauthorized if credentials are invalid, 200 OK if valid, and error for anything else
func VerifyAdminToken(
ctx context.Context,
client *http.Client,
host, ikey, skey string,
) (bool, error) {
// Docs: https://duo.com/docs/adminapi#account-info
verified, _, err := verifyDuoRequest(
ctx,
client,
host,
ikey,
skey,
"/admin/v1/info/summary",
)
return verified, err
}
func verifyDuoRequest(
ctx context.Context,
client *http.Client,
host, ikey, skey string,
path string,
) (bool, int, error) {
timestamp := time.Now().UTC().Format(http.TimeFormat)
// Canonical request string
canonical := strings.Join([]string{
timestamp,
http.MethodGet,
host,
path,
"",
}, "\n")
// HMAC signature
mac := hmac.New(sha1.New, []byte(skey))
_, _ = mac.Write([]byte(canonical))
signature := hex.EncodeToString(mac.Sum(nil))
// Authorization header
auth := base64.StdEncoding.EncodeToString(
[]byte(ikey + ":" + signature),
)
// Build request
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
"https://"+host+path,
nil,
)
if err != nil {
return false, 0, err
}
req.Header.Set("Authorization", "Basic "+auth)
req.Header.Set("Date", timestamp)
res, err := client.Do(req)
if err != nil {
return false, 0, err
}
defer func() {
_, _ = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
}()
switch res.StatusCode {
case http.StatusOK:
return true, res.StatusCode, nil
case http.StatusUnauthorized:
return false, res.StatusCode, nil
case http.StatusForbidden: // Auth API returns 403 if credentials are admin keys be it active or inactive
return true, res.StatusCode, nil
default:
return false, res.StatusCode, fmt.Errorf("unexpected HTTP status %d", res.StatusCode)
}
}
func (s Scanner) Type() detector_typepb.DetectorType {
return detector_typepb.DetectorType_Duo
}
func (s Scanner) Description() string {
return "Duo is a security platform that provides multi-factor authentication and identity management services."
}