feat(detectors): add Resend detector (#5103)

Adds a TruffleHog detector for Resend (https://resend.com), an email API
for transactional and marketing email.

Key format: re_[1-9A-HJ-NP-Za-km-z]{8}_[1-9A-HJ-NP-Za-km-z]{24}
(base58 alphabet, excluding the ambiguous characters 0, O, I, and l).

Co-authored-by: Shahzad Haider <[email protected]>
Co-authored-by: meredith <[email protected]>
This commit is contained in:
Mateusz Wos
2026-09-09 10:33:58 -05:00
committed by GitHub
co-authored by Shahzad Haider meredith
parent 54ff78b9a8
commit e58aec687e
9 changed files with 447 additions and 7 deletions
+1
View File
@@ -580,6 +580,7 @@ func run(state overseer.State, logSync func() error) {
feature.NewRelicMobileAppTokenDetectorEnabled.Store(true)
feature.MSTeamsWebhookV2DetectorEnabled.Store(true)
feature.SolarwindsDetectorEnabled.Store(true)
feature.ResendDetectorEnabled.Store(true)
feature.WeightsAndBiasesV2DetectorEnabled.Store(true)
feature.HumioAPITokenDetectorEnabled.Store(true)
+149
View File
@@ -0,0 +1,149 @@
package resend
import (
"context"
"encoding/json"
"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/detector_typepb"
)
type Scanner struct {
client *http.Client
}
// Ensure the Scanner satisfies the interface at compile time.
var _ detectors.Detector = (*Scanner)(nil)
// GET /api-keys verifies both Resend permission tiers with a single
// read-only request:
// - full_access -> 200
// - sending_access -> 401 with body {"name":"restricted_api_key"}
//
// See https://resend.com/docs/api-reference/api-keys/list-api-keys and
// https://resend.com/docs/api-reference/errors.
const verifyURL = "https://api.resend.com/api-keys"
var (
defaultClient = common.SaneHttpClient()
// Resend API keys: prefix `re_`, 8 chars of base58,
// underscore, then 24 chars from the same alphabet.
keyPat = regexp.MustCompile(`\b(re_[1-9A-HJ-NP-Za-km-z]{8}_[1-9A-HJ-NP-Za-km-z]{24})\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{"re_"}
}
// FromData will find and optionally verify Resend 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)
uniqueMatches := make(map[string]struct{})
for _, match := range keyPat.FindAllStringSubmatch(dataStr, -1) {
uniqueMatches[match[1]] = struct{}{}
}
for token := range uniqueMatches {
s1 := detectors.Result{
DetectorType: detector_typepb.DetectorType_Resend,
Raw: []byte(token),
SecretParts: map[string]string{"key": token},
}
if verify {
client := s.getClient()
isVerified, extraData, verificationErr := verifyMatch(ctx, client, token)
s1.Verified = isVerified
s1.ExtraData = extraData
s1.SetVerificationError(verificationErr, token)
}
results = append(results, s1)
}
return results, nil
}
func (s Scanner) getClient() *http.Client {
if s.client != nil {
return s.client
}
return defaultClient
}
type resendError struct {
Name string `json:"name"`
Message string `json:"message"`
}
func verifyMatch(ctx context.Context, client *http.Client, token string) (bool, map[string]string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, verifyURL, nil)
if err != nil {
return false, nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
res, err := client.Do(req)
if err != nil {
return false, nil, err
}
defer func() {
_, _ = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
}()
extraData := map[string]string{
"rotation_guide": "https://resend.com/docs/dashboard/api-keys/introduction",
}
switch res.StatusCode {
case http.StatusOK:
// Active key with `full_access` permission.
extraData["permission"] = "full_access"
return true, extraData, nil
case http.StatusUnauthorized:
// Resend returns 401 for two distinct cases. We must look at the
// error name to tell them apart:
// - restricted_api_key: active `sending_access` key trying to
// reach a non-send route. The key is real and can send mail.
// - missing_api_key: no Authorization header (should not
// happen here since we set one above).
var body resendError
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
return false, nil, fmt.Errorf("failed to decode 401 response body: %w", err)
}
if body.Name == "restricted_api_key" {
extraData["permission"] = "sending_access"
return true, extraData, nil
}
return false, nil, nil
case http.StatusBadRequest, http.StatusForbidden:
// 400 validation_error: unknown, malformed, or soft-deleted key.
// 403 suspended_api_key / restricted_api_key ("not active"):
// the key exists but is already disabled; no rotation needed.
return false, nil, nil
default:
return false, nil, fmt.Errorf("unexpected HTTP response status %d", res.StatusCode)
}
}
func (s Scanner) Type() detector_typepb.DetectorType {
return detector_typepb.DetectorType_Resend
}
func (s Scanner) Description() string {
return "Resend is an email API for transactional and marketing email. Resend API keys can be used to send email, manage domains and audiences, and access other resources on the account."
}
@@ -0,0 +1,188 @@
//go:build detectors
// +build detectors
package resend
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/detector_typepb"
)
func TestResend_FromChunk(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors5")
if err != nil {
t.Fatalf("could not get test secrets from GCP: %s", err)
}
secret := testSecrets.MustGetField("RESEND_API_KEY")
sendingAccessSecret := testSecrets.MustGetField("RESEND_API_KEY_SENDING_ACCESS")
inactiveSecret := testSecrets.MustGetField("RESEND_API_KEY_INACTIVE")
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 (full_access)",
s: Scanner{},
args: args{
ctx: context.Background(),
data: []byte(fmt.Sprintf("You can find a resend secret %s within", secret)),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detector_typepb.DetectorType_Resend,
Verified: true,
ExtraData: map[string]string{
"permission": "full_access",
"rotation_guide": "https://resend.com/docs/dashboard/api-keys/introduction",
},
},
},
wantErr: false,
wantVerificationErr: false,
},
{
name: "found, verified (sending_access)",
s: Scanner{},
args: args{
ctx: context.Background(),
data: []byte(fmt.Sprintf("You can find a resend secret %s within", sendingAccessSecret)),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detector_typepb.DetectorType_Resend,
Verified: true,
ExtraData: map[string]string{
"permission": "sending_access",
"rotation_guide": "https://resend.com/docs/dashboard/api-keys/introduction",
},
},
},
wantErr: false,
wantVerificationErr: false,
},
{
name: "found, unverified",
s: Scanner{},
args: args{
ctx: context.Background(),
data: []byte(fmt.Sprintf("You can find a resend secret %s within but not valid", inactiveSecret)),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detector_typepb.DetectorType_Resend,
Verified: false,
},
},
wantErr: false,
wantVerificationErr: false,
},
{
name: "not found",
s: Scanner{},
args: args{
ctx: context.Background(),
data: []byte("You cannot find the secret within"),
verify: true,
},
want: nil,
wantErr: false,
wantVerificationErr: false,
},
{
name: "found, would be verified if not for timeout",
s: Scanner{client: common.SaneHttpClientTimeOut(1 * time.Microsecond)},
args: args{
ctx: context.Background(),
data: []byte(fmt.Sprintf("You can find a resend secret %s within", secret)),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detector_typepb.DetectorType_Resend,
Verified: false,
},
},
wantErr: false,
wantVerificationErr: true,
},
{
name: "found, verified but unexpected api surface",
s: Scanner{client: common.ConstantResponseHttpClient(404, "")},
args: args{
ctx: context.Background(),
data: []byte(fmt.Sprintf("You can find a resend secret %s within", secret)),
verify: true,
},
want: []detectors.Result{
{
DetectorType: detector_typepb.DetectorType_Resend,
Verified: false,
},
},
wantErr: false,
wantVerificationErr: true,
},
}
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.Errorf("Resend.FromData() error = %v, wantErr %v", err, tt.wantErr)
return
}
for i := range got {
if len(got[i].Raw) == 0 {
t.Fatalf("no raw secret present: \n %+v", got[i])
}
if (got[i].VerificationError() != nil) != tt.wantVerificationErr {
t.Errorf("Resend.FromData() error = %v, wantVerificationErr %v", got[i].VerificationError(), tt.wantVerificationErr)
}
}
ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "verificationError", "SecretParts")
ignoreUnexported := cmpopts.IgnoreUnexported(detectors.Result{})
if diff := cmp.Diff(got, tt.want, ignoreOpts, ignoreUnexported); diff != "" {
t.Errorf("Resend.FromData() %s diff: (-got +want)\n%s", tt.name, diff)
}
})
}
}
func BenchmarkFromData(benchmark *testing.B) {
ctx := context.Background()
s := Scanner{}
for name, data := range detectors.MustGetBenchmarkData() {
benchmark.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)
}
}
})
}
}
+91
View File
@@ -0,0 +1,91 @@
package resend
import (
"context"
"fmt"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick"
)
var (
validPattern = "re_1234abcd_aBcDeFgHiJkLmNoPqRsTuVwX"
invalidPattern = "re_1234abcd_aBcDeFgHiJk0OIlmNoPqRsTu"
keyword = "resend"
)
func TestResend_Pattern(t *testing.T) {
d := Scanner{}
ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d})
tests := []struct {
name string
input string
want []string
}{
{
name: "valid pattern - with keyword resend",
input: fmt.Sprintf("%s token = '%s'", keyword, validPattern),
want: []string{validPattern},
},
{
name: "valid pattern - ignore duplicate",
input: fmt.Sprintf("%s token = '%s' | '%s'", keyword, validPattern, validPattern),
want: []string{validPattern},
},
{
name: "invalid pattern - contains disallowed base58 chars (0/O/I/l)",
input: fmt.Sprintf("%s = '%s'", keyword, invalidPattern),
want: []string{},
},
{
name: "invalid pattern - wrong length",
input: fmt.Sprintf("%s = 're_1234abcd_short'", keyword),
want: []string{},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
matchedDetectors := ahoCorasickCore.FindDetectorMatches([]byte(test.input))
if len(matchedDetectors) == 0 {
t.Errorf("keywords '%v' not matched by: %s", d.Keywords(), test.input)
return
}
results, err := d.FromData(context.Background(), false, []byte(test.input))
if err != nil {
t.Errorf("error = %v", err)
return
}
if len(results) != len(test.want) {
if len(results) == 0 {
t.Errorf("did not receive result")
} else {
t.Errorf("expected %d results, only received %d", len(test.want), len(results))
}
return
}
actual := make(map[string]struct{}, len(results))
for _, r := range results {
if len(r.RawV2) > 0 {
actual[string(r.RawV2)] = struct{}{}
} else {
actual[string(r.Raw)] = struct{}{}
}
}
expected := make(map[string]struct{}, len(test.want))
for _, v := range test.want {
expected[v] = struct{}{}
}
if diff := cmp.Diff(expected, actual); diff != "" {
t.Errorf("%s diff: (-want +got)\n%s", test.name, diff)
}
})
}
}
+4
View File
@@ -645,6 +645,7 @@ import (
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/replicate"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/replyio"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/requestfinance"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/resend"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/restpackhtmltopdfapi"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/restpackscreenshotapi"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/rev"
@@ -1567,6 +1568,7 @@ func buildDetectorList() []detectors.Detector {
&replicate.Scanner{},
&replyio.Scanner{},
&requestfinance.Scanner{},
&resend.Scanner{},
// &restpack.Scanner{},
&restpackhtmltopdfapi.Scanner{},
&restpackscreenshotapi.Scanner{},
@@ -1898,6 +1900,8 @@ func buildDetectorList() []detectors.Detector {
return !feature.SolarwindsDetectorEnabled.Load()
case *humioapitoken.Scanner:
return !feature.HumioAPITokenDetectorEnabled.Load()
case *resend.Scanner:
return !feature.ResendDetectorEnabled.Load()
case *weightsandbiasesv2.Scanner:
return !feature.WeightsAndBiasesV2DetectorEnabled.Load()
default:
+1
View File
@@ -152,6 +152,7 @@ var excludedFromDefaultList = map[detector_typepb.DetectorType]struct{}{
detector_typepb.DetectorType_NewRelicMobileAppToken: {},
detector_typepb.DetectorType_SolarWindsObservability: {},
detector_typepb.DetectorType_HumioAPIToken: {},
detector_typepb.DetectorType_Resend: {},
// Reserved / special types.
detector_typepb.DetectorType_CustomRegex: {}, // added dynamically via engine config, not via buildDetectorList()
+1
View File
@@ -50,6 +50,7 @@ var (
NewRelicMobileAppTokenDetectorEnabled atomic.Bool
MSTeamsWebhookV2DetectorEnabled atomic.Bool
SolarwindsDetectorEnabled atomic.Bool
ResendDetectorEnabled atomic.Bool
WeightsAndBiasesV2DetectorEnabled atomic.Bool
HumioAPITokenDetectorEnabled atomic.Bool
)
+11 -7
View File
@@ -1126,6 +1126,7 @@ const (
DetectorType_NewRelicMobileAppToken DetectorType = 1068
DetectorType_SolarWindsObservability DetectorType = 1069
DetectorType_HumioAPIToken DetectorType = 1070
DetectorType_Resend DetectorType = 1071
)
// Enum value maps for DetectorType.
@@ -2198,6 +2199,7 @@ var (
1068: "NewRelicMobileAppToken",
1069: "SolarWindsObservability",
1070: "HumioAPIToken",
1071: "Resend",
}
DetectorType_value = map[string]int32{
"Alibaba": 0,
@@ -3267,6 +3269,7 @@ var (
"NewRelicMobileAppToken": 1068,
"SolarWindsObservability": 1069,
"HumioAPIToken": 1070,
"Resend": 1071,
}
)
@@ -3302,7 +3305,7 @@ var File_detector_type_proto protoreflect.FileDescriptor
var file_detector_type_proto_rawDesc = []byte{
0x0a, 0x13, 0x64, 0x65, 0x74, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x2e,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0d, 0x64, 0x65, 0x74, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f,
0x74, 0x79, 0x70, 0x65, 0x2a, 0x87, 0x8c, 0x01, 0x0a, 0x0c, 0x44, 0x65, 0x74, 0x65, 0x63, 0x74,
0x74, 0x79, 0x70, 0x65, 0x2a, 0x94, 0x8c, 0x01, 0x0a, 0x0c, 0x44, 0x65, 0x74, 0x65, 0x63, 0x74,
0x6f, 0x72, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x41, 0x6c, 0x69, 0x62, 0x61, 0x62,
0x61, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x41, 0x4d, 0x51, 0x50, 0x10, 0x01, 0x12, 0x07, 0x0a,
0x03, 0x41, 0x57, 0x53, 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x41, 0x7a, 0x75, 0x72, 0x65, 0x10,
@@ -4422,12 +4425,13 @@ var file_detector_type_proto_rawDesc = []byte{
0x6c, 0x65, 0x41, 0x70, 0x70, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x10, 0xac, 0x08, 0x12, 0x1c, 0x0a,
0x17, 0x53, 0x6f, 0x6c, 0x61, 0x72, 0x57, 0x69, 0x6e, 0x64, 0x73, 0x4f, 0x62, 0x73, 0x65, 0x72,
0x76, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x10, 0xad, 0x08, 0x12, 0x12, 0x0a, 0x0d, 0x48,
0x75, 0x6d, 0x69, 0x6f, 0x41, 0x50, 0x49, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x10, 0xae, 0x08, 0x42,
0x41, 0x5a, 0x3f, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x74, 0x72,
0x75, 0x66, 0x66, 0x6c, 0x65, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x2f, 0x74, 0x72,
0x75, 0x66, 0x66, 0x6c, 0x65, 0x68, 0x6f, 0x67, 0x2f, 0x76, 0x33, 0x2f, 0x70, 0x6b, 0x67, 0x2f,
0x70, 0x62, 0x2f, 0x64, 0x65, 0x74, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x74, 0x79, 0x70, 0x65,
0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
0x75, 0x6d, 0x69, 0x6f, 0x41, 0x50, 0x49, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x10, 0xae, 0x08, 0x12,
0x0b, 0x0a, 0x06, 0x52, 0x65, 0x73, 0x65, 0x6e, 0x64, 0x10, 0xaf, 0x08, 0x42, 0x41, 0x5a, 0x3f,
0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x74, 0x72, 0x75, 0x66, 0x66,
0x6c, 0x65, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x2f, 0x74, 0x72, 0x75, 0x66, 0x66,
0x6c, 0x65, 0x68, 0x6f, 0x67, 0x2f, 0x76, 0x33, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x62, 0x2f,
0x64, 0x65, 0x74, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x70, 0x62, 0x62,
0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
+1
View File
@@ -1072,4 +1072,5 @@ enum DetectorType {
NewRelicMobileAppToken = 1068;
SolarWindsObservability = 1069;
HumioAPIToken = 1070;
Resend = 1071;
}