Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
132 changes: 132 additions & 0 deletions pkg/detectors/braintrust/braintrust.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package braintrust

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
}

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

var (
defaultClient = common.SaneHttpClient()

// Braintrust API keys:
// Format: sk- + 48 alphanumeric characters (observed)
braintrustTokenPat = regexp.MustCompile(
`\b(sk-[A-Za-z0-9]{48})\b`,
)
)

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

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

// FromData scans for Braintrust API 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 braintrustTokenPat.FindAllStringSubmatch(dataStr, -1) {
uniqueTokens[match[1]] = struct{}{}
}

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

if verify {
verified, verificationErr := verifyBraintrustToken(
ctx,
s.getClient(),
token,
)
result.SetVerificationError(verificationErr, token)
result.Verified = verified
}

results = append(results, result)
}

return
}

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

req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
"https://api.braintrust.dev/v1/project?limit=1",
http.NoBody,
)
if err != nil {
return false, err
}

req.Header.Set("Authorization", "Bearer "+token)

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:
// Invalid or revoked token
return false, nil
case http.StatusForbidden:
// Valid token but lacks permission (still valid)
return true, nil
default:
return false, fmt.Errorf(
"unexpected HTTP response status %d",
res.StatusCode,
)
}
}

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

func (s Scanner) Description() string {
return "Braintrust is an AI evaluation and observability platform. Braintrust API keys can be used to access evaluation, observability, and AI development resources within the Braintrust platform."
}
178 changes: 178 additions & 0 deletions pkg/detectors/braintrust/braintrust_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
//go:build detectors
// +build detectors

package braintrust

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 TestBraintrust_FromData(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()

// Load secrets from GCP (same pattern as other detectors)
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("BRAINTRUST_API_KEY")
inactiveToken := "sk-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"

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 Braintrust API key %s", activeToken),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detectorspb.DetectorType_BrainTrustApiKey,
Verified: true,
Raw: []byte(activeToken),
Redacted: activeToken[:8] + "...",
},
},
},
{
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 Braintrust API key %s", activeToken),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detectorspb.DetectorType_BrainTrustApiKey,
Verified: false,
Raw: []byte(activeToken),
Redacted: activeToken[:8] + "...",
},
},
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 Braintrust API key %s", activeToken),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detectorspb.DetectorType_BrainTrustApiKey,
Verified: false,
Raw: []byte(activeToken),
Redacted: activeToken[:8] + "...",
},
},
wantVerificationErr: true,
},
{
name: "found, unverified (inactive token)",
s: Scanner{},
args: args{
ctx: context.Background(),
data: fmt.Appendf([]byte{}, "Using Braintrust API key %s", inactiveToken),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detectorspb.DetectorType_BrainTrustApiKey,
Verified: false,
Raw: []byte(inactiveToken),
Redacted: inactiveToken[:8] + "...",
},
},
},
{
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("Braintrust.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",
)

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

func BenchmarkBraintrust_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