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
133 changes: 133 additions & 0 deletions pkg/detectors/braintrust/braintrust.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
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
detectors.DefaultMultiPartCredentialProvider
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This detector has a single credential

}

// 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. This detector identifies Braintrust API keys."
}
Comment on lines +131 to +133
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Description() text is surfaced to customers in the enterprise UI, so mentioning "This detector identifies..." can be a bit confusing in that context. The pattern we follow is:

  1. First sentence: What the service/platform is.
  2. Second sentence: What risk the exposed credential poses.

Something like: "Braintrust is an AI evaluation and observability platform. Braintrust API keys can be used to access and manage projects, experiments, and evaluation data."
(Feel free to adjust the second sentence based on what the key actually grants access to.)

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
Loading