Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
63d0f95
Added Duffel Test Token Detector
MuneebUllahKhan222 Mar 6, 2026
fef28e3
Fixed invalid pattern test input
MuneebUllahKhan222 Mar 6, 2026
4fcdb2e
resolved bugbot comment
MuneebUllahKhan222 Mar 6, 2026
3f3302e
resolved bugbot comment and updated description
MuneebUllahKhan222 Apr 27, 2026
3d27258
registered detector in defaults.go
MuneebUllahKhan222 Apr 27, 2026
d6677be
resolved bugbot comment
MuneebUllahKhan222 Apr 27, 2026
fc15dbd
Merged main
MuneebUllahKhan222 Apr 27, 2026
8fccf52
regen protos and added secret parts
MuneebUllahKhan222 Apr 27, 2026
c18b939
removed print statement
MuneebUllahKhan222 Apr 27, 2026
334d52b
removed DefaultMultiPartCredentialProvider
MuneebUllahKhan222 Apr 27, 2026
f5cae40
Merge branch 'main' into duffeltest-detector
MuneebUllahKhan222 Apr 27, 2026
a6a97f7
ignored secret parts in test
MuneebUllahKhan222 Apr 27, 2026
27ac210
Merge remote-tracking branch 'origin' into duffeltest-detector
MuneebUllahKhan222 Jun 29, 2026
fe274e0
renamed detector, introduce feature flag gating and fix integration t…
MuneebUllahKhan222 Jun 29, 2026
4afa01a
fix: fix bugbot comment
MuneebUllahKhan222 Jun 29, 2026
f3c209a
addressed review comments
MuneebUllahKhan222 Jul 2, 2026
0d9a524
Merge remote-tracking branch 'origin' into duffeltest-detector
MuneebUllahKhan222 Jul 2, 2026
1edbb2c
resolved bugbot comment
MuneebUllahKhan222 Jul 2, 2026
5d57d10
Merge remote-tracking branch 'origin' into duffeltest-detector
MuneebUllahKhan222 Jul 7, 2026
91cb3f0
regex protos
MuneebUllahKhan222 Jul 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions pkg/detectors/duffeltesttoken/duffeltesttoken.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package duffeltesttoken

import (
"context"
"fmt"
"io"
"net/http"

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/detectorspb"
)

type Scanner struct {
client *http.Client
detectors.DefaultMultiPartCredentialProvider
}

// Compile-time interface check
var _ detectors.Detector = (*Scanner)(nil)

var (
defaultClient = common.SaneHttpClient()

// Duffel test token pattern
// Format: duffel_test_ + 43 alphanumeric / dash / underscore characters
duffelTestTokenPat = regexp.MustCompile(
`\b(duffel_test_[A-Za-z0-9_-]{43})(?:$|[^A-Za-z0-9_-])`,
)
)

// Keywords used for fast pre-filtering
func (s Scanner) Keywords() []string {
return []string{"duffel_test_"}
}

func (s Scanner) getClient() *http.Client {
if s.client != nil {
return s.client
}
return defaultClient
}

// FromData scans for Duffel test tokens and optionally verifies them
func (s Scanner) FromData(
ctx context.Context,
verify bool,
data []byte,
) (results []detectors.Result, err error) {

dataStr := string(data)

uniqueTokens := make(map[string]struct{})
for _, match := range duffelTestTokenPat.FindAllStringSubmatch(dataStr, -1) {
uniqueTokens[match[1]] = struct{}{}
}

for token := range uniqueTokens {
result := detectors.Result{
DetectorType: detectorspb.DetectorType_DuffelTestToken,
Raw: []byte(token),
Redacted: token[:15] + "...",
}

if verify {
verified, verificationErr := verifyDuffelToken(
ctx,
s.getClient(),
token,
)

result.SetVerificationError(verificationErr, token)
result.Verified = verified
}

results = append(results, result)
}

return
}

func verifyDuffelToken(
ctx context.Context,
client *http.Client,
token string,
) (bool, error) {

req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
"https://api.duffel.com/identity/customer/users?limit=1",
http.NoBody,
)
if err != nil {
return false, err
}

req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Duffel-Version", "v2")
req.Header.Set("Accept", "application/json")

res, err := client.Do(req)
if err != nil {
return false, err
}

defer func() {
_, _ = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
}()

switch res.StatusCode {

case http.StatusOK:
return true, nil

case http.StatusUnauthorized, http.StatusForbidden:
// Token invalid or revoked
return false, nil

default:
return false, fmt.Errorf(
"unexpected HTTP response status %d",
res.StatusCode,
)
}
}

func (s Scanner) Type() detectorspb.DetectorType {
return detectorspb.DetectorType_DuffelTestToken
}

func (s Scanner) Description() string {
return "Duffel is a flight search and booking API service. Duffel test API tokens can be used to access and interact with flight search and booking APIs in test environments."
}
177 changes: 177 additions & 0 deletions pkg/detectors/duffeltesttoken/duffeltesttoken_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
//go:build detectors
// +build detectors

package duffeltesttoken

import (
"context"
"fmt"
"testing"
"time"

"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"

"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb"
)

func TestDuffelTestToken_FromData(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()

testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors6")
if err != nil {
t.Fatalf("could not get test secrets from GCP: %s", err)
}

activeToken := testSecrets.MustGetField("DUFFEL_TEST_TOKEN")

inactiveToken := "duffel_test_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"

type args struct {
ctx context.Context
data []byte
verify bool
}

tests := []struct {
name string
s Scanner
args args
want []detectors.Result
wantErr bool
wantVerificationErr bool
}{
{
name: "found, verified",
s: Scanner{},
args: args{
ctx: context.Background(),
data: fmt.Appendf([]byte{}, "Using Duffel token %s for API", activeToken),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detectorspb.DetectorType_DuffelTestToken,
Verified: true,
Raw: []byte(activeToken),
},
},
},
{
name: "found, real token, verification error due to timeout",
s: Scanner{client: common.SaneHttpClientTimeOut(1 * time.Microsecond)},
args: args{
ctx: context.Background(),
data: fmt.Appendf([]byte{}, "Using Duffel token %s for API", activeToken),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detectorspb.DetectorType_DuffelTestToken,
Verified: false,
Raw: []byte(activeToken),
},
},
wantVerificationErr: true,
},
{
name: "found, real token, verification error due to unexpected api surface",
s: Scanner{client: common.ConstantResponseHttpClient(500, "{}")},
args: args{
ctx: context.Background(),
data: fmt.Appendf([]byte{}, "Using Duffel token %s for API", activeToken),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detectorspb.DetectorType_DuffelTestToken,
Verified: false,
Raw: []byte(activeToken),
},
},
wantVerificationErr: true,
},
{
name: "found, unverified (inactive token)",
s: Scanner{},
args: args{
ctx: context.Background(),
data: fmt.Appendf([]byte{}, "Using Duffel token %s for API", inactiveToken),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detectorspb.DetectorType_DuffelTestToken,
Verified: false,
Raw: []byte(inactiveToken),
},
},
},
{
name: "not found",
s: Scanner{},
args: args{
ctx: context.Background(),
data: []byte("no secrets here"),
verify: true,
},
want: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.s.FromData(tt.args.ctx, tt.args.verify, tt.args.data)
if (err != nil) != tt.wantErr {
t.Fatalf("DuffelTestToken.FromData() error = %v, wantErr %v", err, tt.wantErr)
}

for i := range got {
if len(got[i].Raw) == 0 {
t.Fatal("no raw secret present")
}

if (got[i].VerificationError() != nil) != tt.wantVerificationErr {
t.Fatalf(
"wantVerificationError = %v, verification error = %v",
tt.wantVerificationErr,
got[i].VerificationError(),
)
}
}

ignoreOpts := cmpopts.IgnoreFields(
detectors.Result{},
"ExtraData",
"verificationError",
"primarySecret",
"Redacted",
)

if diff := cmp.Diff(got, tt.want, ignoreOpts); diff != "" {
t.Errorf("DuffelTestToken.FromData() %s diff: (-got +want)\n%s", tt.name, diff)
}
})
}
}

func BenchmarkDuffelTestToken_FromData(b *testing.B) {
ctx := context.Background()
s := Scanner{}

for name, data := range detectors.MustGetBenchmarkData() {
b.Run(name, func(b *testing.B) {
b.ResetTimer()

for n := 0; n < b.N; n++ {
_, err := s.FromData(ctx, false, data)
if err != nil {
b.Fatal(err)
}
}
})
}
}
Loading